Antraege durchsuchbar, Fortschritt sichtbar, Spieler an einem Stueck

Der Abgleich zeigt jetzt, was er tut. Gelesen wird zuerst von jedem Board die
erste Seite - danach steht der Umfang fest, und der Balken zeigt einen
ehrlichen Anteil statt einer Schaetzung. Ein Protokoll schreibt Seite fuer
Seite mit.

Beide Antragslisten lassen sich durchsuchen und wahlweise mit dem neuesten
oder dem aeltesten Antrag zuerst anzeigen. Die Liste ohne zugeordneten
Spieler blaettert seitenweise, statt bei fuenfhundert Eintraegen die ersten
fuenfzig zu zeigen und den Rest zu verschweigen.

Neu darueber: "Ersteller ohne Spieler" - dieselben Antraege, nach Ersteller
zusammengefasst. Wer dreiundzwanzig Antraege gestellt hat, faellt dort auf,
waehrend er zwischen fuenfhundert Einzelantraegen untergeht. Ein Klick legt
den Spieler mit bereits eingetragenem Forum-Namen an.

Ein Spieler laesst sich jetzt an einem Stueck abarbeiten, statt zwischen drei
Seiten zu wechseln:

- Seine Forums-Antraege stehen auf der Spielerseite, aeltester zuerst, jeder
  als Link ins Forum. Offene sind dort direkt entscheidbar, ueber denselben
  Weg wie auf der Antraege-Seite - danach stehen sie dort nicht mehr offen.
- Pakete, die noch in einer Import-Vorschau liegen, erscheinen unter "Offene
  Pakete aus dem Abgleich" und lassen sich einzeln oder am Stueck buchen.
  Gebuchtes wird ueberall abgehakt, wo dieselbe Bestellung noch offen liegt,
  auch in einem zweiten Import - doppelt buchen ist ausgeschlossen. Wer ein
  Paket gar nicht buchen will, legt es weg, ohne das Konto zu beruehren.

