Weboberflaeche mit Spielerkonto, Import und Verwaltung
Die Spieler-Detailseite beantwortet die Kernfrage direkt: drei grosse Zaehler, rot sobald ein Topf im Minus ist, darunter ob ein neuer CC oder ein Redesign gerade moeglich ist. Inaktive CCs zeigen ihren Grund und bieten genau die passende Aktion an - gratis reaktivieren oder bezahlt. Beim Redesign steht der Kulanz-Topf an erster Stelle, damit er zuerst aufgebraucht wird. Der Import fuehrt ueber Spaltenzuordnung und Vorschau; gebucht wird erst nach Bestaetigung.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
const CSRF_COOKIE = 'ccm_csrf';
|
||||
const CSRF_HEADER = 'x-csrf-token';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
function readCookie(name: string): string | undefined {
|
||||
const match = document.cookie
|
||||
.split('; ')
|
||||
.find((entry) => entry.startsWith(`${name}=`));
|
||||
return match ? decodeURIComponent(match.slice(name.length + 1)) : undefined;
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const method = options.method ?? 'GET';
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (options.body !== undefined) {
|
||||
headers['content-type'] = 'application/json';
|
||||
}
|
||||
if (method !== 'GET') {
|
||||
const token = readCookie(CSRF_COOKIE);
|
||||
if (token) headers[CSRF_HEADER] = token;
|
||||
}
|
||||
|
||||
const response = await fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
...(options.signal ? { signal: options.signal } : {}),
|
||||
});
|
||||
|
||||
if (response.status === 204) return undefined as T;
|
||||
|
||||
const text = await response.text();
|
||||
const payload = text ? safeParse(text) : null;
|
||||
|
||||
if (!response.ok) {
|
||||
const error = payload as { error?: string; message?: string } | null;
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
error?.error ?? 'UNKNOWN',
|
||||
error?.message ?? `Fehler ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
/** Datei-Upload laeuft ohne JSON-Header, aber mit demselben CSRF-Schutz. */
|
||||
export async function uploadFile<T>(path: string, file: File): Promise<T> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const token = readCookie(CSRF_COOKIE);
|
||||
if (token) headers[CSRF_HEADER] = token;
|
||||
|
||||
const response = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
body: form,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const payload = text ? safeParse(text) : null;
|
||||
|
||||
if (!response.ok) {
|
||||
const error = payload as { error?: string; message?: string } | null;
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
error?.error ?? 'UNKNOWN',
|
||||
error?.message ?? `Fehler ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
function safeParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { message: text };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { CreditKind, CreditSource, InactiveReason, ImportPlan } from './types';
|
||||
|
||||
export const KIND_LABEL: Record<CreditKind, string> = {
|
||||
CC: 'CC-Guthaben',
|
||||
REDESIGN_PAID: 'Redesign (bezahlt)',
|
||||
REDESIGN_KULANZ: 'Redesign (Kulanz)',
|
||||
};
|
||||
|
||||
export const SOURCE_LABEL: Record<CreditSource, string> = {
|
||||
CC_PACKAGE: 'CC-Paket',
|
||||
REDESIGN_PACKAGE: 'Redesign-Paket',
|
||||
KULANZ: 'Kulanz',
|
||||
ADJUSTMENT: 'Korrektur',
|
||||
CONSUME: 'Verbrauch',
|
||||
REVERSAL: 'Storno',
|
||||
};
|
||||
|
||||
export const REASON_LABEL: Record<InactiveReason, string> = {
|
||||
PLAYER_INACTIVITY: 'Spieler-Inaktivitaet',
|
||||
REWORKED: 'Redesignt',
|
||||
OTHER: 'Sonstiger Grund',
|
||||
};
|
||||
|
||||
export const PLAN_LABEL: Record<ImportPlan, string> = {
|
||||
BOOK: 'Wird gebucht',
|
||||
CREATE_PLAYER_AND_BOOK: 'Spieler anlegen + buchen',
|
||||
SKIP_DUPLICATE: 'Uebersprungen: schon gebucht',
|
||||
SKIP_IGNORED: 'Uebersprungen: ignoriertes Produkt',
|
||||
SKIP_UNMAPPED: 'Uebersprungen: kein Mapping',
|
||||
SKIP_NO_PLAYER: 'Uebersprungen: Spieler unbekannt',
|
||||
};
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
export function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '—';
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? '—' : dateFormat.format(parsed);
|
||||
}
|
||||
|
||||
export function formatAmount(amount: number): string {
|
||||
return amount > 0 ? `+${amount}` : String(amount);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { api } from './api';
|
||||
import type { Role, SessionUser } from './types';
|
||||
|
||||
interface SessionState {
|
||||
user: SessionUser | null;
|
||||
loading: boolean;
|
||||
setUser: (user: SessionUser | null) => void;
|
||||
refresh: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
can: (minimum: Role) => boolean;
|
||||
}
|
||||
|
||||
const RANK: Record<Role, number> = { VIEWER: 1, EDITOR: 2, ADMIN: 3 };
|
||||
|
||||
const SessionContext = createContext<SessionState | null>(null);
|
||||
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<SessionUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const result = await api<{ user: SessionUser | null }>('/api/auth/me');
|
||||
setUser(result.user);
|
||||
} catch {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await api('/api/auth/logout', { method: 'POST' });
|
||||
} finally {
|
||||
setUser(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const can = useCallback(
|
||||
(minimum: Role) => (user ? RANK[user.role] >= RANK[minimum] : false),
|
||||
[user],
|
||||
);
|
||||
|
||||
const value = useMemo<SessionState>(
|
||||
() => ({ user, loading, setUser, refresh, logout, can }),
|
||||
[user, loading, refresh, logout, can],
|
||||
);
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSession(): SessionState {
|
||||
const context = useContext(SessionContext);
|
||||
if (!context) throw new Error('useSession benoetigt einen SessionProvider');
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
export type Role = 'ADMIN' | 'EDITOR' | 'VIEWER';
|
||||
export type CreditKind = 'CC' | 'REDESIGN_PAID' | 'REDESIGN_KULANZ';
|
||||
export type CreditSource =
|
||||
| 'CC_PACKAGE'
|
||||
| 'REDESIGN_PACKAGE'
|
||||
| 'KULANZ'
|
||||
| 'ADJUSTMENT'
|
||||
| 'CONSUME'
|
||||
| 'REVERSAL';
|
||||
export type CharacterStatus = 'ACTIVE' | 'INACTIVE';
|
||||
export type InactiveReason = 'PLAYER_INACTIVITY' | 'REWORKED' | 'OTHER';
|
||||
export type ProductTarget = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'IGNORE';
|
||||
|
||||
export interface SessionUser {
|
||||
id: number;
|
||||
username: string;
|
||||
role: Role;
|
||||
mustChangePassword: boolean;
|
||||
totpEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface Balances {
|
||||
CC: number;
|
||||
REDESIGN_PAID: number;
|
||||
REDESIGN_KULANZ: number;
|
||||
redesignTotal: number;
|
||||
}
|
||||
|
||||
export interface PlayerSummary {
|
||||
id: number;
|
||||
displayName: string;
|
||||
steamId: string | null;
|
||||
forumName: string | null;
|
||||
characterCount: number;
|
||||
activeCharacterCount: number;
|
||||
balances: Balances;
|
||||
}
|
||||
|
||||
export interface Player {
|
||||
id: number;
|
||||
displayName: string;
|
||||
steamId: string | null;
|
||||
forumName: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CharacterRef {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Character {
|
||||
id: number;
|
||||
playerId: number;
|
||||
name: string;
|
||||
loreText: string | null;
|
||||
status: CharacterStatus;
|
||||
inactiveReason: InactiveReason | null;
|
||||
lineageId: string;
|
||||
predecessorId: number | null;
|
||||
predecessor: CharacterRef | null;
|
||||
successors: CharacterRef[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface LedgerEntry {
|
||||
id: number;
|
||||
playerId: number;
|
||||
kind: CreditKind;
|
||||
amount: number;
|
||||
source: CreditSource;
|
||||
groupId: string;
|
||||
note: string | null;
|
||||
characterId: number | null;
|
||||
character: CharacterRef | null;
|
||||
createdAt: string;
|
||||
createdBy: { username: string } | null;
|
||||
reversedBy: { id: number } | null;
|
||||
reversalOfId: number | null;
|
||||
}
|
||||
|
||||
export interface PlayerDetail {
|
||||
player: Player;
|
||||
balances: Balances;
|
||||
characters: Character[];
|
||||
ledger: LedgerEntry[];
|
||||
ledgerTotal: number;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
role: Role;
|
||||
isActive: boolean;
|
||||
totpEnabled: boolean;
|
||||
mustChangePassword: boolean;
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ProductMapping {
|
||||
id: number;
|
||||
matchPattern: string;
|
||||
target: ProductTarget;
|
||||
priority: number;
|
||||
isActive: boolean;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number;
|
||||
actorUserId: number | null;
|
||||
actorName: string | null;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId: string | null;
|
||||
details: unknown;
|
||||
ip: string | null;
|
||||
createdAt: string;
|
||||
actor: { username: string } | null;
|
||||
}
|
||||
|
||||
export type ImportPlan =
|
||||
| 'BOOK'
|
||||
| 'CREATE_PLAYER_AND_BOOK'
|
||||
| 'SKIP_DUPLICATE'
|
||||
| 'SKIP_IGNORED'
|
||||
| 'SKIP_UNMAPPED'
|
||||
| 'SKIP_NO_PLAYER';
|
||||
|
||||
export interface ImportRow {
|
||||
id: number;
|
||||
lineNo: number;
|
||||
externalId: string | null;
|
||||
playerKey: string | null;
|
||||
playerName: string | null;
|
||||
productName: string | null;
|
||||
purchasedAt: string | null;
|
||||
target: ProductTarget | null;
|
||||
resolvedPlayerId: number | null;
|
||||
plan: ImportPlan;
|
||||
raw: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ImportRecord {
|
||||
id: number;
|
||||
filename: string;
|
||||
status: 'PENDING' | 'COMMITTED' | 'DISCARDED';
|
||||
rowCount: number;
|
||||
bookedCount: number;
|
||||
createdAt: string;
|
||||
committedAt: string | null;
|
||||
importedBy: { username: string } | null;
|
||||
rows?: ImportRow[];
|
||||
}
|
||||
Reference in New Issue
Block a user