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