Die beiden Antrags-Dialoge liegen dafuer jetzt unter components/, weil beide
Seiten sie brauchen.
This commit is contained in:
2026-09-21 00:49:38 +02:00
parent e65fdfc0ad
commit 293d882cb4
16 changed files with 1655 additions and 431 deletions
+27 -3
View File
@@ -234,6 +234,14 @@ Steam- oder Forumsaccount gewechselt hat, behält so seine Ansprüche. Steht der
landet der Antrag unter *Ohne zugeordneten Spieler*: erfasst und durchsuchbar, aber außerhalb der landet der Antrag unter *Ohne zugeordneten Spieler*: erfasst und durchsuchbar, aber außerhalb der
Arbeitsliste, denn ohne Spieler lässt sich nichts buchen. Von dort ordnest du ihn von Hand zu. Arbeitsliste, denn ohne Spieler lässt sich nichts buchen. Von dort ordnest du ihn von Hand zu.
Darüber steht *Ersteller ohne Spieler*: dieselben Anträge, aber nach Ersteller zusammengefasst.
Wer dreiundzwanzig Anträge gestellt hat, fällt dort auf, während er zwischen fünfhundert
Einzelanträgen untergeht. Ein Klick auf *Spieler anlegen* öffnet das Formular mit bereits
eingetragenem Forum-Namen; danach wandern seine Anträge von selbst in die Arbeitsliste.
Beide Listen lassen sich durchsuchen und wahlweise mit dem neuesten oder dem ältesten Antrag
zuerst anzeigen; die untere blättert seitenweise.
Legst du später einen Spieler mit passendem Forum-Namen an - oder trägst den Namen nach, oder Legst du später einen Spieler mit passendem Forum-Namen an - oder trägst den Namen nach, oder
ergänzt ein früheres Konto -, wandern seine wartenden Anträge **von selbst** in die Arbeitsliste. ergänzt ein früheres Konto -, wandern seine wartenden Anträge **von selbst** in die Arbeitsliste.
Ein erneuter Abgleich ist dafür nicht nötig. Bereits zugeordnete Anträge bleiben dabei unberührt. Ein erneuter Abgleich ist dafür nicht nötig. Bereits zugeordnete Anträge bleiben dabei unberührt.
@@ -241,6 +249,19 @@ Ein erneuter Abgleich ist dafür nicht nötig. Bereits zugeordnete Anträge blei
Ist ein Antrag entschieden, hängt sein Thread am entstandenen CC: auf der Spielerseite führt ein Ist ein Antrag entschieden, hängt sein Thread am entstandenen CC: auf der Spielerseite führt ein
Klick auf den CC-Namen direkt zum Forumsbeitrag. Klick auf den CC-Namen direkt zum Forumsbeitrag.
### Einen Spieler an einem Stück abarbeiten
Auf der Spielerseite stehen seine Anträge unter *Forums-Anträge*, ältester zuerst, jeder als Link
ins Forum. Offene lassen sich dort direkt entscheiden - dieselbe Entscheidung wie auf der
Anträge-Seite, nur ohne den Spieler dort erst suchen zu müssen. Danach steht der Antrag in der
Anträge-Liste nicht mehr offen.
Genauso mit den Käufen: Liegen Pakete dieses Spielers noch in einer Import-Vorschau, erscheinen
sie unter *Offene Pakete aus dem Abgleich* und können einzeln oder am Stück gebucht werden. Was
hier gebucht ist, steht in keiner Vorschau mehr zur Buchung an - auch nicht, wenn dieselbe
Bestellung in einem zweiten Import liegt. Wer ein Paket gar nicht buchen will, legt es weg; es
verschwindet dann aus der Vorschau, ohne das Konto zu berühren.
Angepinnte Ankündigungen (Changelogs, Regelwerke) liegen mit in den Boards. Sie werden über ihre Angepinnte Ankündigungen (Changelogs, Regelwerke) liegen mit in den Boards. Sie werden über ihre
Labels *Wichtig* und *Information* aussortiert. Der Titel wird dagegen **nie** ausgewertet: CC-Anträge Labels *Wichtig* und *Information* aussortiert. Der Titel wird dagegen **nie** ausgewertet: CC-Anträge
heißen mal „CC Antrag | Name", mal „\"The Duke\" Rang Update" - daran ist nichts zu erkennen. heißen mal „CC Antrag | Name", mal „\"The Duke\" Rang Update" - daran ist nichts zu erkennen.
@@ -261,9 +282,12 @@ voller Lauf braucht also rund eine halbe Minute. Bremst das Forum den Zugriff au
angegebene Wartezeit eingehalten, der Abstand für den Rest des Laufs verdoppelt und die Seite angegebene Wartezeit eingehalten, der Abstand für den Rest des Laufs verdoppelt und die Seite
erneut versucht - der Lauf dauert dann länger, liest aber trotzdem alles. erneut versucht - der Lauf dauert dann länger, liest aber trotzdem alles.
Deshalb läuft der Abgleich **im Hintergrund**. Der Knopf zeigt den Fortschritt, und der Lauf Deshalb läuft der Abgleich **im Hintergrund**, mit einem Fortschrittsbalken und einem Protokoll,
geht weiter, auch wenn du die Seite verlässt. Bricht ein Board doch ab, bleibt erhalten, was bis das Seite für Seite mitschreibt. Gelesen wird zuerst von jedem Board die erste Seite - erst
dahin gelesen wurde; die Meldung nennt das Board und die Seite, bis zu der es gereicht hat. danach steht der Umfang fest, und der Balken zeigt einen ehrlichen Anteil statt einer Schätzung.
Der Lauf geht weiter, auch wenn du die Seite verlässt. Bricht ein Board doch ab, bleibt erhalten,
was bis dahin gelesen wurde; die Meldung nennt das Board und die Seite, bis zu der es gereicht
hat.
## Problemfälle ## Problemfälle
+18
View File
@@ -10,6 +10,7 @@ import {
listBoardChangeNotices, listBoardChangeNotices,
listThreads, listThreads,
listUnassignedThreads, listUnassignedThreads,
listUntrackedAuthors,
startScan, startScan,
} from '../services/forum.js'; } from '../services/forum.js';
import { contextOf, idParam, parseWith } from './helpers.js'; import { contextOf, idParam, parseWith } from './helpers.js';
@@ -31,6 +32,8 @@ export async function forumRoutes(app: FastifyInstance): Promise<void> {
open: z.enum(['true', 'false']).default('true'), open: z.enum(['true', 'false']).default('true'),
playerId: z.coerce.number().int().positive().optional(), playerId: z.coerce.number().int().positive().optional(),
board: z.nativeEnum(ForumBoard).optional(), board: z.nativeEnum(ForumBoard).optional(),
q: z.string().trim().max(120).optional(),
order: z.enum(['newest', 'oldest']).default('newest'),
}), }),
request.query, request.query,
); );
@@ -38,8 +41,10 @@ export async function forumRoutes(app: FastifyInstance): Promise<void> {
return { return {
items: await listThreads({ items: await listThreads({
onlyOpen: query.open === 'true', onlyOpen: query.open === 'true',
order: query.order,
...(query.playerId !== undefined ? { playerId: query.playerId } : {}), ...(query.playerId !== undefined ? { playerId: query.playerId } : {}),
...(query.board ? { board: query.board } : {}), ...(query.board ? { board: query.board } : {}),
...(query.q ? { query: query.q } : {}),
}), }),
}; };
}); });
@@ -49,16 +54,29 @@ export async function forumRoutes(app: FastifyInstance): Promise<void> {
z.object({ z.object({
q: z.string().trim().max(120).optional(), q: z.string().trim().max(120).optional(),
take: z.coerce.number().int().min(1).max(200).default(50), take: z.coerce.number().int().min(1).max(200).default(50),
skip: z.coerce.number().int().min(0).default(0),
order: z.enum(['newest', 'oldest']).default('newest'),
}), }),
request.query, request.query,
); );
return listUnassignedThreads({ return listUnassignedThreads({
take: query.take, take: query.take,
skip: query.skip,
order: query.order,
...(query.q ? { query: query.q } : {}), ...(query.q ? { query: query.q } : {}),
}); });
}); });
/** Ersteller aus dem Forum, die hier unter keinem Namen geführt werden. */
app.get('/api/forum/authors/untracked', { preHandler: requireAuth }, async (request) => {
const query = parseWith(
z.object({ take: z.coerce.number().int().min(1).max(500).default(100) }),
request.query,
);
return listUntrackedAuthors(query.take);
});
app.get('/api/forum/notices', { preHandler: requireAuth }, async () => { app.get('/api/forum/notices', { preHandler: requireAuth }, async () => {
return { items: await listBoardChangeNotices() }; return { items: await listBoardChangeNotices() };
}); });
+24
View File
@@ -10,6 +10,8 @@ import {
getImport, getImport,
listImports, listImports,
parseUpload, parseUpload,
bookRows,
dismissRows,
} from '../services/imports.js'; } from '../services/imports.js';
import { contextOf, idParam, parseWith } from './helpers.js'; import { contextOf, idParam, parseWith } from './helpers.js';
@@ -63,6 +65,28 @@ export async function importRoutes(app: FastifyInstance): Promise<void> {
return commitImport(id, contextOf(request)); return commitImport(id, contextOf(request));
}); });
/**
* Einzelne Zeilen sofort buchen, statt auf den ganzen Import zu warten.
* Gedacht für den Weg über die Spielerseite: einen Spieler an einem Stück
* abarbeiten. Gebuchtes verschwindet danach aus der Vorschau.
*/
app.post('/api/imports/rows/book', { preHandler: requireEditor }, async (request) => {
const body = parseWith(
z.object({ rowIds: z.array(z.number().int().positive()).min(1).max(500) }),
request.body,
);
return bookRows(body.rowIds, contextOf(request));
});
/** Zeilen aus der Vorschau nehmen, ohne sie zu buchen. */
app.post('/api/imports/rows/dismiss', { preHandler: requireEditor }, async (request) => {
const body = parseWith(
z.object({ rowIds: z.array(z.number().int().positive()).min(1).max(500) }),
request.body,
);
return dismissRows(body.rowIds, contextOf(request));
});
app.post('/api/imports/:id/discard', { preHandler: requireEditor }, async (request) => { app.post('/api/imports/:id/discard', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params); const { id } = parseWith(idParam, request.params);
await discardImport(id, contextOf(request)); await discardImport(id, contextOf(request));
+126 -27
View File
@@ -14,6 +14,13 @@ export const BOARDS: { board: ForumBoard; path: string }[] = [
{ board: ForumBoard.CC_ARCHIV, path: 'board/113-cc-archiv' }, { board: ForumBoard.CC_ARCHIV, path: 'board/113-cc-archiv' },
]; ];
/// Die Board-Pfade in lesbarer Form, für das Protokoll des Laufs.
const BOARD_NAMES: Record<string, string> = {
'board/66-custom-characters': 'Antragsübersicht',
'board/355-aktive-ccs': 'Aktive CCs',
'board/113-cc-archiv': 'Archiv',
};
const MIN_REQUEST_GAP_MS = 1200; const MIN_REQUEST_GAP_MS = 1200;
/// Bremst das Forum, wird der Abstand verdoppelt, bis hierhin. /// Bremst das Forum, wird der Abstand verdoppelt, bis hierhin.
const MAX_REQUEST_GAP_MS = 10000; const MAX_REQUEST_GAP_MS = 10000;
@@ -118,6 +125,10 @@ export interface ScanState {
/// Woran gerade gelesen wird, für die Anzeige während des Laufs. /// Woran gerade gelesen wird, für die Anzeige während des Laufs.
currentBoard: string | null; currentBoard: string | null;
pagesRead: number; pagesRead: number;
/// Steht fest, sobald von jedem Board die erste Seite gelesen ist. Vorher 0.
totalPages: number;
/// Was der Lauf bisher getan hat, jüngster Eintrag zuletzt.
log: string[];
summary: ForumScanSummary | null; summary: ForumScanSummary | null;
error: string | null; error: string | null;
} }
@@ -136,10 +147,20 @@ const scanState: ScanState = {
finishedAt: null, finishedAt: null,
currentBoard: null, currentBoard: null,
pagesRead: 0, pagesRead: 0,
totalPages: 0,
log: [],
summary: null, summary: null,
error: null, error: null,
}; };
/// Damit ein langer Lauf den Speicher nicht vollschreibt.
const MAX_LOG_LINES = 200;
function protokoll(zeile: string): void {
scanState.log.push(zeile);
if (scanState.log.length > MAX_LOG_LINES) scanState.log.shift();
}
export function getScanState(): ScanState { export function getScanState(): ScanState {
return { ...scanState }; return { ...scanState };
} }
@@ -155,6 +176,8 @@ export function startScan(ctx: ActionContext): ScanState {
scanState.finishedAt = null; scanState.finishedAt = null;
scanState.currentBoard = null; scanState.currentBoard = null;
scanState.pagesRead = 0; scanState.pagesRead = 0;
scanState.totalPages = 0;
scanState.log = [];
scanState.summary = null; scanState.summary = null;
scanState.error = null; scanState.error = null;
@@ -206,40 +229,62 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
// dauern als der Abruf selbst und den Aufruf ins Zeitlimit treiben. // dauern als der Abruf selbst und den Aufruf ins Zeitlimit treiben.
const gefunden = new Map<number, { thread: ForumThread; board: ForumBoard }>(); const gefunden = new Map<number, { thread: ForumThread; board: ForumBoard }>();
// Erst von jedem Board die erste Seite. Danach steht der Umfang des Laufs
// fest, und die Anzeige kann einen ehrlichen Fortschritt zeigen statt zu
// raten, wie viel noch kommt.
const offen: { board: ForumBoard; path: string; seiten: number; gelesen: number }[] = [];
for (const { board, path } of BOARDS) { for (const { board, path } of BOARDS) {
let gelesen = 0;
scanState.currentBoard = path; scanState.currentBoard = path;
protokoll(`${BOARD_NAMES[path] ?? path}: Umfang wird ermittelt`);
try { try {
const first = await fetchPage(path, 1); const first = await fetchPage(path, 1);
gelesen += 1;
summary.scannedPages += 1; summary.scannedPages += 1;
scanState.pagesRead = summary.scannedPages; scanState.pagesRead = summary.scannedPages;
const seiten = Math.min(parsePageCount(first), MAX_PAGES_PER_BOARD);
for (const thread of parseBoard(first)) gefunden.set(thread.threadId, { thread, board }); for (const thread of parseBoard(first)) gefunden.set(thread.threadId, { thread, board });
for (let page = 2; page <= seiten; page += 1) { const seiten = Math.min(parsePageCount(first), MAX_PAGES_PER_BOARD);
const html = await fetchPage(path, page); scanState.totalPages += seiten;
gelesen += 1; offen.push({ board, path, seiten, gelesen: 1 });
protokoll(`${BOARD_NAMES[path] ?? path}: Seite 1 von ${seiten} gelesen`);
} catch (error) {
const grund = error instanceof Error ? error.message : 'Unbekannter Fehler';
summary.failedBoards.push({ board: path, reason: grund, pagesRead: 0 });
protokoll(`${BOARD_NAMES[path] ?? path}: nicht erreichbar (${grund})`);
}
}
for (const eintrag of offen) {
const name = BOARD_NAMES[eintrag.path] ?? eintrag.path;
scanState.currentBoard = eintrag.path;
try {
for (let page = 2; page <= eintrag.seiten; page += 1) {
const html = await fetchPage(eintrag.path, page);
eintrag.gelesen += 1;
summary.scannedPages += 1; summary.scannedPages += 1;
scanState.pagesRead = summary.scannedPages; scanState.pagesRead = summary.scannedPages;
for (const thread of parseBoard(html)) gefunden.set(thread.threadId, { thread, board }); for (const thread of parseBoard(html)) {
gefunden.set(thread.threadId, { thread, board: eintrag.board });
}
protokoll(`${name}: Seite ${page} von ${eintrag.seiten} gelesen`);
} }
summary.scannedBoards += 1; summary.scannedBoards += 1;
protokoll(`${name}: fertig`);
} catch (error) { } catch (error) {
// Was bis hierher gelesen wurde, bleibt erhalten und wird gleich // Was bis hierher gelesen wurde, bleibt erhalten und wird gleich
// verarbeitet - ein Abbruch auf Seite 17 darf nicht die ersten 16 // verarbeitet - ein Abbruch auf Seite 17 darf nicht die ersten 16
// Seiten mit in den Papierkorb nehmen. // Seiten mit in den Papierkorb nehmen.
summary.failedBoards.push({ const grund = error instanceof Error ? error.message : 'Unbekannter Fehler';
board: path, summary.failedBoards.push({ board: eintrag.path, reason: grund, pagesRead: eintrag.gelesen });
reason: error instanceof Error ? error.message : 'Unbekannter Fehler', protokoll(`${name}: abgebrochen nach Seite ${eintrag.gelesen} (${grund})`);
pagesRead: gelesen,
});
} }
} }
summary.rateLimited = rateLimitHits; summary.rateLimited = rateLimitHits;
scanState.currentBoard = null;
protokoll(`${gefunden.size} Threads gelesen, wird gespeichert`);
const relevant: { thread: ForumThread; board: ForumBoard }[] = []; const relevant: { thread: ForumThread; board: ForumBoard }[] = [];
for (const eintrag of gefunden.values()) { for (const eintrag of gefunden.values()) {
@@ -353,6 +398,12 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
}); });
} }
protokoll(
summary.newThreads > 0
? `${summary.newThreads} neue Anträge erfasst`
: 'Keine neuen Anträge',
);
await recordAudit(prisma, ctx, { await recordAudit(prisma, ctx, {
action: 'forum.scan', action: 'forum.scan',
targetType: 'forum', targetType: 'forum',
@@ -388,10 +439,33 @@ async function forumNameIndex(): Promise<Map<string, number>> {
return index; return index;
} }
/// Neueste oder älteste zuerst. Massgeblich ist die Thread-ID: sie waechst
/// mit der Zeit und bleibt stabil, waehrend der Zeitpunkt des letzten Abrufs
/// nach einem Lauf bei allen Threads derselbe ist.
export type ThreadOrder = 'newest' | 'oldest';
function orderOf(order: ThreadOrder | undefined): Prisma.ForumThreadOrderByWithRelationInput {
return { threadId: order === 'oldest' ? 'asc' : 'desc' };
}
/** Freitextsuche über Titel und Ersteller. */
function sucheNach(query: string | undefined): Prisma.ForumThreadWhereInput {
const suche = query?.trim();
if (!suche) return {};
return {
OR: [
{ title: { contains: suche, mode: 'insensitive' } },
{ authorName: { contains: suche, mode: 'insensitive' } },
],
};
}
export async function listThreads(options: { export async function listThreads(options: {
onlyOpen?: boolean; onlyOpen?: boolean;
playerId?: number; playerId?: number;
board?: ForumBoard; board?: ForumBoard;
query?: string;
order?: ThreadOrder;
}) { }) {
return prisma.forumThread.findMany({ return prisma.forumThread.findMany({
where: { where: {
@@ -401,8 +475,9 @@ export async function listThreads(options: {
...(options.onlyOpen ? { needsClassification: true, playerId: { not: null } } : {}), ...(options.onlyOpen ? { needsClassification: true, playerId: { not: null } } : {}),
...(options.playerId !== undefined ? { playerId: options.playerId } : {}), ...(options.playerId !== undefined ? { playerId: options.playerId } : {}),
...(options.board ? { currentBoard: options.board } : {}), ...(options.board ? { currentBoard: options.board } : {}),
...sucheNach(options.query),
}, },
orderBy: [{ lastSeenAt: 'desc' }, { threadId: 'desc' }], orderBy: orderOf(options.order),
include: { include: {
player: { select: { id: true, displayName: true } }, player: { select: { id: true, displayName: true } },
linkedCharacter: { select: { id: true, name: true } }, linkedCharacter: { select: { id: true, name: true } },
@@ -413,30 +488,29 @@ export async function listThreads(options: {
/** /**
* Threads, zu denen kein Spieler gefunden wurde. Das Archiv reicht Jahre * Threads, zu denen kein Spieler gefunden wurde. Das Archiv reicht Jahre
* zurück und enthält hunderte Anträge von Leuten, die hier nie erfasst * zurück und enthält hunderte Anträge von Leuten, die hier nie erfasst
* wurden - deshalb durchsuchbar und begrenzt statt als eine lange Liste. * wurden - deshalb durchsuchbar und seitenweise statt als eine lange Liste.
*/ */
export async function listUnassignedThreads(options: { query?: string; take?: number }) { export async function listUnassignedThreads(options: {
query?: string;
take?: number;
skip?: number;
order?: ThreadOrder;
}) {
const take = Math.min(Math.max(options.take ?? 50, 1), 200); const take = Math.min(Math.max(options.take ?? 50, 1), 200);
const suche = options.query?.trim(); const skip = Math.max(options.skip ?? 0, 0);
const where: Prisma.ForumThreadWhereInput = { const where: Prisma.ForumThreadWhereInput = {
playerId: null, playerId: null,
needsClassification: true, needsClassification: true,
...(suche ...sucheNach(options.query),
? {
OR: [
{ title: { contains: suche, mode: 'insensitive' } },
{ authorName: { contains: suche, mode: 'insensitive' } },
],
}
: {}),
}; };
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
prisma.forumThread.findMany({ prisma.forumThread.findMany({
where, where,
orderBy: [{ threadId: 'desc' }], orderBy: orderOf(options.order),
take, take,
skip,
include: { include: {
player: { select: { id: true, displayName: true } }, player: { select: { id: true, displayName: true } },
linkedCharacter: { select: { id: true, name: true } }, linkedCharacter: { select: { id: true, name: true } },
@@ -445,7 +519,32 @@ export async function listUnassignedThreads(options: { query?: string; take?: nu
prisma.forumThread.count({ where }), prisma.forumThread.count({ where }),
]); ]);
return { items, total }; return { items, total, take, skip };
}
/**
* Wer im Forum Anträge gestellt hat, hier aber unter keinem Namen geführt
* wird - zusammengefasst je Ersteller statt je Antrag. So sieht man auf einen
* Blick, wen anzulegen sich lohnt: drei Anträge unter einem Namen sind ein
* anderes Signal als ein einzelner.
*/
export async function listUntrackedAuthors(take = 100) {
const gruppen = await prisma.forumThread.groupBy({
by: ['authorName'],
where: { playerId: null, needsClassification: true, authorName: { not: null } },
_count: { _all: true },
_max: { threadId: true },
orderBy: { _count: { threadId: 'desc' } },
take: Math.min(Math.max(take, 1), 500),
});
return {
items: gruppen.map((gruppe) => ({
authorName: gruppe.authorName as string,
threadCount: gruppe._count._all,
latestThreadId: gruppe._max.threadId,
})),
};
} }
/** /**
+166
View File
@@ -485,6 +485,172 @@ export async function commitImport(importId: number, ctx: ActionContext) {
return result; return result;
} }
/**
* Offene Paket-Zeilen eines Spielers, quer über alle noch nicht bestätigten
* Importe. Damit lässt sich ein Spieler an einem Stück abarbeiten, statt
* seine Zeilen in der Import-Vorschau zwischen fremden zu suchen.
*/
export async function listPendingRowsFor(playerId: number) {
return prisma.importRow.findMany({
where: {
resolvedPlayerId: playerId,
plan: { in: [PLANS.BOOK, PLANS.CREATE_PLAYER_AND_BOOK] },
import: { status: ImportStatus.PENDING },
},
orderBy: [{ purchasedAt: 'asc' }, { lineNo: 'asc' }],
select: {
id: true,
importId: true,
productName: true,
purchasedAt: true,
target: true,
externalId: true,
import: { select: { id: true, filename: true, createdAt: true } },
},
});
}
/**
* Bucht einzelne Zeilen sofort, ohne auf die Bestätigung des ganzen Imports
* zu warten. Gebuchte Zeilen werden als erledigt vermerkt - und zwar überall,
* wo dieselbe Bestellung noch offen liegt. Ohne das stünde sie später in der
* Import-Vorschau ein zweites Mal zur Buchung bereit.
*
* Gebucht wird über dieselbe Regel wie beim Import: ein CC-Paket schreibt
* zwei Zeilen, ein Redesign-Paket eine. Es gibt keinen zweiten Buchungsweg.
*/
export async function bookRows(rowIds: number[], ctx: ActionContext) {
if (rowIds.length === 0) {
throw badRequest('NOTHING_SELECTED', 'Es ist nichts zum Buchen ausgewählt');
}
const rows = await prisma.importRow.findMany({
where: {
id: { in: rowIds },
plan: { in: [PLANS.BOOK, PLANS.CREATE_PLAYER_AND_BOOK] },
import: { status: ImportStatus.PENDING },
},
orderBy: [{ purchasedAt: 'asc' }, { lineNo: 'asc' }],
});
if (rows.length === 0) {
throw conflict('NOTHING_TO_BOOK', 'Keine dieser Zeilen steht noch zur Buchung an');
}
return prisma.$transaction(async (tx) => {
let booked = 0;
let skipped = 0;
const erledigt: number[] = [];
const externalIds: string[] = [];
for (const row of rows) {
if (row.resolvedPlayerId === null) {
// Hier wird gezielt für einen bekannten Spieler gebucht; einen neuen
// anzulegen ist Sache der Import-Vorschau.
skipped += 1;
continue;
}
if (row.externalId) {
const bereitsGebucht = await tx.importedOrder.findUnique({
where: { externalId: row.externalId },
select: { id: true },
});
if (bereitsGebucht) {
skipped += 1;
erledigt.push(row.id);
externalIds.push(row.externalId);
continue;
}
}
const target =
row.target === ProductTarget.REDESIGN_PACKAGE ? 'REDESIGN_PACKAGE' : 'CC_PACKAGE';
const groupId = randomUUID();
await tx.creditLedger.createMany({
data: buildPackageRows(target, {
playerId: row.resolvedPlayerId,
groupId,
note: row.productName ? `Import: ${row.productName}` : `Import #${row.importId}`,
importId: row.importId,
actorId: ctx.actorId,
}),
});
if (row.externalId) {
await tx.importedOrder.create({
data: {
externalId: row.externalId,
importId: row.importId,
playerId: row.resolvedPlayerId,
target: row.target ?? ProductTarget.CC_PACKAGE,
groupId,
},
});
externalIds.push(row.externalId);
}
erledigt.push(row.id);
booked += 1;
}
// Die gebuchten Zeilen selbst und jede andere offene Zeile mit derselben
// Bestellung abhaken, damit nichts ein zweites Mal gebucht wird.
if (erledigt.length > 0) {
await tx.importRow.updateMany({
where: { id: { in: erledigt } },
data: { plan: PLANS.SKIP_DUPLICATE },
});
}
if (externalIds.length > 0) {
await tx.importRow.updateMany({
where: {
externalId: { in: externalIds },
plan: { in: [PLANS.BOOK, PLANS.CREATE_PLAYER_AND_BOOK] },
},
data: { plan: PLANS.SKIP_DUPLICATE },
});
}
await recordAudit(tx, ctx, {
action: 'import.rows_booked',
targetType: 'player',
targetId: rows[0]?.resolvedPlayerId ?? null,
details: { booked, skipped, rowIds: erledigt },
});
return { booked, skipped };
});
}
/**
* Nimmt offene Zeilen aus der Vorschau, ohne sie zu buchen. Für Pakete, die
* hier nichts verloren haben - ein Kauf, der schon von Hand eingetragen war.
*/
export async function dismissRows(rowIds: number[], ctx: ActionContext) {
if (rowIds.length === 0) {
throw badRequest('NOTHING_SELECTED', 'Es ist nichts ausgewählt');
}
const result = await prisma.importRow.updateMany({
where: {
id: { in: rowIds },
plan: { in: [PLANS.BOOK, PLANS.CREATE_PLAYER_AND_BOOK] },
import: { status: ImportStatus.PENDING },
},
data: { plan: PLANS.SKIP_IGNORED },
});
await recordAudit(prisma, ctx, {
action: 'import.rows_dismissed',
targetType: 'import_row',
targetId: null,
details: { count: result.count, rowIds },
});
return { dismissed: result.count };
}
export async function discardImport(importId: number, ctx: ActionContext): Promise<void> { export async function discardImport(importId: number, ctx: ActionContext): Promise<void> {
const record = await prisma.import.findUnique({ where: { id: importId } }); const record = await prisma.import.findUnique({ where: { id: importId } });
if (!record) throw notFound('Import'); if (!record) throw notFound('Import');
+25 -14
View File
@@ -80,20 +80,29 @@ export async function getPlayerDetail(playerId: number) {
const player = await prisma.player.findUnique({ where: { id: playerId } }); const player = await prisma.player.findUnique({ where: { id: playerId } });
if (!player) throw notFound('Spieler'); if (!player) throw notFound('Spieler');
const [balances, characters, ledger, unmatched, aliases] = await Promise.all([ // Threads und offene Paket-Zeilen kommen mit, damit sich ein Spieler an
getBalances(playerId), // einem Stück abarbeiten lässt, statt zwischen drei Seiten zu wechseln.
listCharacters(playerId), const { listThreads } = await import('./forum.js');
listLedger({ playerId, take: 200 }), const { listPendingRowsFor } = await import('./imports.js');
prisma.unmatchedPackage.findMany({
where: { playerId, resolved: false }, const [balances, characters, ledger, unmatched, aliases, threads, pendingRows] =
orderBy: { scrapedAt: 'desc' }, await Promise.all([
include: { player: { select: { id: true, displayName: true } } }, getBalances(playerId),
}), listCharacters(playerId),
prisma.playerAlias.findMany({ listLedger({ playerId, take: 200 }),
where: { playerId }, prisma.unmatchedPackage.findMany({
orderBy: [{ type: 'asc' }, { value: 'asc' }], where: { playerId, resolved: false },
}), orderBy: { scrapedAt: 'desc' },
]); include: { player: { select: { id: true, displayName: true } } },
}),
prisma.playerAlias.findMany({
where: { playerId },
orderBy: [{ type: 'asc' }, { value: 'asc' }],
}),
// Ältester zuerst: Anträge werden der Reihe nach abgearbeitet.
listThreads({ playerId, order: 'oldest' }),
listPendingRowsFor(playerId),
]);
return { return {
player, player,
@@ -103,6 +112,8 @@ export async function getPlayerDetail(playerId: number) {
ledgerTotal: ledger.total, ledgerTotal: ledger.total,
unmatched, unmatched,
aliases, aliases,
threads,
pendingRows,
}; };
} }
+104
View File
@@ -311,3 +311,107 @@ describe('Konten beim Abgleich', () => {
assert.deepEqual(await scraper.steamIdsOf(spieler.id), []); assert.deepEqual(await scraper.steamIdsOf(spieler.id), []);
}); });
}); });
describe('Sortieren, Blättern und Suchen', () => {
before(async () => {
// Eigener Namensraum, damit die Faelle davor nicht hineinspielen.
for (const id of [8801, 8802, 8803, 8804, 8805]) {
await offenerThread(id, `Serienantrag ${id}`, 'Vielschreiber');
}
});
it('sortiert nach jüngstem und ältestem Antrag', async () => {
const neueste = await forum.listUnassignedThreads({ query: 'Vielschreiber', order: 'newest' });
assert.deepEqual(
neueste.items.map((t) => t.threadId),
[8805, 8804, 8803, 8802, 8801],
);
const aelteste = await forum.listUnassignedThreads({ query: 'Vielschreiber', order: 'oldest' });
assert.deepEqual(
aelteste.items.map((t) => t.threadId),
[8801, 8802, 8803, 8804, 8805],
);
});
it('blättert seitenweise durch und nennt die volle Zahl', async () => {
const erste = await forum.listUnassignedThreads({
query: 'Vielschreiber',
order: 'oldest',
take: 2,
});
assert.deepEqual(
erste.items.map((t) => t.threadId),
[8801, 8802],
);
assert.equal(erste.total, 5);
const zweite = await forum.listUnassignedThreads({
query: 'Vielschreiber',
order: 'oldest',
take: 2,
skip: 2,
});
assert.deepEqual(
zweite.items.map((t) => t.threadId),
[8803, 8804],
'keine Ueberschneidung mit der ersten Seite',
);
});
it('durchsucht auch die Arbeitsliste', async () => {
const spieler = await players.createPlayer(
{ displayName: 'Suchbar', steamId: '76561198000015555', forumName: 'Suchbar' },
ctx,
);
await offenerThread(8901, 'Ein ganz bestimmter Antrag', 'Suchbar');
await offenerThread(8902, 'Etwas voellig anderes', 'Suchbar');
await forum.claimThreadsForPlayer(spieler.id, ['Suchbar']);
const treffer = await forum.listThreads({ onlyOpen: true, query: 'bestimmter' });
assert.deepEqual(
treffer.map((t) => t.threadId),
[8901],
);
const nachAutor = await forum.listThreads({ onlyOpen: true, query: 'Suchbar' });
assert.equal(nachAutor.length, 2, 'die Suche greift auch auf den Ersteller');
});
});
describe('Ersteller ohne Spieler', () => {
it('fasst die Anträge je Ersteller zusammen', async () => {
const liste = await forum.listUntrackedAuthors();
const vielschreiber = liste.items.find((a) => a.authorName === 'Vielschreiber');
assert.ok(vielschreiber, 'wer fuenf Antraege gestellt hat, faellt auf');
assert.equal(vielschreiber?.threadCount, 5);
assert.equal(vielschreiber?.latestThreadId, 8805);
});
it('führt niemanden auf, der längst einem Spieler gehört', async () => {
const liste = await forum.listUntrackedAuthors();
assert.equal(
liste.items.some((a) => a.authorName === 'Suchbar'),
false,
);
});
it('verschwindet aus der Liste, sobald der Spieler angelegt ist', async () => {
assert.equal(
(await forum.listUntrackedAuthors()).items.some((a) => a.authorName === 'Vielschreiber'),
true,
);
await players.createPlayer(
{ displayName: 'Vielschreiber', steamId: '76561198000016666', forumName: 'Vielschreiber' },
ctx,
);
assert.equal(
(await forum.listUntrackedAuthors()).items.some((a) => a.authorName === 'Vielschreiber'),
false,
'seine Antraege stehen jetzt in der Arbeitsliste',
);
});
});
+142
View File
@@ -241,3 +241,145 @@ describe('Kollisionen beim Buchen', () => {
assert.equal(balances.CC, 1, 'genau einmal gebucht, nicht doppelt'); assert.equal(balances.CC, 1, 'genau einmal gebucht, nicht doppelt');
}); });
}); });
describe('Einzelne Zeilen beim Spieler buchen', () => {
// Jeder Fall bekommt eigene Bestellnummern: ein gebuchter Dedup-Anker gilt
// dauerhaft und wuerde sonst den naechsten Fall stillschweigend leerlaufen
// lassen. Die Spieler stehen fest - beim Weg ueber die Spielerseite geht es
// genau darum, einen Bekannten abzuarbeiten.
let eule = 0;
let specht = 0;
function datei(praefix: string): Buffer {
return Buffer.from(
[
'Bestellnummer;Steam-ID;Spielername;Produkt;Kaufdatum',
`${praefix}-1;76561198000000011;Eule;CC-Paket Groß;2025-02-01`,
`${praefix}-2;76561198000000011;Eule;Redesign Gutschein;2025-02-02`,
`${praefix}-3;76561198000000012;Specht;CC-Paket Klein;2025-02-03`,
].join('\n'),
'utf8',
);
}
async function vorschau(praefix: string, name = `${praefix}.csv`) {
const parsed = imports.parseUpload(name, datei(praefix));
const id = await imports.createImport(name, parsed, ctx);
await imports.applyMapping(id, MAPPING, ctx);
return id;
}
before(async () => {
eule = (
await db.prisma.player.create({
data: { displayName: 'Eule', steamId: '76561198000000011' },
})
).id;
specht = (
await db.prisma.player.create({
data: { displayName: 'Specht', steamId: '76561198000000012' },
})
).id;
});
it('bucht nur die gewählte Zeile und lässt den Rest der Vorschau offen', async () => {
const importId = await vorschau('EINZ');
const offen = await imports.listPendingRowsFor(eule);
assert.equal(offen.length, 2, 'ein CC-Paket und ein Redesign-Gutschein');
assert.equal(offen[0]?.productName, 'CC-Paket Groß', 'ältester Kauf zuerst');
const ergebnis = await imports.bookRows([offen[0]!.id], ctx);
assert.equal(ergebnis.booked, 1);
// Ein CC-Paket schreibt zwei Zeilen - dieselbe Regel wie beim Import.
const stand = await credits.getBalances(eule);
assert.equal(stand.CC, 1);
assert.equal(stand.REDESIGN_PAID, 1);
assert.equal(
(await imports.listPendingRowsFor(eule)).length,
1,
'die gebuchte Zeile ist aus der Liste, die andere wartet weiter',
);
const nachher = await db.prisma.import.findUniqueOrThrow({ where: { id: importId } });
assert.equal(nachher.status, 'PENDING', 'der Import als Ganzes bleibt offen');
await imports.discardImport(importId, ctx);
});
it('bucht dieselbe Bestellung nicht ein zweites Mal über den Import', async () => {
const importId = await vorschau('ZWEI');
const offen = await imports.listPendingRowsFor(specht);
assert.equal(offen.length, 1);
await imports.bookRows([offen[0]!.id], ctx);
const vorher = await credits.getBalances(specht);
// Der Import wird danach ganz normal bestätigt - die eben gebuchte Zeile
// steht dabei gar nicht mehr zur Buchung an, sie ist ja abgehakt.
const ergebnis = await imports.commitImport(importId, ctx);
assert.equal(ergebnis.booked, 2, 'nur die beiden Zeilen der Eule bleiben übrig');
assert.deepEqual(
await credits.getBalances(specht),
vorher,
'sonst stünde das Paket doppelt im Konto',
);
});
it('hakt dieselbe Bestellung auch in einem zweiten offenen Import ab', async () => {
// Zweimal dieselbe Datei eingelesen, etwa weil der Abgleich doppelt lief.
const ersterImport = await vorschau('DREI', 'lauf-a.csv');
const zweiterImport = await vorschau('DREI', 'lauf-b.csv');
const offen = await imports.listPendingRowsFor(eule);
const redesigns = offen.filter((row) => row.externalId === 'DREI-2');
assert.equal(redesigns.length, 2, 'dieselbe Bestellung liegt in beiden Vorschauen');
const vorher = await credits.getBalances(eule);
await imports.bookRows([redesigns[0]!.id], ctx);
assert.equal(
(await imports.listPendingRowsFor(eule)).filter((row) => row.externalId === 'DREI-2').length,
0,
'die Zwillingszeile im anderen Import ist mit abgehakt',
);
assert.equal(
(await credits.getBalances(eule)).REDESIGN_PAID,
vorher.REDESIGN_PAID + 1,
'einmal gekauft, einmal gebucht',
);
await imports.discardImport(ersterImport, ctx);
await imports.discardImport(zweiterImport, ctx);
});
it('weist eine leere Auswahl ab', async () => {
await assert.rejects(
() => imports.bookRows([], ctx),
(error: Error & { code?: string }) => error.code === 'NOTHING_SELECTED',
);
});
it('nimmt Zeilen auch ohne Buchung aus der Vorschau', async () => {
const importId = await vorschau('VIER');
const offen = await imports.listPendingRowsFor(specht);
assert.equal(offen.length, 1);
const vorher = await credits.getBalances(specht);
const ergebnis = await imports.dismissRows(
offen.map((row) => row.id),
ctx,
);
assert.equal(ergebnis.dismissed, 1);
assert.deepEqual(await imports.listPendingRowsFor(specht), []);
assert.deepEqual(await credits.getBalances(specht), vorher, 'weggelegt ist nicht gebucht');
await imports.discardImport(importId, ctx);
});
});
+142
View File
@@ -0,0 +1,142 @@
import { useState } from 'react';
import Notice from './Notice';
import { ApiError, api } from '../lib/api';
import { formatDate } from '../lib/format';
import type { PendingRow } from '../lib/types';
const ZIEL_LABEL: Record<string, string> = {
CC_PACKAGE: 'CC-Paket',
REDESIGN_PACKAGE: 'Redesign-Paket',
IGNORE: 'Ohne Wirkung',
};
/**
* Pakete dieses Spielers, die noch in einer offenen Import-Vorschau liegen.
*
* Damit lässt sich ein Spieler an einem Stück abarbeiten, statt seine Zeilen
* in der Import-Vorschau zwischen fremden zu suchen. Was hier gebucht wird,
* steht danach in keiner Vorschau mehr zur Buchung an - auch nicht, wenn
* dieselbe Bestellung in einem zweiten Import liegt.
*/
export default function PendingRowsBox({
rows,
canEdit,
onChanged,
onError,
onFlash,
}: {
rows: PendingRow[];
canEdit: boolean;
onChanged: () => void;
onError: (message: string) => void;
onFlash: (message: string) => void;
}) {
const [busy, setBusy] = useState(false);
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set());
if (rows.length === 0) return null;
const umschalten = (id: number) => {
setGewaehlt((alt) => {
const neu = new Set(alt);
if (neu.has(id)) neu.delete(id);
else neu.add(id);
return neu;
});
};
const ausgewaehlt = rows.filter((row) => gewaehlt.has(row.id));
const betroffen = ausgewaehlt.length > 0 ? ausgewaehlt : rows;
const ausfuehren = async (pfad: 'book' | 'dismiss') => {
const anzahl = betroffen.length;
if (
pfad === 'dismiss' &&
!window.confirm(
`${anzahl} ${anzahl === 1 ? 'Paket' : 'Pakete'} ohne Buchung aus der Vorschau nehmen?`,
)
) {
return;
}
setBusy(true);
try {
await api(`/api/imports/rows/${pfad}`, {
method: 'POST',
body: { rowIds: betroffen.map((row) => row.id) },
});
onFlash(
pfad === 'book'
? `${anzahl} ${anzahl === 1 ? 'Paket' : 'Pakete'} gebucht.`
: `${anzahl} ${anzahl === 1 ? 'Paket' : 'Pakete'} weggelegt.`,
);
setGewaehlt(new Set());
onChanged();
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Buchen fehlgeschlagen');
} finally {
setBusy(false);
}
};
return (
<section className="block block-flag">
<h2>Offene Pakete aus dem Abgleich ({rows.length})</h2>
<Notice kind="warn">
Diese Käufe liegen in einer Import-Vorschau und sind noch nicht gebucht. Hier gebucht,
verschwinden sie aus der Vorschau - doppelt buchen ist ausgeschlossen.
</Notice>
<ul className="cc-list">
{rows.map((row) => (
<li key={row.id} className="cc-item">
<div>
<label className="checkbox">
<input
type="checkbox"
checked={gewaehlt.has(row.id)}
onChange={() => umschalten(row.id)}
disabled={!canEdit || busy}
/>
<span>
<strong>{row.productName ?? 'Ohne Bezeichnung'}</strong>
{row.target && <span className="badge">{ZIEL_LABEL[row.target]}</span>}
</span>
</label>
<p className="muted small">
{row.purchasedAt ? `Gekauft ${formatDate(row.purchasedAt)}` : 'Ohne Kaufdatum'} ·
aus „{row.import.filename}"
{row.externalId && ` · ${row.externalId}`}
</p>
</div>
</li>
))}
</ul>
{canEdit && (
<div className="form-actions">
<button
type="button"
className="btn btn-ghost"
disabled={busy}
onClick={() => void ausfuehren('dismiss')}
>
{ausgewaehlt.length > 0 ? `${ausgewaehlt.length} weglegen` : 'Alle weglegen'}
</button>
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() => void ausfuehren('book')}
>
{busy
? 'Buche …'
: ausgewaehlt.length > 0
? `${ausgewaehlt.length} buchen`
: `Alle ${rows.length} buchen`}
</button>
</div>
)}
</section>
);
}
+328
View File
@@ -0,0 +1,328 @@
import { useEffect, useState } from 'react';
import Modal from './Modal';
import Notice from './Notice';
import { ApiError, api } from '../lib/api';
import { CLASSIFICATION_LABEL } from '../lib/format';
import type {
Character,
ForumClassification,
ForumThread,
PlayerDetail,
PlayerSummary,
} from '../lib/types';
export function ClassifyModal({
thread,
onClose,
onDone,
onError,
}: {
thread: ForumThread;
onClose: () => void;
onDone: (message: string) => void;
onError: (message: string) => void;
}) {
const [choice, setChoice] = useState<ForumClassification>('NEW_CC');
const [name, setName] = useState(thread.title);
const [predecessorId, setPredecessorId] = useState<number | null>(null);
const [reviveId, setReviveId] = useState<number | null>(null);
const [useKind, setUseKind] = useState<'REDESIGN_KULANZ' | 'REDESIGN_PAID'>('REDESIGN_KULANZ');
const [characters, setCharacters] = useState<Character[]>([]);
const [inactive, setInactive] = useState<Character[]>([]);
const [busy, setBusy] = useState(false);
// Für ein Redesign braucht der Admin die Liste der aktiven CCs des Spielers.
useEffect(() => {
if (!thread.playerId) return;
void (async () => {
try {
const detail = await api<PlayerDetail>(`/api/players/${thread.playerId}`);
setCharacters(detail.characters.filter((c) => c.status === 'ACTIVE'));
setInactive(detail.characters.filter((c) => c.status === 'INACTIVE'));
} catch {
// Ohne Liste bleibt nur die Ablehnung - das Formular sagt es.
}
})();
}, [thread.playerId]);
const needsPlayer = choice === 'NEW_CC' || choice === 'REDESIGN' || choice === 'REACTIVATION';
const needsName = choice === 'NEW_CC' || choice === 'REDESIGN';
// Ein redesignter CC existiert im Original nicht mehr und kostet deshalb.
const gewaehlterCC = inactive.find((c) => c.id === reviveId);
const reaktivierungKostet = gewaehlterCC?.inactiveReason === 'REWORKED';
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setBusy(true);
try {
await api(`/api/forum/threads/${thread.threadId}/classify`, {
method: 'POST',
body: {
classification: choice,
...(needsName ? { characterName: name.trim() } : {}),
...(choice === 'REDESIGN' ? { predecessorId, useKind } : {}),
...(choice === 'REACTIVATION'
? { characterId: reviveId, ...(reaktivierungKostet ? { useKind } : {}) }
: {}),
},
});
onDone(`Antrag entschieden: ${CLASSIFICATION_LABEL[choice]}.`);
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Entscheidung fehlgeschlagen');
} finally {
setBusy(false);
}
};
return (
<Modal title="Antrag entscheiden" onClose={onClose}>
<form onSubmit={submit}>
<Notice kind="info">
<strong>{thread.title}</strong>
<br />
von {thread.authorName ?? 'unbekannt'}
{thread.player ? ` · ${thread.player.displayName}` : ' · kein Spieler zugeordnet'}
</Notice>
<div className="radio-group">
{(
[
['NEW_CC', 'Angenommen - neuer CC', 'verbraucht 1 CC-Guthaben'],
['REDESIGN', 'Angenommen - Redesign', 'verbraucht 1 Redesign-Guthaben'],
['REACTIVATION', 'Angenommen - Reaktivierung', 'ein inaktiver CC kehrt zurück'],
['REJECTED', 'Abgelehnt', 'keine Buchung'],
['IGNORE', 'Kein Antrag', 'keine Buchung'],
] as const
).map(([value, label, hint]) => (
<label key={value} className="radio">
<input
type="radio"
name="classification"
checked={choice === value}
onChange={() => setChoice(value)}
/>
<span>
<strong>{label}</strong>
<span className="muted"> - {hint}</span>
</span>
</label>
))}
</div>
{needsPlayer && !thread.playerId && (
<Notice kind="warn">
Ohne zugeordneten Spieler lässt sich nichts buchen. Bitte erst „Spieler zuordnen".
</Notice>
)}
{needsName && thread.playerId && (
<>
<label htmlFor="cc-name">Name des CCs</label>
<input
id="cc-name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
<p className="muted small">Vorbelegt mit dem Thread-Titel - bitte anpassen.</p>
</>
)}
{choice === 'REACTIVATION' && thread.playerId && (
<>
<label htmlFor="revive">Welcher CC kehrt zurück?</label>
<select
id="revive"
value={reviveId ?? ''}
onChange={(event) => setReviveId(Number(event.target.value) || null)}
required
>
<option value="">- bitte wählen -</option>
{inactive.map((character) => (
<option key={character.id} value={character.id}>
{character.name}
{character.inactiveReason === 'REWORKED' ? ' (redesignt - kostet)' : ' (gratis)'}
</option>
))}
</select>
{inactive.length === 0 && (
<p className="muted small neg">Dieser Spieler hat keine inaktiven CCs.</p>
)}
{gewaehlterCC && !reaktivierungKostet && (
<Notice kind="success">
Kommt gratis zurück - der CC existiert unverändert weiter.
</Notice>
)}
{reaktivierungKostet && (
<>
<Notice kind="warn">
Dieser CC wurde redesignt und existiert im Original nicht mehr. Die Rückkehr
kostet ein Guthaben.
</Notice>
<label>Womit bezahlen?</label>
<div className="radio-group">
<label className="radio">
<input
type="radio"
name="reviveKind"
checked={useKind === 'REDESIGN_KULANZ'}
onChange={() => setUseKind('REDESIGN_KULANZ')}
/>
<span>Kulanz-Redesign</span>
</label>
<label className="radio">
<input
type="radio"
name="reviveKind"
checked={useKind === 'REDESIGN_PAID'}
onChange={() => setUseKind('REDESIGN_PAID')}
/>
<span>Bezahltes Redesign</span>
</label>
</div>
</>
)}
</>
)}
{choice === 'REDESIGN' && thread.playerId && (
<>
<label htmlFor="vorgaenger">Welcher CC wurde redesignt?</label>
<select
id="vorgaenger"
value={predecessorId ?? ''}
onChange={(event) => setPredecessorId(Number(event.target.value) || null)}
required
>
<option value="">- bitte wählen -</option>
{characters.map((character) => (
<option key={character.id} value={character.id}>
{character.name}
</option>
))}
</select>
{characters.length === 0 && (
<p className="muted small neg">Dieser Spieler hat keine aktiven CCs.</p>
)}
<label>Womit bezahlen?</label>
<div className="radio-group">
<label className="radio">
<input
type="radio"
name="useKind"
checked={useKind === 'REDESIGN_KULANZ'}
onChange={() => setUseKind('REDESIGN_KULANZ')}
/>
<span>Kulanz-Redesign</span>
</label>
<label className="radio">
<input
type="radio"
name="useKind"
checked={useKind === 'REDESIGN_PAID'}
onChange={() => setUseKind('REDESIGN_PAID')}
/>
<span>Bezahltes Redesign</span>
</label>
</div>
</>
)}
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen
</button>
<button
type="submit"
className="btn btn-primary"
disabled={busy || (needsPlayer && !thread.playerId)}
>
{busy ? 'Speichere …' : 'Entscheiden'}
</button>
</div>
</form>
</Modal>
);
}
export function AssignModal({
thread,
onClose,
onDone,
onError,
}: {
thread: ForumThread;
onClose: () => void;
onDone: () => void;
onError: (message: string) => void;
}) {
const [query, setQuery] = useState(thread.authorName ?? '');
const [results, setResults] = useState<PlayerSummary[]>([]);
useEffect(() => {
const timer = setTimeout(async () => {
try {
const params = new URLSearchParams({ q: query, take: '20' });
const data = await api<{ items: PlayerSummary[] }>(`/api/players?${params}`);
setResults(data.items);
} catch {
// Suchfehler blockieren den Dialog nicht.
}
}, 250);
return () => clearTimeout(timer);
}, [query]);
const assign = async (playerId: number) => {
try {
await api(`/api/forum/threads/${thread.threadId}/player`, {
method: 'POST',
body: { playerId },
});
onDone();
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Zuordnen fehlgeschlagen');
}
};
return (
<Modal title="Spieler zuordnen" onClose={onClose}>
<Notice kind="info">
Forumsname des Antragstellers: <strong>{thread.authorName ?? 'unbekannt'}</strong>. Passt
kein Eintrag, trag den Forumsnamen beim Spieler nach - dann findet der nächste Abgleich
ihn von allein.
</Notice>
<label htmlFor="suche">Spieler suchen</label>
<input
id="suche"
value={query}
onChange={(event) => setQuery(event.target.value)}
autoFocus
/>
<ul className="cc-list" style={{ marginTop: '0.75rem' }}>
{results.map((player) => (
<li key={player.id} className="cc-item">
<div>
<strong>{player.displayName}</strong>
{player.forumName && <span className="muted"> · Forum: {player.forumName}</span>}
</div>
<div className="cc-actions">
<button
type="button"
className="btn btn-small btn-primary"
onClick={() => void assign(player.id)}
>
Zuordnen
</button>
</div>
</li>
))}
{results.length === 0 && <p className="muted">Kein Treffer.</p>}
</ul>
</Modal>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { useState } from 'react';
import Notice from './Notice';
import { ClassifyModal } from './ThreadModals';
import { BOARD_LABEL, CLASSIFICATION_LABEL, formatDate } from '../lib/format';
import type { ForumThread } from '../lib/types';
/**
* Die Forums-Anträge dieses Spielers, ältester zuerst - so wie sie
* abgearbeitet werden. Offene lassen sich hier direkt entscheiden, statt
* dafür auf die Anträge-Seite zu wechseln und ihn dort zu suchen.
*/
export default function ThreadsBox({
threads,
canEdit,
onChanged,
onError,
onFlash,
}: {
threads: ForumThread[];
canEdit: boolean;
onChanged: () => void;
onError: (message: string) => void;
onFlash: (message: string) => void;
}) {
const [dialog, setDialog] = useState<ForumThread | null>(null);
const offen = threads.filter((thread) => thread.needsClassification);
const entschieden = threads.filter((thread) => !thread.needsClassification);
return (
<section className={offen.length > 0 ? 'block block-flag' : 'block'}>
<h2>Forums-Anträge ({threads.length})</h2>
{threads.length === 0 ? (
<p className="muted">
Keine Anträge zugeordnet. Zugeordnet wird über den Forum-Namen des Erstellers - steht
hier keiner, findet der Abgleich nichts.
</p>
) : (
<>
{offen.length > 0 && (
<Notice kind="warn">
{offen.length} {offen.length === 1 ? 'Antrag wartet' : 'Anträge warten'} auf eine
Entscheidung. Was hier entschieden wird, steht danach in der Anträge-Liste nicht
mehr offen.
</Notice>
)}
<ul className="cc-list">
{threads.map((thread) => (
<li
key={thread.threadId}
className={thread.needsClassification ? 'cc-item' : 'cc-item is-inactive'}
>
<div>
<a
href={thread.url}
target="_blank"
rel="noreferrer"
className="link-strong"
title="Im Forum öffnen"
>
{thread.title}
</a>
<span className="badge">{BOARD_LABEL[thread.currentBoard]}</span>
{thread.classification && (
<span className="badge badge-free">
{CLASSIFICATION_LABEL[thread.classification]}
</span>
)}
<p className="muted small">
von {thread.authorName ?? 'unbekannt'}
{thread.labels && ` · ${thread.labels}`}
{thread.linkedCharacter && ` · CC: ${thread.linkedCharacter.name}`}
{thread.classifiedAt && ` · entschieden ${formatDate(thread.classifiedAt)}`}
</p>
</div>
{canEdit && thread.needsClassification && (
<div className="cc-actions">
<button
type="button"
className="btn btn-small btn-primary"
onClick={() => setDialog(thread)}
>
Entscheiden
</button>
</div>
)}
</li>
))}
</ul>
{entschieden.length > 0 && offen.length === 0 && (
<p className="muted small">Alle Anträge sind entschieden.</p>
)}
</>
)}
{dialog && (
<ClassifyModal
thread={dialog}
onClose={() => setDialog(null)}
onDone={(message) => {
setDialog(null);
onFlash(message);
onChanged();
}}
onError={onError}
/>
)}
</section>
);
}
+29
View File
@@ -168,6 +168,10 @@ export interface PlayerDetail {
ledgerTotal: number; ledgerTotal: number;
unmatched: UnmatchedPackage[]; unmatched: UnmatchedPackage[];
aliases: PlayerAlias[]; aliases: PlayerAlias[];
/// Forums-Anträge dieses Spielers, ältester zuerst.
threads: ForumThread[];
/// Pakete, die noch in einer offenen Import-Vorschau liegen.
pendingRows: PendingRow[];
} }
export interface AdminUser { export interface AdminUser {
@@ -260,6 +264,7 @@ export interface ForumThread {
lastBoardChangeAt: string | null; lastBoardChangeAt: string | null;
classification: ForumClassification | null; classification: ForumClassification | null;
needsClassification: boolean; needsClassification: boolean;
classifiedAt: string | null;
linkedCharacterId: number | null; linkedCharacterId: number | null;
linkedCharacter: { id: number; name: string; status?: string } | null; linkedCharacter: { id: number; name: string; status?: string } | null;
boardChangeNotice: boolean; boardChangeNotice: boolean;
@@ -282,6 +287,8 @@ export interface ForumScanSummary {
export interface UnassignedThreads { export interface UnassignedThreads {
items: ForumThread[]; items: ForumThread[];
total: number; total: number;
take: number;
skip: number;
} }
export interface ScanState { export interface ScanState {
@@ -290,6 +297,28 @@ export interface ScanState {
finishedAt: string | null; finishedAt: string | null;
currentBoard: string | null; currentBoard: string | null;
pagesRead: number; pagesRead: number;
/// Steht erst fest, wenn von jedem Board die erste Seite gelesen ist.
totalPages: number;
log: string[];
summary: ForumScanSummary | null; summary: ForumScanSummary | null;
error: string | null; error: string | null;
} }
export type ThreadOrder = 'newest' | 'oldest';
export interface UntrackedAuthor {
authorName: string;
threadCount: number;
latestThreadId: number | null;
}
/// Eine offene Paket-Zeile aus einem noch nicht bestätigten Import.
export interface PendingRow {
id: number;
importId: number;
productName: string | null;
purchasedAt: string | null;
target: ProductTarget | null;
externalId: string | null;
import: { id: number; filename: string; createdAt: string };
}
+25 -6
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import Modal from '../components/Modal'; import Modal from '../components/Modal';
import Notice from '../components/Notice'; import Notice from '../components/Notice';
import { ApiError, api } from '../lib/api'; import { ApiError, api } from '../lib/api';
@@ -25,6 +25,10 @@ export default function Dashboard() {
const [result, setResult] = useState<SearchResult>({ total: 0, items: [] }); const [result, setResult] = useState<SearchResult>({ total: 0, items: [] });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Von der Anträge-Seite aus: "Spieler anlegen" für einen Ersteller, dessen
// Forum-Name schon feststeht.
const [params, setParams] = useSearchParams();
const vorgabe = params.get('anlegen');
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [flagged, setFlagged] = useState<UnmatchedOverview>({ total: 0, players: [] }); const [flagged, setFlagged] = useState<UnmatchedOverview>({ total: 0, players: [] });
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
@@ -215,11 +219,16 @@ export default function Dashboard() {
</p> </p>
)} )}
{creating && ( {(creating || vorgabe !== null) && (
<CreatePlayerModal <CreatePlayerModal
onClose={() => setCreating(false)} forumNameVorgabe={vorgabe ?? ''}
onClose={() => {
setCreating(false);
if (vorgabe !== null) setParams({}, { replace: true });
}}
onCreated={() => { onCreated={() => {
setCreating(false); setCreating(false);
if (vorgabe !== null) setParams({}, { replace: true });
void load(query, nurProbleme); void load(query, nurProbleme);
}} }}
/> />
@@ -228,10 +237,20 @@ export default function Dashboard() {
); );
} }
function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) { function CreatePlayerModal({
const [displayName, setDisplayName] = useState(''); forumNameVorgabe,
onClose,
onCreated,
}: {
forumNameVorgabe: string;
onClose: () => void;
onCreated: () => void;
}) {
// Kommt der Aufruf von einem Forums-Ersteller, ist der Name schon bekannt -
// und taugt bis auf Weiteres auch als Anzeigename.
const [displayName, setDisplayName] = useState(forumNameVorgabe);
const [steamId, setSteamId] = useState(''); const [steamId, setSteamId] = useState('');
const [forumName, setForumName] = useState(''); const [forumName, setForumName] = useState(forumNameVorgabe);
const [istProblem, setIstProblem] = useState(false); const [istProblem, setIstProblem] = useState(false);
const [problemNote, setProblemNote] = useState(''); const [problemNote, setProblemNote] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
+271 -381
View File
@@ -1,19 +1,17 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import Modal from '../components/Modal';
import Notice from '../components/Notice'; import Notice from '../components/Notice';
import { AssignModal, ClassifyModal } from '../components/ThreadModals';
import { ApiError, api } from '../lib/api'; import { ApiError, api } from '../lib/api';
import { BOARD_LABEL, CLASSIFICATION_LABEL, formatDate } from '../lib/format'; import { BOARD_LABEL, CLASSIFICATION_LABEL, formatDate } from '../lib/format';
import { useSession } from '../lib/session'; import { useSession } from '../lib/session';
import type { import type {
Character,
ForumClassification,
ForumScanSummary, ForumScanSummary,
ForumThread, ForumThread,
PlayerDetail,
PlayerSummary,
ScanState, ScanState,
ThreadOrder,
UnassignedThreads, UnassignedThreads,
UntrackedAuthor,
} from '../lib/types'; } from '../lib/types';
/// Die Board-Pfade des Abgleichs in lesbarer Form. /// Die Board-Pfade des Abgleichs in lesbarer Form.
@@ -23,8 +21,11 @@ const BOARD_PFAD: Record<string, string> = {
'board/113-cc-archiv': 'das Archiv', 'board/113-cc-archiv': 'das Archiv',
}; };
const SEITE = 25;
export default function Forum() { export default function Forum() {
const { can } = useSession(); const { can } = useSession();
const navigate = useNavigate();
const darfEntscheiden = can('EDITOR'); const darfEntscheiden = can('EDITOR');
const [threads, setThreads] = useState<ForumThread[]>([]); const [threads, setThreads] = useState<ForumThread[]>([]);
@@ -34,36 +35,73 @@ export default function Forum() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [dialog, setDialog] = useState<ForumThread | null>(null); const [dialog, setDialog] = useState<ForumThread | null>(null);
const [assigning, setAssigning] = useState<ForumThread | null>(null); const [assigning, setAssigning] = useState<ForumThread | null>(null);
const [ohneSpieler, setOhneSpieler] = useState<UnassignedThreads | null>(null);
const [suche, setSuche] = useState('');
const [zeigeOhneSpieler, setZeigeOhneSpieler] = useState(false);
/// Hochzählen heißt: die Liste ohne Spieler neu holen.
const [stand, setStand] = useState(0);
const [scanZustand, setScan] = useState<ScanState | null>(null); const [scanZustand, setScan] = useState<ScanState | null>(null);
const ladeOhneSpieler = useCallback(async (query: string) => { // Arbeitsliste
const param = query.trim() ? `?q=${encodeURIComponent(query.trim())}` : ''; const [sucheOffen, setSucheOffen] = useState('');
const [sortOffen, setSortOffen] = useState<ThreadOrder>('newest');
// Ohne zugeordneten Spieler
const [ohneSpieler, setOhneSpieler] = useState<UnassignedThreads | null>(null);
const [sucheOhne, setSucheOhne] = useState('');
const [sortOhne, setSortOhne] = useState<ThreadOrder>('newest');
const [seite, setSeite] = useState(0);
const [zeigeOhneSpieler, setZeigeOhneSpieler] = useState(false);
// Ersteller ohne Spieler
const [autoren, setAutoren] = useState<UntrackedAuthor[]>([]);
const [zeigeAutoren, setZeigeAutoren] = useState(false);
/// Hochzählen heißt: die abhängigen Listen neu holen.
const [stand, setStand] = useState(0);
const ladeOffene = useCallback(async (query: string, order: ThreadOrder) => {
const params = new URLSearchParams({ open: 'true', order });
if (query.trim()) params.set('q', query.trim());
try { try {
setOhneSpieler(await api<UnassignedThreads>(`/api/forum/threads/unassigned${param}`)); const data = await api<{ items: ForumThread[] }>(`/api/forum/threads?${params}`);
setThreads(data.items);
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen'); setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
} }
}, []); }, []);
const ladeOhneSpieler = useCallback(
async (query: string, order: ThreadOrder, skip: number) => {
const params = new URLSearchParams({
order,
take: String(SEITE),
skip: String(skip),
});
if (query.trim()) params.set('q', query.trim());
try {
setOhneSpieler(await api<UnassignedThreads>(`/api/forum/threads/unassigned?${params}`));
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
}
},
[],
);
const ladeAutoren = useCallback(async () => {
try {
const data = await api<{ items: UntrackedAuthor[] }>('/api/forum/authors/untracked');
setAutoren(data.items);
} catch {
// Die Liste ist Beiwerk; ein Fehler hier darf den Rest nicht stören.
}
}, []);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const [open, notice] = await Promise.all([ setNotices((await api<{ items: ForumThread[] }>('/api/forum/notices')).items);
api<{ items: ForumThread[] }>('/api/forum/threads?open=true'),
api<{ items: ForumThread[] }>('/api/forum/notices'),
]);
setThreads(open.items);
setNotices(notice.items);
setError(null); setError(null);
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen'); setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
} }
await ladeAutoren();
setStand((n) => n + 1); setStand((n) => n + 1);
}, []); }, [ladeAutoren]);
useEffect(() => { useEffect(() => {
void load(); void load();
@@ -71,9 +109,25 @@ export default function Forum() {
// Beim Tippen kurz abwarten, statt jede Taste an den Server zu schicken. // Beim Tippen kurz abwarten, statt jede Taste an den Server zu schicken.
useEffect(() => { useEffect(() => {
const timer = window.setTimeout(() => void ladeOhneSpieler(suche), suche ? 250 : 0); const timer = window.setTimeout(
() => void ladeOffene(sucheOffen, sortOffen),
sucheOffen ? 250 : 0,
);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [suche, stand, ladeOhneSpieler]); }, [sucheOffen, sortOffen, stand, ladeOffene]);
useEffect(() => {
const timer = window.setTimeout(
() => void ladeOhneSpieler(sucheOhne, sortOhne, seite * SEITE),
sucheOhne ? 250 : 0,
);
return () => window.clearTimeout(timer);
}, [sucheOhne, sortOhne, seite, stand, ladeOhneSpieler]);
// Eine neue Suche fängt wieder auf Seite eins an.
useEffect(() => {
setSeite(0);
}, [sucheOhne, sortOhne]);
const bericht = (result: ForumScanSummary): string => { const bericht = (result: ForumScanSummary): string => {
const parts = [`${result.scannedPages} Seiten gelesen`]; const parts = [`${result.scannedPages} Seiten gelesen`];
@@ -100,27 +154,26 @@ export default function Forum() {
useEffect(() => { useEffect(() => {
if (!busy) return; if (!busy) return;
const timer = window.setInterval(() => { const abfragen = async () => {
void (async () => { try {
try { const state = await api<ScanState>('/api/forum/scan/status');
const state = await api<ScanState>('/api/forum/scan/status'); setScan(state);
setScan(state); if (state.running) return;
if (state.running) return;
setBusy(false); setBusy(false);
if (state.error) setError(state.error); if (state.error) setError(state.error);
else if (state.summary) { else if (state.summary) {
setFlash(bericht(state.summary)); setFlash(bericht(state.summary));
window.setTimeout(() => setFlash(null), 12000); window.setTimeout(() => setFlash(null), 12000);
}
await load();
} catch (err) {
setBusy(false);
setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
} }
})(); await load();
}, 2000); } catch (err) {
setBusy(false);
setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
}
};
const timer = window.setInterval(() => void abfragen(), 1500);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [busy, load]); }, [busy, load]);
@@ -156,6 +209,8 @@ export default function Forum() {
} }
}; };
const seiten = ohneSpieler ? Math.ceil(ohneSpieler.total / SEITE) : 0;
return ( return (
<div className="page"> <div className="page">
<div className="page-head"> <div className="page-head">
@@ -163,15 +218,13 @@ export default function Forum() {
<h1>Forums-Anträge</h1> <h1>Forums-Anträge</h1>
<p className="muted"> <p className="muted">
Jeder Antrag wird von Hand entschieden - Titel und Board sind kein verlässliches Jeder Antrag wird von Hand entschieden - Titel und Board sind kein verlässliches
Signal und lösen nie eine Buchung aus. Aufgenommen werden nur Threads von Spielern, Signal und lösen nie eine Buchung aus. Anträge von Erstellern, die hier unter keinem
die hier mit passendem Forum-Namen erfasst sind. Forum-Namen geführt werden, stehen weiter unten.
</p> </p>
</div> </div>
{darfEntscheiden && ( {darfEntscheiden && (
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void scan()}> <button type="button" className="btn btn-primary" disabled={busy} onClick={() => void scan()}>
{busy {busy ? 'Lese Forum …' : 'Forum abgleichen'}
? `Lese Forum … (${scanZustand?.pagesRead ?? 0} Seiten)`
: 'Forum abgleichen'}
</button> </button>
)} )}
</div> </div>
@@ -179,14 +232,7 @@ export default function Forum() {
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>} {error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>} {flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
{busy && ( {busy && <ScanProgress state={scanZustand} />}
<Notice kind="warn">
Der Abgleich liest gerade {BOARD_PFAD[scanZustand?.currentBoard ?? ''] ?? 'das Forum'} -
bisher {scanZustand?.pagesRead ?? 0} Seiten. Das Archiv umfasst über zwanzig Seiten, und
zwischen den Abrufen wird bewusst gewartet, damit das Forum den Zugriff nicht bremst. Der
Lauf geht im Hintergrund weiter, auch wenn diese Seite geschlossen wird.
</Notice>
)}
{notices.length > 0 && ( {notices.length > 0 && (
<section className="block block-flag"> <section className="block block-flag">
@@ -229,9 +275,28 @@ export default function Forum() {
<section className="block"> <section className="block">
<h2>Wartet auf Entscheidung ({threads.length})</h2> <h2>Wartet auf Entscheidung ({threads.length})</h2>
<div className="list-tools">
<input
type="search"
placeholder="Nach Titel oder Ersteller suchen …"
value={sucheOffen}
onChange={(event) => setSucheOffen(event.target.value)}
/>
<select
value={sortOffen}
onChange={(event) => setSortOffen(event.target.value as ThreadOrder)}
aria-label="Sortierung"
>
<option value="newest">Neueste zuerst</option>
<option value="oldest">Älteste zuerst</option>
</select>
</div>
{threads.length === 0 ? ( {threads.length === 0 ? (
<p className="muted"> <p className="muted">
Keine offenen Anträge. {darfEntscheiden && 'Über „Forum abgleichen" nach neuen suchen.'} {sucheOffen
? 'Kein Treffer.'
: `Keine offenen Anträge. ${darfEntscheiden ? 'Über „Forum abgleichen" nach neuen suchen.' : ''}`}
</p> </p>
) : ( ) : (
<ul className="cc-list"> <ul className="cc-list">
@@ -281,14 +346,77 @@ export default function Forum() {
)} )}
</section> </section>
<section className="block">
<div className="page-head">
<div>
<h2>Ersteller ohne Spieler ({autoren.length})</h2>
<p className="muted small">
Wer im Forum Anträge gestellt hat, hier aber unter keinem Forum-Namen geführt wird.
Ein Klick legt den Spieler an - der Forum-Name steht schon drin, und seine Anträge
wandern anschließend von selbst in die Arbeitsliste.
</p>
</div>
{autoren.length > 0 && (
<button
type="button"
className="btn btn-small btn-ghost"
onClick={() => setZeigeAutoren((v) => !v)}
>
{zeigeAutoren ? 'Einklappen' : 'Anzeigen'}
</button>
)}
</div>
{autoren.length === 0 ? (
<p className="muted">Jeder Ersteller ist einem Spieler zugeordnet.</p>
) : (
zeigeAutoren && (
<ul className="cc-list">
{autoren.map((autor) => (
<li key={autor.authorName} className="cc-item">
<div>
<strong>{autor.authorName}</strong>
<span className="badge">
{autor.threadCount} {autor.threadCount === 1 ? 'Antrag' : 'Anträge'}
</span>
</div>
<div className="cc-actions">
<button
type="button"
className="btn btn-small btn-ghost"
onClick={() => {
setSucheOhne(autor.authorName);
setZeigeOhneSpieler(true);
}}
>
Anträge zeigen
</button>
{darfEntscheiden && (
<button
type="button"
className="btn btn-small btn-primary"
onClick={() =>
navigate(`/?anlegen=${encodeURIComponent(autor.authorName)}`)
}
>
Spieler anlegen
</button>
)}
</div>
</li>
))}
</ul>
)
)}
</section>
<section className="block"> <section className="block">
<div className="page-head"> <div className="page-head">
<div> <div>
<h2>Ohne zugeordneten Spieler ({ohneSpieler?.total ?? 0})</h2> <h2>Ohne zugeordneten Spieler ({ohneSpieler?.total ?? 0})</h2>
<p className="muted small"> <p className="muted small">
Anträge, deren Ersteller hier unter keinem Forum-Namen geführt wird. Sie bleiben Einzelne Anträge, deren Ersteller hier nicht geführt wird. Sie bleiben aus der
aus der Arbeitsliste heraus, gehen aber nicht verloren. Sobald ein Spieler mit Arbeitsliste heraus, gehen aber nicht verloren.
passendem Forum-Namen angelegt wird, wandern sie von selbst hinüber.
</p> </p>
</div> </div>
<button <button
@@ -302,21 +430,30 @@ export default function Forum() {
{zeigeOhneSpieler && ( {zeigeOhneSpieler && (
<> <>
<input <div className="list-tools">
type="search" <input
className="input" type="search"
placeholder="Nach Titel oder Ersteller suchen …" placeholder="Nach Titel oder Ersteller suchen …"
value={suche} value={sucheOhne}
onChange={(event) => setSuche(event.target.value)} onChange={(event) => setSucheOhne(event.target.value)}
/> />
<select
value={sortOhne}
onChange={(event) => setSortOhne(event.target.value as ThreadOrder)}
aria-label="Sortierung"
>
<option value="newest">Neueste zuerst</option>
<option value="oldest">Älteste zuerst</option>
</select>
</div>
{ohneSpieler === null || ohneSpieler.items.length === 0 ? ( {ohneSpieler === null || ohneSpieler.items.length === 0 ? (
<p className="muted" style={{ marginTop: '0.75rem' }}> <p className="muted">
{suche ? 'Nichts gefunden.' : 'Alle erfassten Anträge sind zugeordnet.'} {sucheOhne ? 'Nichts gefunden.' : 'Alle erfassten Anträge sind zugeordnet.'}
</p> </p>
) : ( ) : (
<> <>
<ul className="cc-list" style={{ marginTop: '0.75rem' }}> <ul className="cc-list">
{ohneSpieler.items.map((thread) => ( {ohneSpieler.items.map((thread) => (
<li key={thread.threadId} className="cc-item"> <li key={thread.threadId} className="cc-item">
<div> <div>
@@ -349,11 +486,29 @@ export default function Forum() {
</li> </li>
))} ))}
</ul> </ul>
{ohneSpieler.total > ohneSpieler.items.length && (
<p className="muted small"> {seiten > 1 && (
Zeigt {ohneSpieler.items.length} von {ohneSpieler.total} - über die Suche <div className="pager">
eingrenzen. <button
</p> type="button"
className="btn btn-small btn-ghost"
disabled={seite === 0}
onClick={() => setSeite((n) => Math.max(0, n - 1))}
>
Zurück
</button>
<span className="muted small">
Seite {seite + 1} von {seiten} · {ohneSpieler.total} Anträge
</span>
<button
type="button"
className="btn btn-small btn-ghost"
disabled={seite + 1 >= seiten}
onClick={() => setSeite((n) => n + 1)}
>
Weiter
</button>
</div>
)} )}
</> </>
)} )}
@@ -390,318 +545,53 @@ export default function Forum() {
); );
} }
function ClassifyModal({ /**
thread, * Was der Abgleich gerade tut. Der Umfang steht erst fest, wenn von jedem
onClose, * Board die erste Seite gelesen ist - bis dahin läuft der Balken durch,
onDone, * statt einen erfundenen Anteil zu behaupten.
onError, */
}: { function ScanProgress({ state }: { state: ScanState | null }) {
thread: ForumThread; const gelesen = state?.pagesRead ?? 0;
onClose: () => void; const gesamt = state?.totalPages ?? 0;
onDone: (message: string) => void; const bekannt = gesamt > 0;
onError: (message: string) => void; const anteil = bekannt ? Math.min(100, Math.round((gelesen / gesamt) * 100)) : 0;
}) { const letzte = state?.log.slice(-6) ?? [];
const [choice, setChoice] = useState<ForumClassification>('NEW_CC');
const [name, setName] = useState(thread.title);
const [predecessorId, setPredecessorId] = useState<number | null>(null);
const [reviveId, setReviveId] = useState<number | null>(null);
const [useKind, setUseKind] = useState<'REDESIGN_KULANZ' | 'REDESIGN_PAID'>('REDESIGN_KULANZ');
const [characters, setCharacters] = useState<Character[]>([]);
const [inactive, setInactive] = useState<Character[]>([]);
const [busy, setBusy] = useState(false);
// Für ein Redesign braucht der Admin die Liste der aktiven CCs des Spielers.
useEffect(() => {
if (!thread.playerId) return;
void (async () => {
try {
const detail = await api<PlayerDetail>(`/api/players/${thread.playerId}`);
setCharacters(detail.characters.filter((c) => c.status === 'ACTIVE'));
setInactive(detail.characters.filter((c) => c.status === 'INACTIVE'));
} catch {
// Ohne Liste bleibt nur die Ablehnung - das Formular sagt es.
}
})();
}, [thread.playerId]);
const needsPlayer = choice === 'NEW_CC' || choice === 'REDESIGN' || choice === 'REACTIVATION';
const needsName = choice === 'NEW_CC' || choice === 'REDESIGN';
// Ein redesignter CC existiert im Original nicht mehr und kostet deshalb.
const gewaehlterCC = inactive.find((c) => c.id === reviveId);
const reaktivierungKostet = gewaehlterCC?.inactiveReason === 'REWORKED';
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setBusy(true);
try {
await api(`/api/forum/threads/${thread.threadId}/classify`, {
method: 'POST',
body: {
classification: choice,
...(needsName ? { characterName: name.trim() } : {}),
...(choice === 'REDESIGN' ? { predecessorId, useKind } : {}),
...(choice === 'REACTIVATION'
? { characterId: reviveId, ...(reaktivierungKostet ? { useKind } : {}) }
: {}),
},
});
onDone(`Antrag entschieden: ${CLASSIFICATION_LABEL[choice]}.`);
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Entscheidung fehlgeschlagen');
} finally {
setBusy(false);
}
};
return ( return (
<Modal title="Antrag entscheiden" onClose={onClose}> <section className="block progress">
<form onSubmit={submit}> <div className="progress-head">
<Notice kind="info"> <strong>
<strong>{thread.title}</strong> {bekannt
<br /> ? `${gelesen} von ${gesamt} Seiten gelesen`
von {thread.authorName ?? 'unbekannt'} : `Umfang wird ermittelt … (${gelesen} Seiten)`}
{thread.player ? ` · ${thread.player.displayName}` : ' · kein Spieler zugeordnet'} </strong>
</Notice> <span className="muted">
{state?.currentBoard
? `liest ${BOARD_PFAD[state.currentBoard] ?? state.currentBoard}`
: 'wird gespeichert'}
{bekannt && ` · ${anteil} %`}
</span>
</div>
<div className="radio-group"> <div className="progress-bar">
{( <div
[ className={bekannt ? 'progress-fill' : 'progress-fill is-unknown'}
['NEW_CC', 'Angenommen - neuer CC', 'verbraucht 1 CC-Guthaben'], style={bekannt ? { width: `${anteil}%` } : undefined}
['REDESIGN', 'Angenommen - Redesign', 'verbraucht 1 Redesign-Guthaben'], />
['REACTIVATION', 'Angenommen - Reaktivierung', 'ein inaktiver CC kehrt zurück'], </div>
['REJECTED', 'Abgelehnt', 'keine Buchung'],
['IGNORE', 'Kein Antrag', 'keine Buchung'], {letzte.length > 0 && (
] as const <ul className="progress-log">
).map(([value, label, hint]) => ( {letzte.map((zeile, index) => (
<label key={value} className="radio"> <li key={`${index}-${zeile}`}>{zeile}</li>
<input
type="radio"
name="classification"
checked={choice === value}
onChange={() => setChoice(value)}
/>
<span>
<strong>{label}</strong>
<span className="muted"> - {hint}</span>
</span>
</label>
))} ))}
</div> </ul>
)}
{needsPlayer && !thread.playerId && ( <p className="muted small">
<Notice kind="warn"> Zwischen den Abrufen wird bewusst gewartet, damit das Forum den Zugriff nicht bremst. Der
Ohne zugeordneten Spieler lässt sich nichts buchen. Bitte erst „Spieler zuordnen". Lauf geht im Hintergrund weiter, auch wenn diese Seite geschlossen wird.
</Notice> </p>
)} </section>
{needsName && thread.playerId && (
<>
<label htmlFor="cc-name">Name des CCs</label>
<input
id="cc-name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
<p className="muted small">Vorbelegt mit dem Thread-Titel - bitte anpassen.</p>
</>
)}
{choice === 'REACTIVATION' && thread.playerId && (
<>
<label htmlFor="revive">Welcher CC kehrt zurück?</label>
<select
id="revive"
value={reviveId ?? ''}
onChange={(event) => setReviveId(Number(event.target.value) || null)}
required
>
<option value="">- bitte wählen -</option>
{inactive.map((character) => (
<option key={character.id} value={character.id}>
{character.name}
{character.inactiveReason === 'REWORKED' ? ' (redesignt - kostet)' : ' (gratis)'}
</option>
))}
</select>
{inactive.length === 0 && (
<p className="muted small neg">Dieser Spieler hat keine inaktiven CCs.</p>
)}
{gewaehlterCC && !reaktivierungKostet && (
<Notice kind="success">
Kommt gratis zurück - der CC existiert unverändert weiter.
</Notice>
)}
{reaktivierungKostet && (
<>
<Notice kind="warn">
Dieser CC wurde redesignt und existiert im Original nicht mehr. Die Rückkehr
kostet ein Guthaben.
</Notice>
<label>Womit bezahlen?</label>
<div className="radio-group">
<label className="radio">
<input
type="radio"
name="reviveKind"
checked={useKind === 'REDESIGN_KULANZ'}
onChange={() => setUseKind('REDESIGN_KULANZ')}
/>
<span>Kulanz-Redesign</span>
</label>
<label className="radio">
<input
type="radio"
name="reviveKind"
checked={useKind === 'REDESIGN_PAID'}
onChange={() => setUseKind('REDESIGN_PAID')}
/>
<span>Bezahltes Redesign</span>
</label>
</div>
</>
)}
</>
)}
{choice === 'REDESIGN' && thread.playerId && (
<>
<label htmlFor="vorgaenger">Welcher CC wurde redesignt?</label>
<select
id="vorgaenger"
value={predecessorId ?? ''}
onChange={(event) => setPredecessorId(Number(event.target.value) || null)}
required
>
<option value="">- bitte wählen -</option>
{characters.map((character) => (
<option key={character.id} value={character.id}>
{character.name}
</option>
))}
</select>
{characters.length === 0 && (
<p className="muted small neg">Dieser Spieler hat keine aktiven CCs.</p>
)}
<label>Womit bezahlen?</label>
<div className="radio-group">
<label className="radio">
<input
type="radio"
name="useKind"
checked={useKind === 'REDESIGN_KULANZ'}
onChange={() => setUseKind('REDESIGN_KULANZ')}
/>
<span>Kulanz-Redesign</span>
</label>
<label className="radio">
<input
type="radio"
name="useKind"
checked={useKind === 'REDESIGN_PAID'}
onChange={() => setUseKind('REDESIGN_PAID')}
/>
<span>Bezahltes Redesign</span>
</label>
</div>
</>
)}
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen
</button>
<button
type="submit"
className="btn btn-primary"
disabled={busy || (needsPlayer && !thread.playerId)}
>
{busy ? 'Speichere …' : 'Entscheiden'}
</button>
</div>
</form>
</Modal>
);
}
function AssignModal({
thread,
onClose,
onDone,
onError,
}: {
thread: ForumThread;
onClose: () => void;
onDone: () => void;
onError: (message: string) => void;
}) {
const [query, setQuery] = useState(thread.authorName ?? '');
const [results, setResults] = useState<PlayerSummary[]>([]);
useEffect(() => {
const timer = setTimeout(async () => {
try {
const params = new URLSearchParams({ q: query, take: '20' });
const data = await api<{ items: PlayerSummary[] }>(`/api/players?${params}`);
setResults(data.items);
} catch {
// Suchfehler blockieren den Dialog nicht.
}
}, 250);
return () => clearTimeout(timer);
}, [query]);
const assign = async (playerId: number) => {
try {
await api(`/api/forum/threads/${thread.threadId}/player`, {
method: 'POST',
body: { playerId },
});
onDone();
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Zuordnen fehlgeschlagen');
}
};
return (
<Modal title="Spieler zuordnen" onClose={onClose}>
<Notice kind="info">
Forumsname des Antragstellers: <strong>{thread.authorName ?? 'unbekannt'}</strong>. Passt
kein Eintrag, trag den Forumsnamen beim Spieler nach - dann findet der nächste Abgleich
ihn von allein.
</Notice>
<label htmlFor="suche">Spieler suchen</label>
<input
id="suche"
value={query}
onChange={(event) => setQuery(event.target.value)}
autoFocus
/>
<ul className="cc-list" style={{ marginTop: '0.75rem' }}>
{results.map((player) => (
<li key={player.id} className="cc-item">
<div>
<strong>{player.displayName}</strong>
{player.forumName && <span className="muted"> · Forum: {player.forumName}</span>}
</div>
<div className="cc-actions">
<button
type="button"
className="btn btn-small btn-primary"
onClick={() => void assign(player.id)}
>
Zuordnen
</button>
</div>
</li>
))}
{results.length === 0 && <p className="muted">Kein Treffer.</p>}
</ul>
</Modal>
); );
} }
+24
View File
@@ -3,6 +3,8 @@ import { Link, useNavigate, useParams } from 'react-router-dom';
import BalanceCards from '../components/BalanceCards'; 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 PendingRowsBox from '../components/PendingRowsBox';
import ThreadsBox from '../components/ThreadsBox';
import Modal from '../components/Modal'; import Modal from '../components/Modal';
import Notice from '../components/Notice'; import Notice from '../components/Notice';
import { ApiError, api } from '../lib/api'; import { ApiError, api } from '../lib/api';
@@ -231,6 +233,17 @@ export default function PlayerDetail() {
onResolved={() => void load()} onResolved={() => void load()}
/> />
<PendingRowsBox
rows={detail.pendingRows ?? []}
canEdit={editor}
onChanged={() => void load()}
onError={setError}
onFlash={(text) => {
setFlash(text);
window.setTimeout(() => setFlash(null), 6000);
}}
/>
{editor && ( {editor && (
<section className="action-bar"> <section className="action-bar">
<button <button
@@ -441,6 +454,17 @@ export default function PlayerDetail() {
)} )}
</section> </section>
<ThreadsBox
threads={detail.threads ?? []}
canEdit={editor}
onChanged={() => void load()}
onError={setError}
onFlash={(text) => {
setFlash(text);
window.setTimeout(() => setFlash(null), 6000);
}}
/>
<AliasBox <AliasBox
playerId={playerId} playerId={playerId}
aliases={detail.aliases ?? []} aliases={detail.aliases ?? []}
+91
View File
@@ -996,3 +996,94 @@ textarea {
border-color: var(--negative); border-color: var(--negative);
color: var(--negative); color: var(--negative);
} }
/* --- Fortschritt eines laufenden Abgleichs --- */
.progress {
margin: 0.75rem 0;
}
.progress-bar {
height: 0.5rem;
background: var(--bg-input);
border-radius: 999px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: 999px;
transition: width 0.4s ease;
}
/* Solange der Umfang noch nicht feststeht, läuft der Balken durch. */
.progress-fill.is-unknown {
width: 35%;
animation: progress-sweep 1.6s ease-in-out infinite;
}
@keyframes progress-sweep {
0% {
margin-left: -35%;
}
100% {
margin-left: 100%;
}
}
.progress-head {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.4rem;
font-size: 0.85rem;
}
.progress-log {
margin: 0.6rem 0 0;
padding: 0.5rem 0.7rem;
max-height: 9rem;
overflow-y: auto;
background: var(--bg-input);
border-radius: var(--radius);
list-style: none;
font-size: 0.82rem;
color: var(--text-muted);
}
.progress-log li + li {
margin-top: 0.15rem;
}
.progress-log li:last-child {
color: var(--text);
}
/* --- Blättern --- */
.pager {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-top: 0.75rem;
}
.list-tools {
display: flex;
gap: 0.75rem;
align-items: center;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.list-tools input[type='search'] {
flex: 1 1 14rem;
width: auto;
}
.list-tools select {
flex: 0 0 auto;
width: auto;
}