From 245666d3630eb3f59be8260d46af7d7fa0fa0a77 Mon Sep 17 00:00:00 2001 From: Harlekin Date: Fri, 18 Sep 2026 11:37:43 +0200 Subject: [PATCH] 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. --- web/src/App.tsx | 97 ++++ web/src/components/BalanceCards.tsx | 43 ++ web/src/components/Layout.tsx | 45 ++ web/src/components/Modal.tsx | 40 ++ web/src/components/Notice.tsx | 18 + web/src/lib/api.ts | 102 ++++ web/src/lib/format.ts | 49 ++ web/src/lib/session.tsx | 63 +++ web/src/lib/types.ts | 156 ++++++ web/src/main.tsx | 19 + web/src/pages/Account.tsx | 203 +++++++ web/src/pages/Admin.tsx | 507 +++++++++++++++++ web/src/pages/Dashboard.tsx | 208 +++++++ web/src/pages/Import.tsx | 388 +++++++++++++ web/src/pages/Login.tsx | 141 +++++ web/src/pages/PlayerDetail.tsx | 730 +++++++++++++++++++++++++ web/src/styles.css | 819 ++++++++++++++++++++++++++++ 17 files changed, 3628 insertions(+) create mode 100644 web/src/App.tsx create mode 100644 web/src/components/BalanceCards.tsx create mode 100644 web/src/components/Layout.tsx create mode 100644 web/src/components/Modal.tsx create mode 100644 web/src/components/Notice.tsx create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/format.ts create mode 100644 web/src/lib/session.tsx create mode 100644 web/src/lib/types.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/pages/Account.tsx create mode 100644 web/src/pages/Admin.tsx create mode 100644 web/src/pages/Dashboard.tsx create mode 100644 web/src/pages/Import.tsx create mode 100644 web/src/pages/Login.tsx create mode 100644 web/src/pages/PlayerDetail.tsx create mode 100644 web/src/styles.css diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..0ced32a --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,97 @@ +import { Navigate, Route, Routes } from 'react-router-dom'; +import Layout from './components/Layout'; +import { useSession } from './lib/session'; +import type { Role } from './lib/types'; +import Account from './pages/Account'; +import Admin from './pages/Admin'; +import Dashboard from './pages/Dashboard'; +import Import from './pages/Import'; +import Login from './pages/Login'; +import PlayerDetail from './pages/PlayerDetail'; + +function Protected({ children, minimum }: { children: React.ReactNode; minimum?: Role }) { + const { user, loading, can } = useSession(); + + if (loading) return
Lade …
; + if (!user) return ; + + // Ein frisch angelegtes Konto muss zuerst sein Passwort wechseln. + if (user.mustChangePassword) return ; + if (minimum && !can(minimum)) return ; + + return <>{children}; +} + +export default function App() { + const { user, loading } = useSession(); + + return ( + + Lade … : user ? : } + /> + + Lade … + ) : !user ? ( + + ) : ( + + + + ) + } + /> + + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> + + } /> + + ); +} diff --git a/web/src/components/BalanceCards.tsx b/web/src/components/BalanceCards.tsx new file mode 100644 index 0000000..38beed9 --- /dev/null +++ b/web/src/components/BalanceCards.tsx @@ -0,0 +1,43 @@ +import type { Balances } from '../lib/types'; + +function Card({ label, value, hint }: { label: string; value: number; hint?: string }) { + return ( +
+ {label} + {value} + {hint && {hint}} +
+ ); +} + +export default function BalanceCards({ balances }: { balances: Balances }) { + const canCreate = balances.CC >= 1; + const canRedesign = balances.redesignTotal >= 1; + + return ( +
+
+ + + +
+ +
+ + {canCreate ? '✓' : '✕'} Neuer CC {canCreate ? 'moeglich' : 'nicht moeglich'} + + + {canRedesign ? '✓' : '✕'} Redesign {canRedesign ? 'moeglich' : 'nicht moeglich'} + {canRedesign && ` (${balances.redesignTotal} verfuegbar)`} + +
+ + {(balances.CC < 0 || balances.REDESIGN_PAID < 0 || balances.REDESIGN_KULANZ < 0) && ( +

+ Mindestens ein Topf ist im Minus. Das ist erlaubt, deutet aber meist auf ein noch nicht + erfasstes Paket hin. +

+ )} +
+ ); +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx new file mode 100644 index 0000000..da14ae8 --- /dev/null +++ b/web/src/components/Layout.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from 'react'; +import { NavLink, useNavigate } from 'react-router-dom'; +import { useSession } from '../lib/session'; + +export default function Layout({ children }: { children: ReactNode }) { + const { user, logout, can } = useSession(); + const navigate = useNavigate(); + + const handleLogout = async () => { + await logout(); + navigate('/login', { replace: true }); + }; + + return ( +
+
+
+ + CC-Manager + + + + +
+ + {user?.username} + {user?.role} + + +
+
+
+ +
{children}
+
+ ); +} diff --git a/web/src/components/Modal.tsx b/web/src/components/Modal.tsx new file mode 100644 index 0000000..01a8dd7 --- /dev/null +++ b/web/src/components/Modal.tsx @@ -0,0 +1,40 @@ +import { useEffect } from 'react'; +import type { ReactNode } from 'react'; + +interface ModalProps { + title: string; + onClose: () => void; + children: ReactNode; + footer?: ReactNode; +} + +export default function Modal({ title, onClose, children, footer }: ModalProps) { + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return ( +
+
event.stopPropagation()} + > +
+

{title}

+ +
+
{children}
+ {footer &&
{footer}
} +
+
+ ); +} diff --git a/web/src/components/Notice.tsx b/web/src/components/Notice.tsx new file mode 100644 index 0000000..dde5399 --- /dev/null +++ b/web/src/components/Notice.tsx @@ -0,0 +1,18 @@ +interface NoticeProps { + kind: 'error' | 'success' | 'info' | 'warn'; + children: React.ReactNode; + onDismiss?: () => void; +} + +export default function Notice({ kind, children, onDismiss }: NoticeProps) { + return ( +
+ {children} + {onDismiss && ( + + )} +
+ ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..35c4d36 --- /dev/null +++ b/web/src/lib/api.ts @@ -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(path: string, options: RequestOptions = {}): Promise { + const method = options.method ?? 'GET'; + const headers: Record = {}; + + 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(path: string, file: File): Promise { + const form = new FormData(); + form.append('file', file); + + const headers: Record = {}; + 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 }; + } +} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..d6654de --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,49 @@ +import type { CreditKind, CreditSource, InactiveReason, ImportPlan } from './types'; + +export const KIND_LABEL: Record = { + CC: 'CC-Guthaben', + REDESIGN_PAID: 'Redesign (bezahlt)', + REDESIGN_KULANZ: 'Redesign (Kulanz)', +}; + +export const SOURCE_LABEL: Record = { + CC_PACKAGE: 'CC-Paket', + REDESIGN_PACKAGE: 'Redesign-Paket', + KULANZ: 'Kulanz', + ADJUSTMENT: 'Korrektur', + CONSUME: 'Verbrauch', + REVERSAL: 'Storno', +}; + +export const REASON_LABEL: Record = { + PLAYER_INACTIVITY: 'Spieler-Inaktivitaet', + REWORKED: 'Redesignt', + OTHER: 'Sonstiger Grund', +}; + +export const PLAN_LABEL: Record = { + 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); +} diff --git a/web/src/lib/session.tsx b/web/src/lib/session.tsx new file mode 100644 index 0000000..b45ffca --- /dev/null +++ b/web/src/lib/session.tsx @@ -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; + logout: () => Promise; + can: (minimum: Role) => boolean; +} + +const RANK: Record = { VIEWER: 1, EDITOR: 2, ADMIN: 3 }; + +const SessionContext = createContext(null); + +export function SessionProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(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( + () => ({ user, loading, setUser, refresh, logout, can }), + [user, loading, refresh, logout, can], + ); + + return {children}; +} + +export function useSession(): SessionState { + const context = useContext(SessionContext); + if (!context) throw new Error('useSession benoetigt einen SessionProvider'); + return context; +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 0000000..42176d2 --- /dev/null +++ b/web/src/lib/types.ts @@ -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; +} + +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[]; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..c24edd8 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,19 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import App from './App'; +import { SessionProvider } from './lib/session'; +import './styles.css'; + +const container = document.getElementById('root'); +if (!container) throw new Error('Wurzelelement fehlt'); + +createRoot(container).render( + + + + + + + , +); diff --git a/web/src/pages/Account.tsx b/web/src/pages/Account.tsx new file mode 100644 index 0000000..5acfb9a --- /dev/null +++ b/web/src/pages/Account.tsx @@ -0,0 +1,203 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import Notice from '../components/Notice'; +import { ApiError, api } from '../lib/api'; +import { useSession } from '../lib/session'; + +export default function Account() { + const { user, refresh } = useSession(); + const navigate = useNavigate(); + + const [current, setCurrent] = useState(''); + const [next, setNext] = useState(''); + const [repeat, setRepeat] = useState(''); + const [error, setError] = useState(null); + const [flash, setFlash] = useState(null); + const [busy, setBusy] = useState(false); + + const [totpSetup, setTotpSetup] = useState<{ qr: string; secret: string } | null>(null); + const [totpCode, setTotpCode] = useState(''); + + const changePassword = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + + if (next !== repeat) { + setError('Die beiden neuen Passwoerter stimmen nicht ueberein'); + return; + } + + setBusy(true); + try { + await api('/api/account/password', { + method: 'POST', + body: { currentPassword: current, newPassword: next }, + }); + setCurrent(''); + setNext(''); + setRepeat(''); + await refresh(); + setFlash('Passwort geaendert. Andere Sitzungen wurden beendet.'); + if (user?.mustChangePassword) navigate('/', { replace: true }); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Aenderung fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + + const startTotp = async () => { + setError(null); + try { + const result = await api<{ qr: string; secret: string }>('/api/account/totp/setup', { + method: 'POST', + body: {}, + }); + setTotpSetup(result); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Einrichtung fehlgeschlagen'); + } + }; + + const enableTotp = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setBusy(true); + try { + await api('/api/account/totp/enable', { method: 'POST', body: { code: totpCode } }); + setTotpSetup(null); + setTotpCode(''); + await refresh(); + setFlash('Zwei-Faktor-Anmeldung ist aktiv.'); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Code wurde nicht akzeptiert'); + } finally { + setBusy(false); + } + }; + + const disableTotp = async () => { + const password = window.prompt('Zum Deaktivieren bitte das Passwort eingeben:'); + if (!password) return; + setError(null); + try { + await api('/api/account/totp/disable', { method: 'POST', body: { password } }); + await refresh(); + setFlash('Zwei-Faktor-Anmeldung wurde deaktiviert.'); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Deaktivieren fehlgeschlagen'); + } + }; + + return ( +
+
+

Mein Konto

+
+ + {user?.mustChangePassword && ( + + Dieses Konto verwendet noch ein vergebenes Erstpasswort. Bitte jetzt aendern — die + uebrigen Seiten sind bis dahin gesperrt. + + )} + + {error && setError(null)}>{error}} + {flash && setFlash(null)}>{flash}} + +
+

Passwort aendern

+
+ + setCurrent(event.target.value)} + required + /> + + + setNext(event.target.value)} + required + /> +

