diff --git a/README.md b/README.md
index c794e9d..4dc656d 100644
--- a/README.md
+++ b/README.md
@@ -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
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
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.
@@ -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
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
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.
@@ -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
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
-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.
+Deshalb läuft der Abgleich **im Hintergrund**, mit einem Fortschrittsbalken und einem Protokoll,
+das Seite für Seite mitschreibt. Gelesen wird zuerst von jedem Board die erste Seite - erst
+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
diff --git a/server/src/routes/forum.routes.ts b/server/src/routes/forum.routes.ts
index 5093189..955aaff 100644
--- a/server/src/routes/forum.routes.ts
+++ b/server/src/routes/forum.routes.ts
@@ -10,6 +10,7 @@ import {
listBoardChangeNotices,
listThreads,
listUnassignedThreads,
+ listUntrackedAuthors,
startScan,
} from '../services/forum.js';
import { contextOf, idParam, parseWith } from './helpers.js';
@@ -31,6 +32,8 @@ export async function forumRoutes(app: FastifyInstance): Promise {
open: z.enum(['true', 'false']).default('true'),
playerId: z.coerce.number().int().positive().optional(),
board: z.nativeEnum(ForumBoard).optional(),
+ q: z.string().trim().max(120).optional(),
+ order: z.enum(['newest', 'oldest']).default('newest'),
}),
request.query,
);
@@ -38,8 +41,10 @@ export async function forumRoutes(app: FastifyInstance): Promise {
return {
items: await listThreads({
onlyOpen: query.open === 'true',
+ order: query.order,
...(query.playerId !== undefined ? { playerId: query.playerId } : {}),
...(query.board ? { board: query.board } : {}),
+ ...(query.q ? { query: query.q } : {}),
}),
};
});
@@ -49,16 +54,29 @@ export async function forumRoutes(app: FastifyInstance): Promise {
z.object({
q: z.string().trim().max(120).optional(),
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,
);
return listUnassignedThreads({
take: query.take,
+ skip: query.skip,
+ order: query.order,
...(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 () => {
return { items: await listBoardChangeNotices() };
});
diff --git a/server/src/routes/imports.routes.ts b/server/src/routes/imports.routes.ts
index c1dd32b..e56dc4a 100644
--- a/server/src/routes/imports.routes.ts
+++ b/server/src/routes/imports.routes.ts
@@ -10,6 +10,8 @@ import {
getImport,
listImports,
parseUpload,
+ bookRows,
+ dismissRows,
} from '../services/imports.js';
import { contextOf, idParam, parseWith } from './helpers.js';
@@ -63,6 +65,28 @@ export async function importRoutes(app: FastifyInstance): Promise {
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) => {
const { id } = parseWith(idParam, request.params);
await discardImport(id, contextOf(request));
diff --git a/server/src/services/forum.ts b/server/src/services/forum.ts
index 2c1e73f..ca7c6a4 100644
--- a/server/src/services/forum.ts
+++ b/server/src/services/forum.ts
@@ -14,6 +14,13 @@ export const BOARDS: { board: ForumBoard; path: string }[] = [
{ 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 = {
+ 'board/66-custom-characters': 'Antragsübersicht',
+ 'board/355-aktive-ccs': 'Aktive CCs',
+ 'board/113-cc-archiv': 'Archiv',
+};
+
const MIN_REQUEST_GAP_MS = 1200;
/// Bremst das Forum, wird der Abstand verdoppelt, bis hierhin.
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.
currentBoard: string | null;
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;
error: string | null;
}
@@ -136,10 +147,20 @@ const scanState: ScanState = {
finishedAt: null,
currentBoard: null,
pagesRead: 0,
+ totalPages: 0,
+ log: [],
summary: 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 {
return { ...scanState };
}
@@ -155,6 +176,8 @@ export function startScan(ctx: ActionContext): ScanState {
scanState.finishedAt = null;
scanState.currentBoard = null;
scanState.pagesRead = 0;
+ scanState.totalPages = 0;
+ scanState.log = [];
scanState.summary = null;
scanState.error = null;
@@ -206,40 +229,62 @@ export async function scanForum(ctx: ActionContext): Promise {
// dauern als der Abruf selbst und den Aufruf ins Zeitlimit treiben.
const gefunden = new Map();
+ // 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) {
- let gelesen = 0;
scanState.currentBoard = path;
+ protokoll(`${BOARD_NAMES[path] ?? path}: Umfang wird ermittelt`);
try {
const first = await fetchPage(path, 1);
- gelesen += 1;
summary.scannedPages += 1;
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 (let page = 2; page <= seiten; page += 1) {
- const html = await fetchPage(path, page);
- gelesen += 1;
+ const seiten = Math.min(parsePageCount(first), MAX_PAGES_PER_BOARD);
+ scanState.totalPages += seiten;
+ 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;
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;
+ protokoll(`${name}: fertig`);
} catch (error) {
// Was bis hierher gelesen wurde, bleibt erhalten und wird gleich
// verarbeitet - ein Abbruch auf Seite 17 darf nicht die ersten 16
// Seiten mit in den Papierkorb nehmen.
- summary.failedBoards.push({
- board: path,
- reason: error instanceof Error ? error.message : 'Unbekannter Fehler',
- pagesRead: gelesen,
- });
+ const grund = error instanceof Error ? error.message : 'Unbekannter Fehler';
+ summary.failedBoards.push({ board: eintrag.path, reason: grund, pagesRead: eintrag.gelesen });
+ protokoll(`${name}: abgebrochen nach Seite ${eintrag.gelesen} (${grund})`);
}
}
summary.rateLimited = rateLimitHits;
+ scanState.currentBoard = null;
+ protokoll(`${gefunden.size} Threads gelesen, wird gespeichert`);
const relevant: { thread: ForumThread; board: ForumBoard }[] = [];
for (const eintrag of gefunden.values()) {
@@ -353,6 +398,12 @@ export async function scanForum(ctx: ActionContext): Promise {
});
}
+ protokoll(
+ summary.newThreads > 0
+ ? `${summary.newThreads} neue Anträge erfasst`
+ : 'Keine neuen Anträge',
+ );
+
await recordAudit(prisma, ctx, {
action: 'forum.scan',
targetType: 'forum',
@@ -388,10 +439,33 @@ async function forumNameIndex(): Promise> {
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: {
onlyOpen?: boolean;
playerId?: number;
board?: ForumBoard;
+ query?: string;
+ order?: ThreadOrder;
}) {
return prisma.forumThread.findMany({
where: {
@@ -401,8 +475,9 @@ export async function listThreads(options: {
...(options.onlyOpen ? { needsClassification: true, playerId: { not: null } } : {}),
...(options.playerId !== undefined ? { playerId: options.playerId } : {}),
...(options.board ? { currentBoard: options.board } : {}),
+ ...sucheNach(options.query),
},
- orderBy: [{ lastSeenAt: 'desc' }, { threadId: 'desc' }],
+ orderBy: orderOf(options.order),
include: {
player: { select: { id: true, displayName: 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
* 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 suche = options.query?.trim();
+ const skip = Math.max(options.skip ?? 0, 0);
const where: Prisma.ForumThreadWhereInput = {
playerId: null,
needsClassification: true,
- ...(suche
- ? {
- OR: [
- { title: { contains: suche, mode: 'insensitive' } },
- { authorName: { contains: suche, mode: 'insensitive' } },
- ],
- }
- : {}),
+ ...sucheNach(options.query),
};
const [items, total] = await Promise.all([
prisma.forumThread.findMany({
where,
- orderBy: [{ threadId: 'desc' }],
+ orderBy: orderOf(options.order),
take,
+ skip,
include: {
player: { select: { id: true, displayName: true } },
linkedCharacter: { select: { id: true, name: true } },
@@ -445,7 +519,32 @@ export async function listUnassignedThreads(options: { query?: string; take?: nu
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,
+ })),
+ };
}
/**
diff --git a/server/src/services/imports.ts b/server/src/services/imports.ts
index 544d644..c3f6178 100644
--- a/server/src/services/imports.ts
+++ b/server/src/services/imports.ts
@@ -485,6 +485,172 @@ export async function commitImport(importId: number, ctx: ActionContext) {
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 {
const record = await prisma.import.findUnique({ where: { id: importId } });
if (!record) throw notFound('Import');
diff --git a/server/src/services/players.ts b/server/src/services/players.ts
index b0fbab6..e0811b3 100644
--- a/server/src/services/players.ts
+++ b/server/src/services/players.ts
@@ -80,20 +80,29 @@ export async function getPlayerDetail(playerId: number) {
const player = await prisma.player.findUnique({ where: { id: playerId } });
if (!player) throw notFound('Spieler');
- const [balances, characters, ledger, unmatched, aliases] = await Promise.all([
- getBalances(playerId),
- listCharacters(playerId),
- listLedger({ playerId, take: 200 }),
- prisma.unmatchedPackage.findMany({
- where: { playerId, resolved: false },
- orderBy: { scrapedAt: 'desc' },
- include: { player: { select: { id: true, displayName: true } } },
- }),
- prisma.playerAlias.findMany({
- where: { playerId },
- orderBy: [{ type: 'asc' }, { value: 'asc' }],
- }),
- ]);
+ // Threads und offene Paket-Zeilen kommen mit, damit sich ein Spieler an
+ // einem Stück abarbeiten lässt, statt zwischen drei Seiten zu wechseln.
+ const { listThreads } = await import('./forum.js');
+ const { listPendingRowsFor } = await import('./imports.js');
+
+ const [balances, characters, ledger, unmatched, aliases, threads, pendingRows] =
+ await Promise.all([
+ getBalances(playerId),
+ listCharacters(playerId),
+ listLedger({ playerId, take: 200 }),
+ prisma.unmatchedPackage.findMany({
+ 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 {
player,
@@ -103,6 +112,8 @@ export async function getPlayerDetail(playerId: number) {
ledgerTotal: ledger.total,
unmatched,
aliases,
+ threads,
+ pendingRows,
};
}
diff --git a/server/test/antraege.test.ts b/server/test/antraege.test.ts
index 82a21fa..9d426d5 100644
--- a/server/test/antraege.test.ts
+++ b/server/test/antraege.test.ts
@@ -311,3 +311,107 @@ describe('Konten beim Abgleich', () => {
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',
+ );
+ });
+});
diff --git a/server/test/imports.test.ts b/server/test/imports.test.ts
index d6565b7..791101e 100644
--- a/server/test/imports.test.ts
+++ b/server/test/imports.test.ts
@@ -241,3 +241,145 @@ describe('Kollisionen beim Buchen', () => {
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);
+ });
+});
diff --git a/web/src/components/PendingRowsBox.tsx b/web/src/components/PendingRowsBox.tsx
new file mode 100644
index 0000000..57800b4
--- /dev/null
+++ b/web/src/components/PendingRowsBox.tsx
@@ -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 = {
+ 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>(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 (
+
+ Offene Pakete aus dem Abgleich ({rows.length})
+
+ Diese Käufe liegen in einer Import-Vorschau und sind noch nicht gebucht. Hier gebucht,
+ verschwinden sie aus der Vorschau - doppelt buchen ist ausgeschlossen.
+
+
+
+ {rows.map((row) => (
+
+
+
+ umschalten(row.id)}
+ disabled={!canEdit || busy}
+ />
+
+ {row.productName ?? 'Ohne Bezeichnung'}
+ {row.target && {ZIEL_LABEL[row.target]} }
+
+
+
+ {row.purchasedAt ? `Gekauft ${formatDate(row.purchasedAt)}` : 'Ohne Kaufdatum'} ·
+ aus „{row.import.filename}"
+ {row.externalId && ` · ${row.externalId}`}
+
+
+
+ ))}
+
+
+ {canEdit && (
+
+ void ausfuehren('dismiss')}
+ >
+ {ausgewaehlt.length > 0 ? `${ausgewaehlt.length} weglegen` : 'Alle weglegen'}
+
+ void ausfuehren('book')}
+ >
+ {busy
+ ? 'Buche …'
+ : ausgewaehlt.length > 0
+ ? `${ausgewaehlt.length} buchen`
+ : `Alle ${rows.length} buchen`}
+
+
+ )}
+
+ );
+}
diff --git a/web/src/components/ThreadModals.tsx b/web/src/components/ThreadModals.tsx
new file mode 100644
index 0000000..be8d117
--- /dev/null
+++ b/web/src/components/ThreadModals.tsx
@@ -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('NEW_CC');
+ const [name, setName] = useState(thread.title);
+ const [predecessorId, setPredecessorId] = useState(null);
+ const [reviveId, setReviveId] = useState(null);
+ const [useKind, setUseKind] = useState<'REDESIGN_KULANZ' | 'REDESIGN_PAID'>('REDESIGN_KULANZ');
+ const [characters, setCharacters] = useState([]);
+ const [inactive, setInactive] = useState([]);
+ 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(`/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 (
+
+
+
+ );
+}
+
+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([]);
+
+ 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 (
+
+
+ Forumsname des Antragstellers: {thread.authorName ?? 'unbekannt'} . Passt
+ kein Eintrag, trag den Forumsnamen beim Spieler nach - dann findet der nächste Abgleich
+ ihn von allein.
+
+
+ Spieler suchen
+ setQuery(event.target.value)}
+ autoFocus
+ />
+
+
+
+ );
+}
diff --git a/web/src/components/ThreadsBox.tsx b/web/src/components/ThreadsBox.tsx
new file mode 100644
index 0000000..fbe29fb
--- /dev/null
+++ b/web/src/components/ThreadsBox.tsx
@@ -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(null);
+
+ const offen = threads.filter((thread) => thread.needsClassification);
+ const entschieden = threads.filter((thread) => !thread.needsClassification);
+
+ return (
+ 0 ? 'block block-flag' : 'block'}>
+ Forums-Anträge ({threads.length})
+
+ {threads.length === 0 ? (
+
+ Keine Anträge zugeordnet. Zugeordnet wird über den Forum-Namen des Erstellers - steht
+ hier keiner, findet der Abgleich nichts.
+
+ ) : (
+ <>
+ {offen.length > 0 && (
+
+ {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.
+
+ )}
+
+
+
+ {entschieden.length > 0 && offen.length === 0 && (
+ Alle Anträge sind entschieden.
+ )}
+ >
+ )}
+
+ {dialog && (
+ setDialog(null)}
+ onDone={(message) => {
+ setDialog(null);
+ onFlash(message);
+ onChanged();
+ }}
+ onError={onError}
+ />
+ )}
+
+ );
+}
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index f8105cf..5cbfd76 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -168,6 +168,10 @@ export interface PlayerDetail {
ledgerTotal: number;
unmatched: UnmatchedPackage[];
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 {
@@ -260,6 +264,7 @@ export interface ForumThread {
lastBoardChangeAt: string | null;
classification: ForumClassification | null;
needsClassification: boolean;
+ classifiedAt: string | null;
linkedCharacterId: number | null;
linkedCharacter: { id: number; name: string; status?: string } | null;
boardChangeNotice: boolean;
@@ -282,6 +287,8 @@ export interface ForumScanSummary {
export interface UnassignedThreads {
items: ForumThread[];
total: number;
+ take: number;
+ skip: number;
}
export interface ScanState {
@@ -290,6 +297,28 @@ export interface ScanState {
finishedAt: string | null;
currentBoard: string | null;
pagesRead: number;
+ /// Steht erst fest, wenn von jedem Board die erste Seite gelesen ist.
+ totalPages: number;
+ log: string[];
summary: ForumScanSummary | 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 };
+}
diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx
index 3d9ffbd..4b74659 100644
--- a/web/src/pages/Dashboard.tsx
+++ b/web/src/pages/Dashboard.tsx
@@ -1,5 +1,5 @@
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 Notice from '../components/Notice';
import { ApiError, api } from '../lib/api';
@@ -25,6 +25,10 @@ export default function Dashboard() {
const [result, setResult] = useState({ total: 0, items: [] });
const [loading, setLoading] = useState(true);
const [error, setError] = useState(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 [flagged, setFlagged] = useState({ total: 0, players: [] });
const [syncing, setSyncing] = useState(false);
@@ -215,11 +219,16 @@ export default function Dashboard() {
)}
- {creating && (
+ {(creating || vorgabe !== null) && (
setCreating(false)}
+ forumNameVorgabe={vorgabe ?? ''}
+ onClose={() => {
+ setCreating(false);
+ if (vorgabe !== null) setParams({}, { replace: true });
+ }}
onCreated={() => {
setCreating(false);
+ if (vorgabe !== null) setParams({}, { replace: true });
void load(query, nurProbleme);
}}
/>
@@ -228,10 +237,20 @@ export default function Dashboard() {
);
}
-function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
- const [displayName, setDisplayName] = useState('');
+function CreatePlayerModal({
+ 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 [forumName, setForumName] = useState('');
+ const [forumName, setForumName] = useState(forumNameVorgabe);
const [istProblem, setIstProblem] = useState(false);
const [problemNote, setProblemNote] = useState('');
const [error, setError] = useState(null);
diff --git a/web/src/pages/Forum.tsx b/web/src/pages/Forum.tsx
index 82e33ba..34cb636 100644
--- a/web/src/pages/Forum.tsx
+++ b/web/src/pages/Forum.tsx
@@ -1,19 +1,17 @@
import { useCallback, useEffect, useState } from 'react';
-import { Link } from 'react-router-dom';
-import Modal from '../components/Modal';
+import { Link, useNavigate } from 'react-router-dom';
import Notice from '../components/Notice';
+import { AssignModal, ClassifyModal } from '../components/ThreadModals';
import { ApiError, api } from '../lib/api';
import { BOARD_LABEL, CLASSIFICATION_LABEL, formatDate } from '../lib/format';
import { useSession } from '../lib/session';
import type {
- Character,
- ForumClassification,
ForumScanSummary,
ForumThread,
- PlayerDetail,
- PlayerSummary,
ScanState,
+ ThreadOrder,
UnassignedThreads,
+ UntrackedAuthor,
} from '../lib/types';
/// Die Board-Pfade des Abgleichs in lesbarer Form.
@@ -23,8 +21,11 @@ const BOARD_PFAD: Record = {
'board/113-cc-archiv': 'das Archiv',
};
+const SEITE = 25;
+
export default function Forum() {
const { can } = useSession();
+ const navigate = useNavigate();
const darfEntscheiden = can('EDITOR');
const [threads, setThreads] = useState([]);
@@ -34,36 +35,73 @@ export default function Forum() {
const [busy, setBusy] = useState(false);
const [dialog, setDialog] = useState(null);
const [assigning, setAssigning] = useState(null);
- const [ohneSpieler, setOhneSpieler] = useState(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(null);
- const ladeOhneSpieler = useCallback(async (query: string) => {
- const param = query.trim() ? `?q=${encodeURIComponent(query.trim())}` : '';
+ // Arbeitsliste
+ const [sucheOffen, setSucheOffen] = useState('');
+ const [sortOffen, setSortOffen] = useState('newest');
+
+ // Ohne zugeordneten Spieler
+ const [ohneSpieler, setOhneSpieler] = useState(null);
+ const [sucheOhne, setSucheOhne] = useState('');
+ const [sortOhne, setSortOhne] = useState('newest');
+ const [seite, setSeite] = useState(0);
+ const [zeigeOhneSpieler, setZeigeOhneSpieler] = useState(false);
+
+ // Ersteller ohne Spieler
+ const [autoren, setAutoren] = useState([]);
+ 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 {
- setOhneSpieler(await api(`/api/forum/threads/unassigned${param}`));
+ const data = await api<{ items: ForumThread[] }>(`/api/forum/threads?${params}`);
+ setThreads(data.items);
} catch (err) {
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(`/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 () => {
try {
- const [open, notice] = await Promise.all([
- api<{ items: ForumThread[] }>('/api/forum/threads?open=true'),
- api<{ items: ForumThread[] }>('/api/forum/notices'),
- ]);
- setThreads(open.items);
- setNotices(notice.items);
+ setNotices((await api<{ items: ForumThread[] }>('/api/forum/notices')).items);
setError(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
}
+ await ladeAutoren();
setStand((n) => n + 1);
- }, []);
+ }, [ladeAutoren]);
useEffect(() => {
void load();
@@ -71,9 +109,25 @@ export default function Forum() {
// Beim Tippen kurz abwarten, statt jede Taste an den Server zu schicken.
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);
- }, [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 parts = [`${result.scannedPages} Seiten gelesen`];
@@ -100,27 +154,26 @@ export default function Forum() {
useEffect(() => {
if (!busy) return;
- const timer = window.setInterval(() => {
- void (async () => {
- try {
- const state = await api('/api/forum/scan/status');
- setScan(state);
- if (state.running) return;
+ const abfragen = async () => {
+ try {
+ const state = await api('/api/forum/scan/status');
+ setScan(state);
+ if (state.running) return;
- setBusy(false);
- if (state.error) setError(state.error);
- else if (state.summary) {
- setFlash(bericht(state.summary));
- window.setTimeout(() => setFlash(null), 12000);
- }
- await load();
- } catch (err) {
- setBusy(false);
- setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
+ setBusy(false);
+ if (state.error) setError(state.error);
+ else if (state.summary) {
+ setFlash(bericht(state.summary));
+ window.setTimeout(() => setFlash(null), 12000);
}
- })();
- }, 2000);
+ await load();
+ } catch (err) {
+ setBusy(false);
+ setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
+ }
+ };
+ const timer = window.setInterval(() => void abfragen(), 1500);
return () => window.clearInterval(timer);
}, [busy, load]);
@@ -156,6 +209,8 @@ export default function Forum() {
}
};
+ const seiten = ohneSpieler ? Math.ceil(ohneSpieler.total / SEITE) : 0;
+
return (
@@ -163,15 +218,13 @@ export default function Forum() {
Forums-Anträge
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,
- die hier mit passendem Forum-Namen erfasst sind.
+ Signal und lösen nie eine Buchung aus. Anträge von Erstellern, die hier unter keinem
+ Forum-Namen geführt werden, stehen weiter unten.
{darfEntscheiden && (
void scan()}>
- {busy
- ? `Lese Forum … (${scanZustand?.pagesRead ?? 0} Seiten)`
- : 'Forum abgleichen'}
+ {busy ? 'Lese Forum …' : 'Forum abgleichen'}
)}
@@ -179,14 +232,7 @@ export default function Forum() {
{error && setError(null)}>{error} }
{flash && setFlash(null)}>{flash} }
- {busy && (
-
- 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.
-
- )}
+ {busy && }
{notices.length > 0 && (
@@ -229,9 +275,28 @@ export default function Forum() {
+
+
+
+
Ersteller ohne Spieler ({autoren.length})
+
+ 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.
+
+
+ {autoren.length > 0 && (
+
setZeigeAutoren((v) => !v)}
+ >
+ {zeigeAutoren ? 'Einklappen' : 'Anzeigen'}
+
+ )}
+
+
+ {autoren.length === 0 ? (
+ Jeder Ersteller ist einem Spieler zugeordnet.
+ ) : (
+ zeigeAutoren && (
+
+ {autoren.map((autor) => (
+
+
+ {autor.authorName}
+
+ {autor.threadCount} {autor.threadCount === 1 ? 'Antrag' : 'Anträge'}
+
+
+
+ {
+ setSucheOhne(autor.authorName);
+ setZeigeOhneSpieler(true);
+ }}
+ >
+ Anträge zeigen
+
+ {darfEntscheiden && (
+
+ navigate(`/?anlegen=${encodeURIComponent(autor.authorName)}`)
+ }
+ >
+ Spieler anlegen
+
+ )}
+
+
+ ))}
+
+ )
+ )}
+
+
Ohne zugeordneten Spieler ({ohneSpieler?.total ?? 0})
- Anträge, deren Ersteller hier unter keinem Forum-Namen geführt wird. Sie bleiben
- aus der Arbeitsliste heraus, gehen aber nicht verloren. Sobald ein Spieler mit
- passendem Forum-Namen angelegt wird, wandern sie von selbst hinüber.
+ Einzelne Anträge, deren Ersteller hier nicht geführt wird. Sie bleiben aus der
+ Arbeitsliste heraus, gehen aber nicht verloren.
- setSuche(event.target.value)}
- />
+
+ setSucheOhne(event.target.value)}
+ />
+ setSortOhne(event.target.value as ThreadOrder)}
+ aria-label="Sortierung"
+ >
+ Neueste zuerst
+ Älteste zuerst
+
+
{ohneSpieler === null || ohneSpieler.items.length === 0 ? (
-
- {suche ? 'Nichts gefunden.' : 'Alle erfassten Anträge sind zugeordnet.'}
+
+ {sucheOhne ? 'Nichts gefunden.' : 'Alle erfassten Anträge sind zugeordnet.'}
) : (
<>
-
+
{ohneSpieler.items.map((thread) => (
@@ -349,11 +486,29 @@ export default function Forum() {
))}
- {ohneSpieler.total > ohneSpieler.items.length && (
-
- Zeigt {ohneSpieler.items.length} von {ohneSpieler.total} - über die Suche
- eingrenzen.
-
+
+ {seiten > 1 && (
+
+ setSeite((n) => Math.max(0, n - 1))}
+ >
+ Zurück
+
+
+ Seite {seite + 1} von {seiten} · {ohneSpieler.total} Anträge
+
+ = seiten}
+ onClick={() => setSeite((n) => n + 1)}
+ >
+ Weiter
+
+
)}
>
)}
@@ -390,318 +545,53 @@ export default function Forum() {
);
}
-function ClassifyModal({
- thread,
- onClose,
- onDone,
- onError,
-}: {
- thread: ForumThread;
- onClose: () => void;
- onDone: (message: string) => void;
- onError: (message: string) => void;
-}) {
- const [choice, setChoice] = useState
('NEW_CC');
- const [name, setName] = useState(thread.title);
- const [predecessorId, setPredecessorId] = useState(null);
- const [reviveId, setReviveId] = useState(null);
- const [useKind, setUseKind] = useState<'REDESIGN_KULANZ' | 'REDESIGN_PAID'>('REDESIGN_KULANZ');
- const [characters, setCharacters] = useState([]);
- const [inactive, setInactive] = useState([]);
- 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(`/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);
- }
- };
+/**
+ * Was der Abgleich gerade tut. Der Umfang steht erst fest, wenn von jedem
+ * Board die erste Seite gelesen ist - bis dahin läuft der Balken durch,
+ * statt einen erfundenen Anteil zu behaupten.
+ */
+function ScanProgress({ state }: { state: ScanState | null }) {
+ const gelesen = state?.pagesRead ?? 0;
+ const gesamt = state?.totalPages ?? 0;
+ const bekannt = gesamt > 0;
+ const anteil = bekannt ? Math.min(100, Math.round((gelesen / gesamt) * 100)) : 0;
+ const letzte = state?.log.slice(-6) ?? [];
return (
-
-
-
- );
-}
-
-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([]);
-
- 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 (
-
-
- Forumsname des Antragstellers: {thread.authorName ?? 'unbekannt'} . Passt
- kein Eintrag, trag den Forumsnamen beim Spieler nach - dann findet der nächste Abgleich
- ihn von allein.
-
-
- Spieler suchen
- setQuery(event.target.value)}
- autoFocus
- />
-
-
-
+
+ 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.
+
+
);
}
diff --git a/web/src/pages/PlayerDetail.tsx b/web/src/pages/PlayerDetail.tsx
index 8831b3f..d163fc3 100644
--- a/web/src/pages/PlayerDetail.tsx
+++ b/web/src/pages/PlayerDetail.tsx
@@ -3,6 +3,8 @@ import { Link, useNavigate, useParams } from 'react-router-dom';
import BalanceCards from '../components/BalanceCards';
import UnmatchedBox from '../components/UnmatchedBox';
import AliasBox from '../components/AliasBox';
+import PendingRowsBox from '../components/PendingRowsBox';
+import ThreadsBox from '../components/ThreadsBox';
import Modal from '../components/Modal';
import Notice from '../components/Notice';
import { ApiError, api } from '../lib/api';
@@ -231,6 +233,17 @@ export default function PlayerDetail() {
onResolved={() => void load()}
/>
+ void load()}
+ onError={setError}
+ onFlash={(text) => {
+ setFlash(text);
+ window.setTimeout(() => setFlash(null), 6000);
+ }}
+ />
+
{editor && (
+ void load()}
+ onError={setError}
+ onFlash={(text) => {
+ setFlash(text);
+ window.setTimeout(() => setFlash(null), 6000);
+ }}
+ />
+