Guthaben: "von wie vielen" anzeigen und Zugaben zuruecknehmen

In den Kaesten steht jetzt neben dem offenen Stand, wie viel jemals in den
Topf kam: "0 von 3" liest sich anders als "0 von 0" - das eine heisst
aufgebraucht, das andere nie etwas gehabt. Gezaehlt werden dabei nur echte
Zugaenge: Die Ruecknahme eines Verbrauchs zaehlt dorthin, wo die stornierte
Zeile zaehlte, sonst saehe ein zurueckgenommenes Redesign wie ein
zusaetzliches Paket aus.

Neben jedem Plus steht ein Minus, das die zuletzt von Hand eingetragene
Zugabe dieser Art zurueckholt - fuer den vertippten oder doppelten Klick,
ohne in der Historie nach der richtigen Zeile zu suchen. Zurueckgenommen wird
ueber eine Gegenbuchung; bei einem CC-Paket gehen beide Zeilen mit. Aus einem
Import gebuchte Pakete bleiben aussen vor: die gehoeren zu einer Bestellung
und werden dort storniert, wo der Zusammenhang sichtbar ist. Die Rechte
folgen dem Eintragen - Kulanz bleibt bei den Admins.

Nebenbei zwei wacklige Tests festgezurrt: Die Testdatenbanken weichen auf den
naechsten Port aus, wenn einer aus einem frueheren Lauf noch haengt, und der
Sperr-Test des Abgleichs klopft nicht mehr bei GExtension an, sondern laeuft
gegen eine tote Adresse.
This commit is contained in:
2026-09-21 18:50:27 +02:00
parent 2d1c008c0a
commit f7b48a9891
11 changed files with 397 additions and 38 deletions
+14
View File
@@ -100,6 +100,20 @@ An jedem Eintrag hängen beliebig viele Verweise - das Steam-Addon des Ersteller
eine Absprache. Eingetragen wird ein **Name** und eine **Adresse**; in der Liste steht dann der eine Absprache. Eingetragen wird ein **Name** und eine **Adresse**; in der Liste steht dann der
Name als Link. Ändern und entfernen geht über denselben Knopf *Verweise*. Name als Link. Ändern und entfernen geht über denselben Knopf *Verweise*.
## Guthaben von Hand eintragen
Auf der Spielerseite stehen drei Paare: **+ CC-Paket**, **+ Redesign-Paket**, **+ Kulanz** - und
daneben je ein **−**, das die zuletzt von Hand eingetragene Zugabe dieser Art zurücknimmt. Für
den vertippten oder doppelten Klick, ohne in der Historie nach der richtigen Zeile zu suchen.
Zurückgenommen wird über eine Gegenbuchung; in der Historie bleibt beides stehen. Bei einem
CC-Paket gehen beide Zeilen mit zurück, das CC-Guthaben und das darin enthaltene Redesign.
**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.
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.
## Gruppen-CCs ## Gruppen-CCs
Manche CCs teilen sich mehrere Spieler. Für jeden gelten dabei dieselben Regeln wie für einen Manche CCs teilen sich mehrere Spieler. Für jeden gelten dabei dieselben Regeln wie für einen
+35 -1
View File
@@ -1,12 +1,13 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { CreditKind } from '@prisma/client'; import { CreditKind, CreditSource } from '@prisma/client';
import { requireAdmin, requireEditor } from '../auth/guard.js'; import { requireAdmin, requireEditor } from '../auth/guard.js';
import { import {
bookAdjustment, bookAdjustment,
bookCcPackage, bookCcPackage,
bookKulanz, bookKulanz,
bookRedesignPackage, bookRedesignPackage,
removeLastGrant,
reverseGroup, reverseGroup,
} from '../services/credits.js'; } from '../services/credits.js';
import { contextOf, idParam, optionalNote, parseWith } from './helpers.js'; import { contextOf, idParam, optionalNote, parseWith } from './helpers.js';
@@ -38,6 +39,39 @@ export async function creditRoutes(app: FastifyInstance): Promise<void> {
return bookKulanz(id, contextOf(request), { note: body.note }); return bookKulanz(id, contextOf(request), { note: body.note });
}); });
/**
* Nimmt die zuletzt von Hand eingetragene Zugabe zurück.
*
* Die Rechte folgen dem Eintragen: Wer ein Paket eintragen darf, darf es
* auch wieder zurücknehmen; bei Kulanz bleibt beides bei den Admins.
*/
app.post(
'/api/players/:id/credits/cc-package/remove',
{ preHandler: requireEditor },
async (request) => {
const { id } = parseWith(idParam, request.params);
return removeLastGrant(id, CreditSource.CC_PACKAGE, contextOf(request));
},
);
app.post(
'/api/players/:id/credits/redesign-package/remove',
{ preHandler: requireEditor },
async (request) => {
const { id } = parseWith(idParam, request.params);
return removeLastGrant(id, CreditSource.REDESIGN_PACKAGE, contextOf(request));
},
);
app.post(
'/api/players/:id/credits/kulanz/remove',
{ preHandler: requireAdmin },
async (request) => {
const { id } = parseWith(idParam, request.params);
return removeLastGrant(id, CreditSource.KULANZ, contextOf(request));
},
);
app.post('/api/players/:id/credits/adjustment', { preHandler: requireAdmin }, async (request) => { app.post('/api/players/:id/credits/adjustment', { preHandler: requireAdmin }, async (request) => {
const { id } = parseWith(idParam, request.params); const { id } = parseWith(idParam, request.params);
const body = parseWith( const body = parseWith(
+105 -8
View File
@@ -10,27 +10,71 @@ export interface Balances {
REDESIGN_KULANZ: number; REDESIGN_KULANZ: number;
// Für ein Redesign zählen beide Redesign-Töpfe zusammen. // Für ein Redesign zählen beide Redesign-Töpfe zusammen.
redesignTotal: number; redesignTotal: number;
/**
* Wie viel jemals in den Topf kam - Käufe, Kulanz, Korrekturen, abzüglich
* dessen, was davon storniert wurde. Der Stand oben sagt, was noch offen
* ist; das hier sagt, wovon. "0 von 3" liest sich anders als "0 von 0".
*/
granted: { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number };
} }
const EMPTY: Balances = { CC: 0, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0, redesignTotal: 0 }; const EMPTY: Balances = {
CC: 0,
REDESIGN_PAID: 0,
REDESIGN_KULANZ: 0,
redesignTotal: 0,
granted: { CC: 0, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0 },
};
function assemble(sums: Partial<Record<CreditKind, number>>): Balances { function assemble(
sums: Partial<Record<CreditKind, number>>,
granted?: Partial<Record<CreditKind, number>>,
): Balances {
const cc = sums.CC ?? 0; const cc = sums.CC ?? 0;
const paid = sums.REDESIGN_PAID ?? 0; const paid = sums.REDESIGN_PAID ?? 0;
const kulanz = sums.REDESIGN_KULANZ ?? 0; const kulanz = sums.REDESIGN_KULANZ ?? 0;
return { CC: cc, REDESIGN_PAID: paid, REDESIGN_KULANZ: kulanz, redesignTotal: paid + kulanz }; return {
CC: cc,
REDESIGN_PAID: paid,
REDESIGN_KULANZ: kulanz,
redesignTotal: paid + kulanz,
granted: {
CC: granted?.CC ?? 0,
REDESIGN_PAID: granted?.REDESIGN_PAID ?? 0,
REDESIGN_KULANZ: granted?.REDESIGN_KULANZ ?? 0,
},
};
} }
export async function getBalances(playerId: number): Promise<Balances> { export async function getBalances(playerId: number): Promise<Balances> {
const rows = await prisma.creditLedger.groupBy({ // Für den Stand genügte eine Summe je Topf. Für "von wie vielen" muss
by: ['kind'], // jede Zeile angesehen werden: ein Storno zählt dorthin, wohin die Zeile
// zählte, die es zurücknimmt - sonst sähe die Rücknahme eines Verbrauchs
// wie ein zusätzliches Paket aus.
const eintraege = await prisma.creditLedger.findMany({
where: { playerId }, where: { playerId },
_sum: { amount: true }, select: {
kind: true,
amount: true,
source: true,
reversalOf: { select: { source: true } },
},
}); });
const sums: Partial<Record<CreditKind, number>> = {}; const sums: Partial<Record<CreditKind, number>> = {};
for (const row of rows) sums[row.kind] = row._sum.amount ?? 0; const granted: Partial<Record<CreditKind, number>> = {};
return assemble(sums);
for (const eintrag of eintraege) {
sums[eintrag.kind] = (sums[eintrag.kind] ?? 0) + eintrag.amount;
const zweck =
eintrag.source === CreditSource.REVERSAL ? eintrag.reversalOf?.source : eintrag.source;
if (zweck !== CreditSource.CONSUME) {
granted[eintrag.kind] = (granted[eintrag.kind] ?? 0) + eintrag.amount;
}
}
return assemble(sums, granted);
} }
export async function getBalancesForMany(playerIds: number[]): Promise<Map<number, Balances>> { export async function getBalancesForMany(playerIds: number[]): Promise<Map<number, Balances>> {
@@ -243,6 +287,59 @@ export async function bookAdjustment(
* bleibt vollständig lesbar. Verbrauchszeilen sind ausgenommen, die werden * bleibt vollständig lesbar. Verbrauchszeilen sind ausgenommen, die werden
* über die zugehörige CC-Aktion zurückgenommen. * über die zugehörige CC-Aktion zurückgenommen.
*/ */
/// Was sich über die Knöpfe auf der Spielerseite eintragen lässt.
const HANDEINTRAEGE = [
CreditSource.CC_PACKAGE,
CreditSource.REDESIGN_PACKAGE,
CreditSource.KULANZ,
] as const;
export type Handeintrag = (typeof HANDEINTRAEGE)[number];
/**
* Nimmt die zuletzt von Hand eingetragene Zugabe wieder zurück.
*
* Wer sich vertippt oder zweimal geklickt hat, soll das geradeziehen können,
* ohne in der Historie nach der richtigen Zeile zu suchen. Zurückgenommen
* wird die jüngste offene Zugabe dieser Art - über eine Gegenbuchung, wie
* jede Korrektur hier. Bei einem CC-Paket sind das beide Zeilen: das
* CC-Guthaben und das Redesign, das darin steckt.
*
* Eingetragen aus einem Import wird dabei ausgelassen. Ein gebuchtes Paket
* gehört zu einer Bestellung; wer das zurücknehmen will, tut es dort, wo der
* Zusammenhang sichtbar ist - in der Historie.
*/
export async function removeLastGrant(
playerId: number,
source: Handeintrag,
ctx: ActionContext,
): Promise<{ balances: Balances; reversed: number }> {
await requirePlayer(playerId);
if (!HANDEINTRAEGE.includes(source)) {
throw badRequest('INVALID_SOURCE', 'Diese Art lässt sich nicht von Hand zurücknehmen');
}
const juengste = await prisma.creditLedger.findFirst({
where: { playerId, source, importId: null, reversedBy: null },
orderBy: { id: 'desc' },
});
if (!juengste) {
const was =
source === CreditSource.CC_PACKAGE
? 'CC-Paket'
: source === CreditSource.REDESIGN_PACKAGE
? 'Redesign-Paket'
: 'Kulanz';
throw conflict(
'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.`,
);
}
return reverseGroup(juengste.id, 'Von Hand zurückgenommen', ctx);
}
export async function reverseGroup( export async function reverseGroup(
ledgerId: number, ledgerId: number,
reason: string, reason: string,
+25 -3
View File
@@ -31,13 +31,35 @@ export async function startTestDatabase(port: number): Promise<TestDatabase> {
await db.exec(readFileSync(file, 'utf8')); await db.exec(readFileSync(file, 'utf8'));
} }
const server = new PGLiteSocketServer({ db, port, host: '127.0.0.1' }); // Die Testdateien laufen nebeneinander und bekommen feste Ports. Hängt aus
await server.start(); // einem vorherigen Lauf noch einer fest, wäre der Start sonst ohne Grund
// fehlgeschlagen - deshalb ein paar Versuche mit dem nächsten Port.
let server: PGLiteSocketServer | null = null;
let benutzt = port;
for (let versuch = 0; versuch < 20; versuch += 1) {
const kandidat = new PGLiteSocketServer({ db, port: benutzt, host: '127.0.0.1' });
try {
await kandidat.start();
server = kandidat;
break;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'EADDRINUSE') throw error;
benutzt += 1;
}
}
if (!server) {
throw new Error(`Kein freier Port ab ${port} für die Testdatenbank gefunden`);
}
const belegterPort = benutzt;
return { return {
// connection_limit=1, weil die Testinstanz immer nur eine Verbindung // connection_limit=1, weil die Testinstanz immer nur eine Verbindung
// gleichzeitig bedient. Echtes Postgres braucht die Einschränkung nicht. // gleichzeitig bedient. Echtes Postgres braucht die Einschränkung nicht.
url: `postgresql://postgres:postgres@127.0.0.1:${port}/postgres?connection_limit=1`, url: `postgresql://postgres:postgres@127.0.0.1:${belegterPort}/postgres?connection_limit=1`,
stop: async () => { stop: async () => {
await server.stop(); await server.stop();
await db.close(); await db.close();
+2
View File
@@ -385,6 +385,8 @@ describe('Guthaben über die API', () => {
REDESIGN_PAID: 1, REDESIGN_PAID: 1,
REDESIGN_KULANZ: 0, REDESIGN_KULANZ: 0,
redesignTotal: 1, redesignTotal: 1,
// Was offen ist, und wovon: "1 von 1".
granted: { CC: 1, REDESIGN_PAID: 1, REDESIGN_KULANZ: 0 },
}); });
}); });
+90
View File
@@ -314,3 +314,93 @@ describe('Korrekturen', () => {
); );
}); });
}); });
describe('Von Hand eingetragene Guthaben zurücknehmen', () => {
it('zählt, wie viel jemals in den Topf kam', async () => {
const playerId = await freshPlayer('Zaehler');
await credits.bookCcPackage(playerId, ctx);
await credits.bookCcPackage(playerId, ctx);
await characters.createCharacter(playerId, { name: 'Verbraucht eines' }, ctx);
const stand = await credits.getBalances(playerId);
assert.equal(stand.CC, 1, 'eines ist noch offen');
assert.equal(stand.granted.CC, 2, 'zwei kamen jemals herein');
assert.equal(stand.granted.REDESIGN_PAID, 2, 'jedes Paket bringt eines mit');
});
it('zählt die Rücknahme eines Verbrauchs nicht als Zugang', async () => {
// Sonst sähe ein storniertes Redesign wie ein zusätzliches Paket aus.
const playerId = await freshPlayer('Stornierer');
await credits.bookCcPackage(playerId, ctx);
const cc = await characters.createCharacter(playerId, { name: 'Wird zurückgenommen' }, ctx);
await characters.revertCharacter(cc.character.id, 'Fehleintrag', ctx);
const stand = await credits.getBalances(playerId);
assert.equal(stand.CC, 1, 'das Guthaben ist zurück');
assert.equal(stand.granted.CC, 1, 'hereingekommen ist trotzdem nur eines');
});
it('nimmt das zuletzt eingetragene CC-Paket zurück, beide Zeilen', async () => {
const playerId = await freshPlayer('Vertipper');
await credits.bookCcPackage(playerId, ctx);
await credits.bookCcPackage(playerId, ctx);
const { balances } = await credits.removeLastGrant(playerId, 'CC_PACKAGE', ctx);
assert.equal(balances.CC, 1);
assert.equal(balances.REDESIGN_PAID, 1, 'das mitgelieferte Redesign geht mit zurück');
assert.equal(balances.granted.CC, 1, 'und zählt auch nicht mehr als erhalten');
});
it('nimmt eine Kulanz zurück', async () => {
const playerId = await freshPlayer('Kulanzfall');
await credits.bookKulanz(playerId, ctx, { note: 'War zu grosszügig' });
const { balances } = await credits.removeLastGrant(playerId, 'KULANZ', ctx);
assert.equal(balances.REDESIGN_KULANZ, 0);
assert.equal(balances.granted.REDESIGN_KULANZ, 0);
});
it('meldet sich, wenn es nichts zurückzunehmen gibt', async () => {
const playerId = await freshPlayer('Leerer Topf');
await assert.rejects(
() => credits.removeLastGrant(playerId, 'CC_PACKAGE', ctx),
(error: Error & { code?: string }) => error.code === 'NOTHING_TO_REMOVE',
);
});
it('lässt gebuchte Import-Pakete in Ruhe', async () => {
// Ein Paket aus einer Bestellung gehört in die Historie, nicht unter
// einen Knopf, der "das letzte weg" bedeutet.
const playerId = await freshPlayer('Importiert');
const importRecord = await db.prisma.import.create({
data: { filename: 'shop.csv', status: 'COMMITTED', rowCount: 1 },
});
await db.prisma.creditLedger.create({
data: {
playerId,
kind: 'CC',
amount: 1,
source: 'CC_PACKAGE',
importId: importRecord.id,
},
});
await assert.rejects(
() => credits.removeLastGrant(playerId, 'CC_PACKAGE', ctx),
(error: Error & { code?: string }) => error.code === 'NOTHING_TO_REMOVE',
);
});
it('nimmt dieselbe Zugabe nicht zweimal zurück', async () => {
const playerId = await freshPlayer('Doppelklicker');
await credits.bookKulanz(playerId, ctx);
await credits.removeLastGrant(playerId, 'KULANZ', ctx);
await assert.rejects(
() => credits.removeLastGrant(playerId, 'KULANZ', ctx),
(error: Error & { code?: string }) => error.code === 'NOTHING_TO_REMOVE',
);
});
});
+4
View File
@@ -21,6 +21,10 @@ before(async () => {
process.env.DATABASE_URL = database.url; process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000'; process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
// Der Sperr-Test stösst einen echten Abruf an. Er soll sofort scheitern
// statt in ein Zeitlimit zu laufen - und vor allem nicht wirklich bei
// GExtension anklopfen, nur weil hier Tests laufen.
process.env.GEXTENSION_BASE_URL = 'http://127.0.0.1:1';
db = await import('../src/db.js'); db = await import('../src/db.js');
schedule = await import('../src/services/schedule.js'); schedule = await import('../src/services/schedule.js');
+23 -6
View File
@@ -1,11 +1,19 @@
import type { Balances } from '../lib/types'; import type { Balances } from '../lib/types';
function Card({ label, value, hint }: { label: string; value: number; hint?: string }) { /**
* Ein Topf: was noch offen ist, und wovon.
*
* "0 von 3" liest sich anders als "0 von 0" - das eine heisst aufgebraucht,
* das andere nie etwas gehabt. Ohne die zweite Zahl sehen beide gleich aus.
*/
function Card({ label, value, granted }: { label: string; value: number; granted: number }) {
return ( return (
<div className={`balance-card${value < 0 ? ' is-negative' : ''}`}> <div className={`balance-card${value < 0 ? ' is-negative' : ''}`}>
<span className="balance-label">{label}</span> <span className="balance-label">{label}</span>
<span className="balance-value">{value}</span> <span className="balance-value">
{hint && <span className="balance-hint">{hint}</span>} {value}
{granted > 0 && <span className="balance-of"> von {granted}</span>}
</span>
</div> </div>
); );
} }
@@ -13,13 +21,22 @@ function Card({ label, value, hint }: { label: string; value: number; hint?: str
export default function BalanceCards({ balances }: { balances: Balances }) { export default function BalanceCards({ balances }: { balances: Balances }) {
const canCreate = balances.CC >= 1; const canCreate = balances.CC >= 1;
const canRedesign = balances.redesignTotal >= 1; const canRedesign = balances.redesignTotal >= 1;
const granted = balances.granted ?? { CC: 0, REDESIGN_PAID: 0, REDESIGN_KULANZ: 0 };
return ( return (
<section className="balances" aria-label="Guthaben"> <section className="balances" aria-label="Guthaben">
<div className="balance-grid"> <div className="balance-grid">
<Card label="CC-Guthaben" value={balances.CC} /> <Card label="CC-Guthaben" value={balances.CC} granted={granted.CC} />
<Card label="Redesign (bezahlt)" value={balances.REDESIGN_PAID} /> <Card
<Card label="Redesign (Kulanz)" value={balances.REDESIGN_KULANZ} /> label="Redesign (bezahlt)"
value={balances.REDESIGN_PAID}
granted={granted.REDESIGN_PAID}
/>
<Card
label="Redesign (Kulanz)"
value={balances.REDESIGN_KULANZ}
granted={granted.REDESIGN_KULANZ}
/>
</div> </div>
<div className="eligibility"> <div className="eligibility">
+2
View File
@@ -29,6 +29,8 @@ export interface Balances {
REDESIGN_PAID: number; REDESIGN_PAID: number;
REDESIGN_KULANZ: number; REDESIGN_KULANZ: number;
redesignTotal: number; redesignTotal: number;
/// Wie viel jemals in den Topf kam, abzüglich stornierter Zugänge.
granted?: { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number };
} }
export interface PlayerSummary { export interface PlayerSummary {
+71 -20
View File
@@ -101,6 +101,22 @@ export default function PlayerDetail() {
} }
}; };
/**
* Nimmt die zuletzt von Hand eingetragene Zugabe zurück.
*
* Zurückgenommen wird über eine Gegenbuchung - in der Historie bleibt
* beides stehen. Gebuchte Import-Pakete rührt das nicht an; die gehören zu
* einer Bestellung und werden dort storniert, wo der Zusammenhang steht.
*/
const zugabeZuruecknehmen = async (pfad: string, was: string) => {
if (!window.confirm(`Zuletzt eingetragenes ${was} zurücknehmen?`)) return;
await run(
() =>
api(`/api/players/${playerId}/credits/${pfad}/remove`, { method: 'POST', body: {} }),
`${was} zurückgenommen.`,
);
};
// 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.
@@ -259,32 +275,67 @@ export default function PlayerDetail() {
{editor && ( {editor && (
<section className="action-bar"> <section className="action-bar">
<button <span className="credit-pair">
type="button"
className="btn"
disabled={busy}
onClick={() => setDialog({ type: 'addCredit', kind: 'cc-package' })}
>
+ CC-Paket
</button>
<button
type="button"
className="btn"
disabled={busy}
onClick={() => setDialog({ type: 'addCredit', kind: 'redesign-package' })}
>
+ Redesign-Paket
</button>
{/* Kulanz vergibt das Team, sie steht in keinem Kauf - daher nur Admins. */}
{can('ADMIN') && (
<button <button
type="button" type="button"
className="btn" className="btn"
disabled={busy} disabled={busy}
onClick={() => setDialog({ type: 'addCredit', kind: 'kulanz' })} onClick={() => setDialog({ type: 'addCredit', kind: 'cc-package' })}
> >
+ Kulanz + CC-Paket
</button> </button>
<button
type="button"
className="btn btn-ghost"
disabled={busy}
title="Das zuletzt von Hand eingetragene CC-Paket zurücknehmen"
onClick={() => void zugabeZuruecknehmen('cc-package', 'CC-Paket')}
>
−
</button>
</span>
<span className="credit-pair">
<button
type="button"
className="btn"
disabled={busy}
onClick={() => setDialog({ type: 'addCredit', kind: 'redesign-package' })}
>
+ Redesign-Paket
</button>
<button
type="button"
className="btn btn-ghost"
disabled={busy}
title="Das zuletzt von Hand eingetragene Redesign-Paket zurücknehmen"
onClick={() => void zugabeZuruecknehmen('redesign-package', 'Redesign-Paket')}
>
−
</button>
</span>
{/* Kulanz vergibt das Team, sie steht in keinem Kauf - daher nur Admins. */}
{can('ADMIN') && (
<span className="credit-pair">
<button
type="button"
className="btn"
disabled={busy}
onClick={() => setDialog({ type: 'addCredit', kind: 'kulanz' })}
>
+ Kulanz
</button>
<button
type="button"
className="btn btn-ghost"
disabled={busy}
title="Die zuletzt von Hand eingetragene Kulanz zurücknehmen"
onClick={() => void zugabeZuruecknehmen('kulanz', 'Kulanz')}
>
−
</button>
</span>
)} )}
<button <button
type="button" type="button"
+26
View File
@@ -1260,3 +1260,29 @@ textarea {
display: block; display: block;
margin-top: 0.2rem; margin-top: 0.2rem;
} }
/* "3 von 5": der zweite Teil ist Beiwerk und tritt zurück. */
.balance-of {
font-size: 0.55em;
font-weight: 400;
color: var(--text-muted);
margin-left: 0.3rem;
}
/* Hinzufügen und Zurücknehmen gehören zusammen und sehen auch so aus. */
.credit-pair {
display: inline-flex;
}
.credit-pair .btn:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.credit-pair .btn:last-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: none;
padding-left: 0.6rem;
padding-right: 0.6rem;
}