+ Mindestens 12 Zeichen, Gross- und Kleinbuchstaben sowie eine Ziffer. +

+ + + setRepeat(event.target.value)} + required + /> + + +
+
+ +
+

Zwei-Faktor-Anmeldung

+ + {user?.totpEnabled ? ( + <> + Zwei-Faktor-Anmeldung ist fuer dieses Konto aktiv. + + + ) : totpSetup ? ( +
+

+ QR-Code in der Authenticator-App scannen und den angezeigten Code eintragen. +

+ QR-Code zur Einrichtung +

+ Falls Scannen nicht geht, Schluessel manuell eintragen:{' '} + {totpSetup.secret} +

+ + + setTotpCode(event.target.value)} + maxLength={10} + required + /> + +
+ + +
+
+ ) : ( + <> +

+ Schuetzt das Konto zusaetzlich mit einem Einmalcode. Fuer Administratoren empfohlen. +

+ + + )} +
+
+ ); +} diff --git a/web/src/pages/Admin.tsx b/web/src/pages/Admin.tsx new file mode 100644 index 0000000..ff284da --- /dev/null +++ b/web/src/pages/Admin.tsx @@ -0,0 +1,507 @@ +import { useCallback, useEffect, useState } from 'react'; +import Modal from '../components/Modal'; +import Notice from '../components/Notice'; +import { ApiError, api } from '../lib/api'; +import { formatDate } from '../lib/format'; +import { useSession } from '../lib/session'; +import type { AdminUser, AuditEntry, ProductMapping, ProductTarget, Role } from '../lib/types'; + +type Tab = 'users' | 'mappings' | 'audit'; + +const TARGET_LABEL: Record = { + CC_PACKAGE: 'CC-Paket', + REDESIGN_PACKAGE: 'Redesign-Paket', + IGNORE: 'Ignorieren', +}; + +export default function Admin() { + const [tab, setTab] = useState('users'); + const [error, setError] = useState(null); + const [flash, setFlash] = useState(null); + + const notify = (message: string) => { + setFlash(message); + window.setTimeout(() => setFlash(null), 4000); + }; + + return ( +
+
+

Verwaltung

+
+ +
+ + + +
+ + {error && setError(null)}>{error}} + {flash && setFlash(null)}>{flash}} + + {tab === 'users' && } + {tab === 'mappings' && } + {tab === 'audit' && } +
+ ); +} + +interface TabProps { + onError: (message: string) => void; + onDone: (message: string) => void; +} + +function UsersTab({ onError, onDone }: TabProps) { + const { user: me } = useSession(); + const [users, setUsers] = useState([]); + const [creating, setCreating] = useState(false); + + const load = useCallback(async () => { + try { + const result = await api<{ items: AdminUser[] }>('/api/admin/users'); + setUsers(result.items); + } catch (err) { + onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen'); + } + }, [onError]); + + useEffect(() => { + void load(); + }, [load]); + + const act = async (action: () => Promise, message: string) => { + try { + await action(); + await load(); + onDone(message); + } catch (err) { + onError(err instanceof ApiError ? err.message : 'Aktion fehlgeschlagen'); + } + }; + + const resetPassword = (target: AdminUser) => { + const password = window.prompt( + `Neues Passwort fuer "${target.username}" (min. 12 Zeichen, Gross/Klein, Ziffer):`, + ); + if (!password) return; + void act( + () => + api(`/api/admin/users/${target.id}/reset-password`, { + method: 'POST', + body: { newPassword: password }, + }), + `Passwort fuer "${target.username}" gesetzt. Muss beim naechsten Login geaendert werden.`, + ); + }; + + return ( +
+
+

Nutzer

+ +
+ + + Es gibt keine oeffentliche Registrierung. Konten werden ausschliesslich hier angelegt. + + +
+ + + + + + + + + + + + {users.map((row) => ( + + + + + + + + + ))} + +
BenutzernameRolleStatus2FALetzter Login +
+ {row.username} + {row.id === me?.id && du} + {row.mustChangePassword && Erstpasswort} + + + {row.isActive ? 'aktiv' : 'gesperrt'}{row.totpEnabled ? 'ja' : 'nein'}{formatDate(row.lastLoginAt)} + + {row.id !== me?.id && ( + <> + + + + )} +
+
+ + {creating && ( + setCreating(false)} + onCreated={(username) => { + setCreating(false); + void load(); + onDone(`Konto "${username}" angelegt.`); + }} + onError={onError} + /> + )} +
+ ); +} + +function CreateUserModal({ + onClose, + onCreated, + onError, +}: { + onClose: () => void; + onCreated: (username: string) => void; + onError: (message: string) => void; +}) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [role, setRole] = useState('EDITOR'); + const [busy, setBusy] = useState(false); + + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + setBusy(true); + try { + await api('/api/admin/users', { method: 'POST', body: { username, password, role } }); + onCreated(username); + } catch (err) { + onError(err instanceof ApiError ? err.message : 'Anlegen fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + + return ( + +
+ + setUsername(event.target.value)} + autoFocus + required + /> + + + setPassword(event.target.value)} + required + /> +

+ Min. 12 Zeichen, Gross- und Kleinbuchstaben, eine Ziffer. Muss beim ersten Login + geaendert werden. +

+ + + + +
+ + +
+
+
+ ); +} + +function MappingsTab({ onError, onDone }: TabProps) { + const [items, setItems] = useState([]); + const [pattern, setPattern] = useState(''); + const [target, setTarget] = useState('CC_PACKAGE'); + + const load = useCallback(async () => { + try { + const result = await api<{ items: ProductMapping[] }>('/api/admin/product-mappings'); + setItems(result.items); + } catch (err) { + onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen'); + } + }, [onError]); + + useEffect(() => { + void load(); + }, [load]); + + const add = async (event: React.FormEvent) => { + event.preventDefault(); + try { + await api('/api/admin/product-mappings', { + method: 'POST', + body: { matchPattern: pattern, target }, + }); + setPattern(''); + await load(); + onDone('Mapping angelegt.'); + } catch (err) { + onError(err instanceof ApiError ? err.message : 'Anlegen fehlgeschlagen'); + } + }; + + const remove = async (row: ProductMapping) => { + if (!window.confirm(`Mapping "${row.matchPattern}" loeschen?`)) return; + try { + await api(`/api/admin/product-mappings/${row.id}`, { method: 'DELETE' }); + await load(); + onDone('Mapping geloescht.'); + } catch (err) { + onError(err instanceof ApiError ? err.message : 'Loeschen fehlgeschlagen'); + } + }; + + return ( +
+

Produkt-Mappings

+ + Beim Import wird der Produktname gegen diese Muster geprueft (Teiltreffer, Gross- und + Kleinschreibung egal). Das erste passende Muster mit der niedrigsten Prioritaet gewinnt. + + +
+ setPattern(event.target.value)} + required + /> + + +
+ +
+ + + + + + + + + + + {items.map((row) => ( + + + + + + + + ))} + {items.length === 0 && ( + + + + )} + +
MusterBedeutungPrioritaetAktiv +
{row.matchPattern}{TARGET_LABEL[row.target]}{row.priority}{row.isActive ? 'ja' : 'nein'} + +
+ Noch keine Mappings. +
+
+
+ ); +} + +function AuditTab({ onError }: { onError: (message: string) => void }) { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [filter, setFilter] = useState(''); + + useEffect(() => { + const controller = new AbortController(); + const timer = setTimeout(async () => { + try { + const params = new URLSearchParams({ take: '150' }); + if (filter) params.set('action', filter); + const result = await api<{ items: AuditEntry[]; total: number }>( + `/api/admin/audit?${params}`, + { signal: controller.signal }, + ); + setItems(result.items); + setTotal(result.total); + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen'); + } + }, 250); + + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [filter, onError]); + + return ( +
+

Audit-Log

+ + setFilter(event.target.value)} + /> + +
+ + + + + + + + + + + + {items.map((entry) => ( + + + + + + + + ))} + {items.length === 0 && ( + + + + )} + +
ZeitpunktWerAktionZielDetails
{formatDate(entry.createdAt)}{entry.actor?.username ?? entry.actorName ?? '—'}{entry.action} + {entry.targetType} + {entry.targetId ? ` #${entry.targetId}` : ''} + + {entry.details ? JSON.stringify(entry.details) : '—'} +
+ Keine Eintraege. +
+
+ + {total > items.length && ( +

+ {items.length} von {total} Eintraegen angezeigt. +

+ )} +
+ ); +} diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx new file mode 100644 index 0000000..7daf6b2 --- /dev/null +++ b/web/src/pages/Dashboard.tsx @@ -0,0 +1,208 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import Modal from '../components/Modal'; +import Notice from '../components/Notice'; +import { ApiError, api } from '../lib/api'; +import { useSession } from '../lib/session'; +import type { PlayerSummary } from '../lib/types'; + +interface SearchResult { + total: number; + items: PlayerSummary[]; +} + +function Counter({ value, title }: { value: number; title: string }) { + return ( + + {value} + + ); +} + +export default function Dashboard() { + const { can } = useSession(); + const [query, setQuery] = useState(''); + const [result, setResult] = useState({ total: 0, items: [] }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [creating, setCreating] = useState(false); + + const load = useCallback(async (search: string, signal?: AbortSignal) => { + setLoading(true); + try { + const params = new URLSearchParams({ q: search, take: '100' }); + const data = await api(`/api/players?${params}`, signal ? { signal } : {}); + setResult(data); + setError(null); + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen'); + } finally { + setLoading(false); + } + }, []); + + // Tippen entprellen, damit nicht jeder Anschlag eine Anfrage ausloest. + useEffect(() => { + const controller = new AbortController(); + const timer = setTimeout(() => void load(query, controller.signal), 250); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [query, load]); + + return ( +
+
+

Spieler

+ {can('EDITOR') && ( + + )} +
+ + setQuery(event.target.value)} + autoFocus + /> + + {error && {error}} + +
+ + + + + + + + + + + + + {result.items.map((player) => ( + + + + + + + + + ))} + + {!loading && result.items.length === 0 && ( + + + + )} + +
SpielerSteamIDCCRedesignKulanzAktive CCs
+ + {player.displayName} + + {player.forumName && · {player.forumName}} + {player.steamId ?? '—'} + + + + + + + {player.activeCharacterCount} / {player.characterCount} +
+ {query ? 'Kein Treffer.' : 'Noch keine Spieler erfasst.'} +
+
+ + {loading &&

Lade …

} + {!loading && result.total > result.items.length && ( +

+ {result.items.length} von {result.total} angezeigt — Suche eingrenzen. +

+ )} + + {creating && ( + setCreating(false)} + onCreated={() => { + setCreating(false); + void load(query); + }} + /> + )} +
+ ); +} + +function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) { + const [displayName, setDisplayName] = useState(''); + const [steamId, setSteamId] = useState(''); + const [forumName, setForumName] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + setBusy(true); + setError(null); + try { + await api('/api/players', { + method: 'POST', + body: { + displayName, + steamId: steamId.trim() || null, + forumName: forumName.trim() || null, + }, + }); + onCreated(); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Anlegen fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + + return ( + +
+ {error && {error}} + + + setDisplayName(event.target.value)} + autoFocus + required + /> + + + setSteamId(event.target.value)} /> + + + setForumName(event.target.value)} + /> + +
+ + +
+
+
+ ); +} diff --git a/web/src/pages/Import.tsx b/web/src/pages/Import.tsx new file mode 100644 index 0000000..75eadc6 --- /dev/null +++ b/web/src/pages/Import.tsx @@ -0,0 +1,388 @@ +import { useState } from 'react'; +import Notice from '../components/Notice'; +import { ApiError, api, uploadFile } from '../lib/api'; +import { PLAN_LABEL, formatDate } from '../lib/format'; +import type { ImportPlan, ImportRecord } from '../lib/types'; + +interface UploadResult { + importId: number; + columns: string[]; + rowCount: number; + sample: Record[]; +} + +interface MappingForm { + playerKey: string; + playerName: string; + product: string; + orderId: string; + purchasedAt: string; + createMissingPlayers: boolean; +} + +const EMPTY_MAPPING: MappingForm = { + playerKey: '', + playerName: '', + product: '', + orderId: '', + purchasedAt: '', + createMissingPlayers: false, +}; + +// Trifft die ueblichen Spaltennamen, damit das Formular meist schon passt. +const GUESSES: Record, string[]> = { + playerKey: ['steamid', 'steam_id', 'steam', 'identifier', 'user', 'kunde'], + playerName: ['name', 'username', 'displayname', 'spieler'], + product: ['product', 'produkt', 'item', 'artikel', 'package', 'paket'], + orderId: ['order', 'bestell', 'transaction', 'id', 'purchase'], + purchasedAt: ['date', 'datum', 'created', 'time', 'kauf'], +}; + +function guessMapping(columns: string[]): MappingForm { + const result = { ...EMPTY_MAPPING }; + for (const [field, needles] of Object.entries(GUESSES) as [ + keyof typeof GUESSES, + string[], + ][]) { + const hit = columns.find((column) => + needles.some((needle) => column.toLowerCase().includes(needle)), + ); + if (hit) result[field] = hit; + } + return result; +} + +export default function Import() { + const [upload, setUpload] = useState(null); + const [mapping, setMapping] = useState(EMPTY_MAPPING); + const [preview, setPreview] = useState<{ + summary: Record; + import: ImportRecord; + } | null>(null); + const [error, setError] = useState(null); + const [flash, setFlash] = useState(null); + const [busy, setBusy] = useState(false); + + const reset = () => { + setUpload(null); + setPreview(null); + setMapping(EMPTY_MAPPING); + setError(null); + }; + + const handleUpload = async (file: File) => { + setBusy(true); + setError(null); + setPreview(null); + try { + const result = await uploadFile('/api/imports', file); + setUpload(result); + setMapping(guessMapping(result.columns)); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Upload fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + + const handlePreview = async (event: React.FormEvent) => { + event.preventDefault(); + if (!upload) return; + setBusy(true); + setError(null); + try { + const body = { + playerKey: mapping.playerKey, + product: mapping.product, + createMissingPlayers: mapping.createMissingPlayers, + ...(mapping.playerName ? { playerName: mapping.playerName } : {}), + ...(mapping.orderId ? { orderId: mapping.orderId } : {}), + ...(mapping.purchasedAt ? { purchasedAt: mapping.purchasedAt } : {}), + }; + const result = await api<{ summary: Record; import: ImportRecord }>( + `/api/imports/${upload.importId}/mapping`, + { method: 'POST', body }, + ); + setPreview(result); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Vorschau fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + + const handleCommit = async () => { + if (!upload) return; + setBusy(true); + setError(null); + try { + const result = await api<{ booked: number; createdPlayers: number }>( + `/api/imports/${upload.importId}/commit`, + { method: 'POST', body: {} }, + ); + setFlash( + `${result.booked} Buchungen angelegt` + + (result.createdPlayers > 0 ? `, ${result.createdPlayers} Spieler neu erfasst.` : '.'), + ); + reset(); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Buchen fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + + const handleDiscard = async () => { + if (!upload) return; + try { + await api(`/api/imports/${upload.importId}/discard`, { method: 'POST', body: {} }); + } finally { + reset(); + } + }; + + const bookable = preview + ? preview.summary.BOOK + preview.summary.CREATE_PLAYER_AND_BOOK + : 0; + + return ( +
+
+

Import

+
+ + {error && setError(null)}>{error}} + {flash && setFlash(null)}>{flash}} + + {!upload && ( +
+ + CSV oder JSON hochladen. Danach werden die Spalten zugeordnet und eine Vorschau + gezeigt — gebucht wird erst nach Bestaetigung. Bereits importierte Bestell-IDs + werden automatisch uebersprungen. + + + +
+ )} + + {upload && !preview && ( +
+

Spalten zuordnen

+

+ {upload.rowCount} Zeilen aus {upload.columns.length} Spalten gelesen. +

+ +
+
+ setMapping({ ...mapping, playerKey: value })} + /> + setMapping({ ...mapping, product: value })} + /> + setMapping({ ...mapping, playerName: value })} + /> + setMapping({ ...mapping, orderId: value })} + /> + setMapping({ ...mapping, purchasedAt: value })} + /> +
+ + + + {!mapping.orderId && ( + + Ohne Bestell-ID kann ein zweiter Import dieselben Kaeufe erneut buchen. + + )} + +
+ + +
+
+ +

