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) => ( +
  • +
    + +

    + {row.purchasedAt ? `Gekauft ${formatDate(row.purchasedAt)}` : 'Ohne Kaufdatum'} · + aus „{row.import.filename}" + {row.externalId && ` · ${row.externalId}`} +

    +
    +
  • + ))} +
+ + {canEdit && ( +
+ + +
+ )} +
+ ); +} 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 ( + +
+ + {thread.title} +
+ von {thread.authorName ?? 'unbekannt'} + {thread.player ? ` · ${thread.player.displayName}` : ' · kein Spieler zugeordnet'} +
+ +
+ {( + [ + ['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]) => ( + + ))} +
+ + {needsPlayer && !thread.playerId && ( + + Ohne zugeordneten Spieler lässt sich nichts buchen. Bitte erst „Spieler zuordnen". + + )} + + {needsName && thread.playerId && ( + <> + + setName(event.target.value)} + required + /> +

Vorbelegt mit dem Thread-Titel - bitte anpassen.

+ + )} + + {choice === 'REACTIVATION' && thread.playerId && ( + <> + + + {inactive.length === 0 && ( +

Dieser Spieler hat keine inaktiven CCs.

+ )} + + {gewaehlterCC && !reaktivierungKostet && ( + + Kommt gratis zurück - der CC existiert unverändert weiter. + + )} + + {reaktivierungKostet && ( + <> + + Dieser CC wurde redesignt und existiert im Original nicht mehr. Die Rückkehr + kostet ein Guthaben. + + +
+ + +
+ + )} + + )} + + {choice === 'REDESIGN' && thread.playerId && ( + <> + + + {characters.length === 0 && ( +

Dieser Spieler hat keine aktiven CCs.

+ )} + + +
+ + +
+ + )} + +
+ + +
+
+
+ ); +} + +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. + + + + setQuery(event.target.value)} + autoFocus + /> + +
    + {results.map((player) => ( +
  • +
    + {player.displayName} + {player.forumName && · Forum: {player.forumName}} +
    +
    + +
    +
  • + ))} + {results.length === 0 &&

    Kein Treffer.

    } +
+
+ ); +} 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. + + )} + +
    + {threads.map((thread) => ( +
  • +
    + + {thread.title} + + {BOARD_LABEL[thread.currentBoard]} + {thread.classification && ( + + {CLASSIFICATION_LABEL[thread.classification]} + + )} +

    + von {thread.authorName ?? 'unbekannt'} + {thread.labels && ` · ${thread.labels}`} + {thread.linkedCharacter && ` · CC: ${thread.linkedCharacter.name}`} + {thread.classifiedAt && ` · entschieden ${formatDate(thread.classifiedAt)}`} +

    +
    + {canEdit && thread.needsClassification && ( +
    + +
    + )} +
  • + ))} +
+ + {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 && ( )}
@@ -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() {

Wartet auf Entscheidung ({threads.length})

+
+ setSucheOffen(event.target.value)} + /> + +
+ {threads.length === 0 ? (

- 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.' : ''}`}

) : (
    @@ -281,14 +346,77 @@ 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 && ( + + )} +
+ + {autoren.length === 0 ? ( +

Jeder Ersteller ist einem Spieler zugeordnet.

+ ) : ( + zeigeAutoren && ( +
    + {autoren.map((autor) => ( +
  • +
    + {autor.authorName} + + {autor.threadCount} {autor.threadCount === 1 ? 'Antrag' : 'Anträge'} + +
    +
    + + {darfEntscheiden && ( + + )} +
    +
  • + ))} +
+ ) + )} +
+

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.

+ + Seite {seite + 1} von {seiten} · {ohneSpieler.total} Anträge + + +
)} )} @@ -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 ( - -
- - {thread.title} -
- von {thread.authorName ?? 'unbekannt'} - {thread.player ? ` · ${thread.player.displayName}` : ' · kein Spieler zugeordnet'} -
+
+
+ + {bekannt + ? `${gelesen} von ${gesamt} Seiten gelesen` + : `Umfang wird ermittelt … (${gelesen} Seiten)`} + + + {state?.currentBoard + ? `liest ${BOARD_PFAD[state.currentBoard] ?? state.currentBoard}` + : 'wird gespeichert'} + {bekannt && ` · ${anteil} %`} + +
-
- {( - [ - ['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]) => ( - +
+
+
+ + {letzte.length > 0 && ( +
    + {letzte.map((zeile, index) => ( +
  • {zeile}
  • ))} -
+ + )} - {needsPlayer && !thread.playerId && ( - - Ohne zugeordneten Spieler lässt sich nichts buchen. Bitte erst „Spieler zuordnen". - - )} - - {needsName && thread.playerId && ( - <> - - setName(event.target.value)} - required - /> -

Vorbelegt mit dem Thread-Titel - bitte anpassen.

- - )} - - {choice === 'REACTIVATION' && thread.playerId && ( - <> - - - {inactive.length === 0 && ( -

Dieser Spieler hat keine inaktiven CCs.

- )} - - {gewaehlterCC && !reaktivierungKostet && ( - - Kommt gratis zurück - der CC existiert unverändert weiter. - - )} - - {reaktivierungKostet && ( - <> - - Dieser CC wurde redesignt und existiert im Original nicht mehr. Die Rückkehr - kostet ein Guthaben. - - -
- - -
- - )} - - )} - - {choice === 'REDESIGN' && thread.playerId && ( - <> - - - {characters.length === 0 && ( -

Dieser Spieler hat keine aktiven CCs.

- )} - - -
- - -
- - )} - -
- - -
- - - ); -} - -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. - - - - setQuery(event.target.value)} - autoFocus - /> - -
    - {results.map((player) => ( -
  • -
    - {player.displayName} - {player.forumName && · Forum: {player.forumName}} -
    -
    - -
    -
  • - ))} - {results.length === 0 &&

    Kein Treffer.

    } -
-
+

+ 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 && (