Beispielzeilen

+
+ + + + {upload.columns.map((column) => ( + + ))} + + + + {upload.sample.map((row, index) => ( + + {upload.columns.map((column) => ( + + ))} + + ))} + +
{column}
+ {row[column] ?? ''} +
+
+
+ )} + + {preview && ( +
+

Vorschau

+ +
+ {(Object.keys(PLAN_LABEL) as ImportPlan[]).map((plan) => ( +
+ {preview.summary[plan] ?? 0} + {PLAN_LABEL[plan]} +
+ ))} +
+ + {bookable === 0 ? ( + + Keine Zeile wuerde gebucht. Pruefe die Spaltenzuordnung oder die Produkt-Mappings + in der Verwaltung. + + ) : ( + + {bookable} Zeilen werden gebucht. Ein CC-Paket erzeugt dabei automatisch ein + CC-Guthaben und ein bezahltes Redesign-Guthaben. + + )} + +
+ + + + + + + + + + + + + {(preview.import.rows ?? []).map((row) => ( + + + + + + + + + ))} + +
#SpielerProduktBestell-IDDatumErgebnis
{row.lineNo} + {row.playerName || row.playerKey || '—'} + {row.resolvedPlayerId === null && row.plan === 'CREATE_PLAYER_AND_BOOK' && ( + neu + )} + {row.productName ?? '—'}{row.externalId ?? '—'}{formatDate(row.purchasedAt)} + {PLAN_LABEL[row.plan]} +
+
+ +
+ + + +
+
+ )} +
+ ); +} + +function MappingSelect({ + label, + value, + columns, + required, + onChange, +}: { + label: string; + value: string; + columns: string[]; + required?: boolean; + onChange: (value: string) => void; +}) { + return ( +
+ + +
+ ); +} diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx new file mode 100644 index 0000000..2973f3f --- /dev/null +++ b/web/src/pages/Login.tsx @@ -0,0 +1,141 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import Notice from '../components/Notice'; +import { ApiError, api } from '../lib/api'; +import { useSession } from '../lib/session'; +import type { SessionUser } from '../lib/types'; + +interface LoginResponse { + totpRequired: boolean; + challenge?: string; + user?: SessionUser; +} + +export default function Login() { + const { setUser } = useSession(); + const navigate = useNavigate(); + + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [code, setCode] = useState(''); + const [challenge, setChallenge] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const finish = (user: SessionUser) => { + setUser(user); + navigate(user.mustChangePassword ? '/account' : '/', { replace: true }); + }; + + const submitPassword = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setBusy(true); + try { + const result = await api('/api/auth/login', { + method: 'POST', + body: { username, password }, + }); + + if (result.totpRequired && result.challenge) { + setChallenge(result.challenge); + } else if (result.user) { + finish(result.user); + } + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Anmeldung fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + + const submitTotp = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setBusy(true); + try { + const result = await api<{ user: SessionUser }>('/api/auth/login/totp', { + method: 'POST', + body: { challenge, code }, + }); + finish(result.user); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Anmeldung fehlgeschlagen'); + if (err instanceof ApiError && err.code === 'UNAUTHORIZED' && err.message.includes('abgelaufen')) { + setChallenge(null); + setCode(''); + } + } finally { + setBusy(false); + } + }; + + return ( +
+
+

CC-Manager

+

+ {challenge ? 'Bestaetigungscode eingeben' : 'Bitte anmelden'} +

+ + {error && {error}} + + {challenge ? ( +
+ + setCode(event.target.value)} + maxLength={10} + autoFocus + required + /> + + +
+ ) : ( +
+ + setUsername(event.target.value)} + autoFocus + required + /> + + + setPassword(event.target.value)} + required + /> + + +
+ )} +
+
+ ); +} diff --git a/web/src/pages/PlayerDetail.tsx b/web/src/pages/PlayerDetail.tsx new file mode 100644 index 0000000..6cf765e --- /dev/null +++ b/web/src/pages/PlayerDetail.tsx @@ -0,0 +1,730 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import BalanceCards from '../components/BalanceCards'; +import Modal from '../components/Modal'; +import Notice from '../components/Notice'; +import { ApiError, api } from '../lib/api'; +import { KIND_LABEL, REASON_LABEL, SOURCE_LABEL, formatAmount, formatDate } from '../lib/format'; +import { useSession } from '../lib/session'; +import type { Character, CreditKind, LedgerEntry, PlayerDetail as Detail } from '../lib/types'; + +type DialogState = + | { type: 'none' } + | { type: 'newCharacter' } + | { type: 'redesign'; character: Character } + | { type: 'deactivate'; character: Character } + | { type: 'reactivatePaid'; character: Character } + | { type: 'revert'; character: Character } + | { type: 'reverse'; entry: LedgerEntry }; + +export default function PlayerDetail() { + const { id } = useParams<{ id: string }>(); + const playerId = Number(id); + const { can } = useSession(); + + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [flash, setFlash] = useState(null); + const [dialog, setDialog] = useState({ type: 'none' }); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + try { + setDetail(await api(`/api/players/${playerId}`)); + setError(null); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen'); + } finally { + setLoading(false); + } + }, [playerId]); + + useEffect(() => { + void load(); + }, [load]); + + const run = async (action: () => Promise, message: string) => { + setBusy(true); + setError(null); + try { + await action(); + await load(); + setDialog({ type: 'none' }); + setFlash(message); + window.setTimeout(() => setFlash(null), 4000); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Aktion fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + + if (loading) return

Lade …

; + if (!detail) return {error ?? 'Spieler nicht gefunden'}; + + const editor = can('EDITOR'); + const active = detail.characters.filter((c) => c.status === 'ACTIVE'); + const inactive = detail.characters.filter((c) => c.status === 'INACTIVE'); + + return ( +
+
+
+ + ← Alle Spieler + +

{detail.player.displayName}

+

+ {detail.player.steamId ?? 'keine SteamID'} + {detail.player.forumName && ` · Forum: ${detail.player.forumName}`} +

+
+
+ + {error && setError(null)}>{error}} + {flash && setFlash(null)}>{flash}} + + + + {editor && ( +
+ + + + +
+ )} + +
+

Aktive CCs ({active.length})

+ {active.length === 0 ? ( +

Keine aktiven CCs.

+ ) : ( +
    + {active.map((character) => ( +
  • +
    + {character.name} + {character.predecessor && ( + · Nachfolger von {character.predecessor.name} + )} + {character.loreText &&

    {character.loreText}

    } + Seit {formatDate(character.createdAt)} +
    + {editor && ( +
    + + + +
    + )} +
  • + ))} +
+ )} +
+ +
+

Inaktive CCs ({inactive.length})

+ {inactive.length === 0 ? ( +

Keine inaktiven CCs.

+ ) : ( +
    + {inactive.map((character) => { + const free = character.inactiveReason === 'PLAYER_INACTIVITY'; + return ( +
  • +
    + {character.name} + + {character.inactiveReason + ? REASON_LABEL[character.inactiveReason] + : 'Unbekannt'} + + {character.successors.length > 0 && ( + + {' '} + · Ersetzt durch {character.successors.map((s) => s.name).join(', ')} + + )} +

    + {free + ? 'Kommt gratis zurueck — kostet kein Guthaben.' + : 'Reaktivierung kostet ein Redesign- oder CC-Guthaben.'} +

    +
    + {editor && ( +
    + {free ? ( + + ) : ( + + )} +
    + )} +
  • + ); + })} +
+ )} +
+ +
+

Guthaben-Historie

+
+ + + + + + + + + + + + + {detail.ledger.map((entry) => ( + + + + + + + + + + ))} + {detail.ledger.length === 0 && ( + + + + )} + +
ZeitpunktTopfMengeAnlassNotizVon +
{formatDate(entry.createdAt)}{KIND_LABEL[entry.kind]} + {formatAmount(entry.amount)} + {SOURCE_LABEL[entry.source]} + {entry.note ?? '—'} + {entry.character && ` (${entry.character.name})`} + {entry.createdBy?.username ?? 'System'} + {editor && + !entry.reversedBy && + entry.source !== 'CONSUME' && + entry.source !== 'REVERSAL' && ( + + )} + {entry.reversedBy && storniert} +
+ Noch keine Buchungen. +
+
+ {detail.ledgerTotal > detail.ledger.length && ( +

+ {detail.ledger.length} von {detail.ledgerTotal} Buchungen angezeigt. +

+ )} +
+ + {dialog.type === 'newCharacter' && ( + setDialog({ type: 'none' })} + onSubmit={(values) => + void run( + () => + api(`/api/players/${playerId}/characters`, { + method: 'POST', + body: values, + }), + `CC "${values.name}" angelegt — 1 CC-Guthaben verbraucht.`, + ) + } + /> + )} + + {dialog.type === 'redesign' && ( + setDialog({ type: 'none' })} + onSubmit={(values) => + void run( + () => + api(`/api/characters/${dialog.character.id}/redesign`, { + method: 'POST', + body: values, + }), + `Redesign gebucht: "${dialog.character.name}" → "${values.name}".`, + ) + } + /> + )} + + {dialog.type === 'deactivate' && ( + setDialog({ type: 'none' })} + onSubmit={(reason) => + void run( + () => + api(`/api/characters/${dialog.character.id}/deactivate`, { + method: 'POST', + body: { reason }, + }), + `"${dialog.character.name}" ist jetzt inaktiv.`, + ) + } + /> + )} + + {dialog.type === 'reactivatePaid' && ( + setDialog({ type: 'none' })} + onSubmit={(useKind) => + void run( + () => + api(`/api/characters/${dialog.character.id}/reactivate-paid`, { + method: 'POST', + body: { useKind }, + }), + `"${dialog.character.name}" ist wieder aktiv.`, + ) + } + /> + )} + + {dialog.type === 'revert' && ( + setDialog({ type: 'none' })} + onSubmit={(reason) => + void run( + () => + api(`/api/characters/${dialog.character.id}/revert`, { + method: 'POST', + body: { reason }, + }), + `"${dialog.character.name}" wurde zurueckgenommen.`, + ) + } + /> + )} + + {dialog.type === 'reverse' && ( + setDialog({ type: 'none' })} + onSubmit={(reason) => + void run( + () => + api(`/api/credits/${dialog.entry.id}/reverse`, { + method: 'POST', + body: { reason }, + }), + 'Buchung storniert.', + ) + } + /> + )} +
+ ); +} + +interface CharacterValues { + name: string; + loreText?: string; + useKind?: CreditKind; +} + +function CharacterFormModal({ + title, + submitLabel, + hint, + busy, + withKind, + balances, + onClose, + onSubmit, +}: { + title: string; + submitLabel: string; + hint: string; + busy: boolean; + withKind?: CreditKind[]; + balances?: { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number }; + onClose: () => void; + onSubmit: (values: CharacterValues) => void; +}) { + const [name, setName] = useState(''); + const [loreText, setLoreText] = useState(''); + // Kulanz steht bewusst vorne: sie soll zuerst aufgebraucht werden. + const [useKind, setUseKind] = useState(withKind?.[0] ?? 'CC'); + + return ( + +
{ + event.preventDefault(); + onSubmit({ + name, + ...(loreText.trim() ? { loreText: loreText.trim() } : {}), + ...(withKind ? { useKind } : {}), + }); + }} + > + {hint} + + + setName(event.target.value)} + autoFocus + required + /> + + +