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,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 <div className="boot">Lade …</div>;
|
||||||
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
|
|
||||||
|
// Ein frisch angelegtes Konto muss zuerst sein Passwort wechseln.
|
||||||
|
if (user.mustChangePassword) return <Navigate to="/account" replace />;
|
||||||
|
if (minimum && !can(minimum)) return <Navigate to="/" replace />;
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { user, loading } = useSession();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
element={loading ? <div className="boot">Lade …</div> : user ? <Navigate to="/" replace /> : <Login />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/account"
|
||||||
|
element={
|
||||||
|
loading ? (
|
||||||
|
<div className="boot">Lade …</div>
|
||||||
|
) : !user ? (
|
||||||
|
<Navigate to="/login" replace />
|
||||||
|
) : (
|
||||||
|
<Layout>
|
||||||
|
<Account />
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<Protected>
|
||||||
|
<Layout>
|
||||||
|
<Dashboard />
|
||||||
|
</Layout>
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/players/:id"
|
||||||
|
element={
|
||||||
|
<Protected>
|
||||||
|
<Layout>
|
||||||
|
<PlayerDetail />
|
||||||
|
</Layout>
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/import"
|
||||||
|
element={
|
||||||
|
<Protected minimum="ADMIN">
|
||||||
|
<Layout>
|
||||||
|
<Import />
|
||||||
|
</Layout>
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<Protected minimum="ADMIN">
|
||||||
|
<Layout>
|
||||||
|
<Admin />
|
||||||
|
</Layout>
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Balances } from '../lib/types';
|
||||||
|
|
||||||
|
function Card({ label, value, hint }: { label: string; value: number; hint?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`balance-card${value < 0 ? ' is-negative' : ''}`}>
|
||||||
|
<span className="balance-label">{label}</span>
|
||||||
|
<span className="balance-value">{value}</span>
|
||||||
|
{hint && <span className="balance-hint">{hint}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BalanceCards({ balances }: { balances: Balances }) {
|
||||||
|
const canCreate = balances.CC >= 1;
|
||||||
|
const canRedesign = balances.redesignTotal >= 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="balances" aria-label="Guthaben">
|
||||||
|
<div className="balance-grid">
|
||||||
|
<Card label="CC-Guthaben" value={balances.CC} />
|
||||||
|
<Card label="Redesign (bezahlt)" value={balances.REDESIGN_PAID} />
|
||||||
|
<Card label="Redesign (Kulanz)" value={balances.REDESIGN_KULANZ} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="eligibility">
|
||||||
|
<span className={canCreate ? 'ok' : 'no'}>
|
||||||
|
{canCreate ? '✓' : '✕'} Neuer CC {canCreate ? 'moeglich' : 'nicht moeglich'}
|
||||||
|
</span>
|
||||||
|
<span className={canRedesign ? 'ok' : 'no'}>
|
||||||
|
{canRedesign ? '✓' : '✕'} Redesign {canRedesign ? 'moeglich' : 'nicht moeglich'}
|
||||||
|
{canRedesign && ` (${balances.redesignTotal} verfuegbar)`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(balances.CC < 0 || balances.REDESIGN_PAID < 0 || balances.REDESIGN_KULANZ < 0) && (
|
||||||
|
<p className="overdrawn-hint">
|
||||||
|
Mindestens ein Topf ist im Minus. Das ist erlaubt, deutet aber meist auf ein noch nicht
|
||||||
|
erfasstes Paket hin.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="shell">
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="topbar-inner">
|
||||||
|
<NavLink to="/" className="brand">
|
||||||
|
CC-Manager
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<nav className="nav">
|
||||||
|
<NavLink to="/" end>
|
||||||
|
Spieler
|
||||||
|
</NavLink>
|
||||||
|
{can('ADMIN') && <NavLink to="/import">Import</NavLink>}
|
||||||
|
{can('ADMIN') && <NavLink to="/admin">Verwaltung</NavLink>}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="topbar-right">
|
||||||
|
<NavLink to="/account" className="user-chip">
|
||||||
|
{user?.username}
|
||||||
|
<span className="role-tag">{user?.role}</span>
|
||||||
|
</NavLink>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={handleLogout}>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="content">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="modal-backdrop" onMouseDown={onClose}>
|
||||||
|
<div
|
||||||
|
className="modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="modal-head">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose} aria-label="Schliessen">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">{children}</div>
|
||||||
|
{footer && <div className="modal-foot">{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className={`notice notice-${kind}`} role={kind === 'error' ? 'alert' : 'status'}>
|
||||||
|
<span>{children}</span>
|
||||||
|
{onDismiss && (
|
||||||
|
<button type="button" className="notice-close" onClick={onDismiss} aria-label="Ausblenden">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<SessionProvider>
|
||||||
|
<App />
|
||||||
|
</SessionProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -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<string | null>(null);
|
||||||
|
const [flash, setFlash] = useState<string | null>(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 (
|
||||||
|
<div className="page narrow">
|
||||||
|
<div className="page-head">
|
||||||
|
<h1>Mein Konto</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user?.mustChangePassword && (
|
||||||
|
<Notice kind="warn">
|
||||||
|
Dieses Konto verwendet noch ein vergebenes Erstpasswort. Bitte jetzt aendern — die
|
||||||
|
uebrigen Seiten sind bis dahin gesperrt.
|
||||||
|
</Notice>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
||||||
|
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
|
||||||
|
|
||||||
|
<section className="block">
|
||||||
|
<h2>Passwort aendern</h2>
|
||||||
|
<form onSubmit={changePassword}>
|
||||||
|
<label htmlFor="current">Aktuelles Passwort</label>
|
||||||
|
<input
|
||||||
|
id="current"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={current}
|
||||||
|
onChange={(event) => setCurrent(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="next">Neues Passwort</label>
|
||||||
|
<input
|
||||||
|
id="next"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={next}
|
||||||
|
onChange={(event) => setNext(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="muted small">
|
||||||
|
Mindestens 12 Zeichen, Gross- und Kleinbuchstaben sowie eine Ziffer.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label htmlFor="repeat">Neues Passwort wiederholen</label>
|
||||||
|
<input
|
||||||
|
id="repeat"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={repeat}
|
||||||
|
onChange={(event) => setRepeat(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Speichere …' : 'Passwort aendern'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="block">
|
||||||
|
<h2>Zwei-Faktor-Anmeldung</h2>
|
||||||
|
|
||||||
|
{user?.totpEnabled ? (
|
||||||
|
<>
|
||||||
|
<Notice kind="success">Zwei-Faktor-Anmeldung ist fuer dieses Konto aktiv.</Notice>
|
||||||
|
<button type="button" className="btn btn-danger" onClick={() => void disableTotp()}>
|
||||||
|
Deaktivieren
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : totpSetup ? (
|
||||||
|
<form onSubmit={enableTotp}>
|
||||||
|
<p className="muted">
|
||||||
|
QR-Code in der Authenticator-App scannen und den angezeigten Code eintragen.
|
||||||
|
</p>
|
||||||
|
<img className="qr" src={totpSetup.qr} alt="QR-Code zur Einrichtung" />
|
||||||
|
<p className="muted small">
|
||||||
|
Falls Scannen nicht geht, Schluessel manuell eintragen:{' '}
|
||||||
|
<code className="mono">{totpSetup.secret}</code>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label htmlFor="totp">Code aus der App</label>
|
||||||
|
<input
|
||||||
|
id="totp"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={totpCode}
|
||||||
|
onChange={(event) => setTotpCode(event.target.value)}
|
||||||
|
maxLength={10}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => setTotpSetup(null)}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
Aktivieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="muted">
|
||||||
|
Schuetzt das Konto zusaetzlich mit einem Einmalcode. Fuer Administratoren empfohlen.
|
||||||
|
</p>
|
||||||
|
<button type="button" className="btn" onClick={() => void startTotp()}>
|
||||||
|
Einrichten
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ProductTarget, string> = {
|
||||||
|
CC_PACKAGE: 'CC-Paket',
|
||||||
|
REDESIGN_PACKAGE: 'Redesign-Paket',
|
||||||
|
IGNORE: 'Ignorieren',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Admin() {
|
||||||
|
const [tab, setTab] = useState<Tab>('users');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [flash, setFlash] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const notify = (message: string) => {
|
||||||
|
setFlash(message);
|
||||||
|
window.setTimeout(() => setFlash(null), 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-head">
|
||||||
|
<h1>Verwaltung</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={tab === 'users' ? 'tab is-active' : 'tab'}
|
||||||
|
onClick={() => setTab('users')}
|
||||||
|
>
|
||||||
|
Nutzer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={tab === 'mappings' ? 'tab is-active' : 'tab'}
|
||||||
|
onClick={() => setTab('mappings')}
|
||||||
|
>
|
||||||
|
Produkt-Mappings
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={tab === 'audit' ? 'tab is-active' : 'tab'}
|
||||||
|
onClick={() => setTab('audit')}
|
||||||
|
>
|
||||||
|
Audit-Log
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
||||||
|
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
|
||||||
|
|
||||||
|
{tab === 'users' && <UsersTab onError={setError} onDone={notify} />}
|
||||||
|
{tab === 'mappings' && <MappingsTab onError={setError} onDone={notify} />}
|
||||||
|
{tab === 'audit' && <AuditTab onError={setError} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabProps {
|
||||||
|
onError: (message: string) => void;
|
||||||
|
onDone: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsersTab({ onError, onDone }: TabProps) {
|
||||||
|
const { user: me } = useSession();
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
|
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<unknown>, 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 (
|
||||||
|
<section className="block">
|
||||||
|
<div className="block-head">
|
||||||
|
<h2>Nutzer</h2>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>
|
||||||
|
Nutzer anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Notice kind="info">
|
||||||
|
Es gibt keine oeffentliche Registrierung. Konten werden ausschliesslich hier angelegt.
|
||||||
|
</Notice>
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Benutzername</th>
|
||||||
|
<th>Rolle</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>2FA</th>
|
||||||
|
<th>Letzter Login</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((row) => (
|
||||||
|
<tr key={row.id} className={row.isActive ? undefined : 'is-skipped'}>
|
||||||
|
<td>
|
||||||
|
<strong>{row.username}</strong>
|
||||||
|
{row.id === me?.id && <span className="badge">du</span>}
|
||||||
|
{row.mustChangePassword && <span className="badge badge-paid">Erstpasswort</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
value={row.role}
|
||||||
|
disabled={row.id === me?.id}
|
||||||
|
onChange={(event) =>
|
||||||
|
void act(
|
||||||
|
() =>
|
||||||
|
api(`/api/admin/users/${row.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { role: event.target.value as Role },
|
||||||
|
}),
|
||||||
|
`Rolle von "${row.username}" geaendert.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="VIEWER">Viewer</option>
|
||||||
|
<option value="EDITOR">Editor</option>
|
||||||
|
<option value="ADMIN">Admin</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>{row.isActive ? 'aktiv' : 'gesperrt'}</td>
|
||||||
|
<td className="muted">{row.totpEnabled ? 'ja' : 'nein'}</td>
|
||||||
|
<td className="muted small">{formatDate(row.lastLoginAt)}</td>
|
||||||
|
<td className="row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-tiny btn-ghost"
|
||||||
|
onClick={() => resetPassword(row)}
|
||||||
|
>
|
||||||
|
Passwort
|
||||||
|
</button>
|
||||||
|
{row.id !== me?.id && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-tiny btn-ghost"
|
||||||
|
onClick={() =>
|
||||||
|
void act(
|
||||||
|
() =>
|
||||||
|
api(`/api/admin/users/${row.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { isActive: !row.isActive },
|
||||||
|
}),
|
||||||
|
row.isActive
|
||||||
|
? `"${row.username}" gesperrt.`
|
||||||
|
: `"${row.username}" entsperrt.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.isActive ? 'Sperren' : 'Entsperren'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-tiny btn-danger"
|
||||||
|
onClick={() => {
|
||||||
|
if (!window.confirm(`Konto "${row.username}" wirklich loeschen?`)) return;
|
||||||
|
void act(
|
||||||
|
() => api(`/api/admin/users/${row.id}`, { method: 'DELETE' }),
|
||||||
|
`"${row.username}" geloescht.`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Loeschen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<CreateUserModal
|
||||||
|
onClose={() => setCreating(false)}
|
||||||
|
onCreated={(username) => {
|
||||||
|
setCreating(false);
|
||||||
|
void load();
|
||||||
|
onDone(`Konto "${username}" angelegt.`);
|
||||||
|
}}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Role>('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 (
|
||||||
|
<Modal title="Nutzer anlegen" onClose={onClose}>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<label htmlFor="new-username">Benutzername</label>
|
||||||
|
<input
|
||||||
|
id="new-username"
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="new-password">Erstpasswort</label>
|
||||||
|
<input
|
||||||
|
id="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="muted small">
|
||||||
|
Min. 12 Zeichen, Gross- und Kleinbuchstaben, eine Ziffer. Muss beim ersten Login
|
||||||
|
geaendert werden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label htmlFor="new-role">Rolle</label>
|
||||||
|
<select
|
||||||
|
id="new-role"
|
||||||
|
value={role}
|
||||||
|
onChange={(event) => setRole(event.target.value as Role)}
|
||||||
|
>
|
||||||
|
<option value="VIEWER">Viewer — nur lesen</option>
|
||||||
|
<option value="EDITOR">Editor — Spieler, Guthaben, CCs</option>
|
||||||
|
<option value="ADMIN">Admin — alles inkl. Import und Nutzer</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Lege an …' : 'Anlegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MappingsTab({ onError, onDone }: TabProps) {
|
||||||
|
const [items, setItems] = useState<ProductMapping[]>([]);
|
||||||
|
const [pattern, setPattern] = useState('');
|
||||||
|
const [target, setTarget] = useState<ProductTarget>('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 (
|
||||||
|
<section className="block">
|
||||||
|
<h2>Produkt-Mappings</h2>
|
||||||
|
<Notice kind="info">
|
||||||
|
Beim Import wird der Produktname gegen diese Muster geprueft (Teiltreffer, Gross- und
|
||||||
|
Kleinschreibung egal). Das erste passende Muster mit der niedrigsten Prioritaet gewinnt.
|
||||||
|
</Notice>
|
||||||
|
|
||||||
|
<form className="inline-form" onSubmit={add}>
|
||||||
|
<input
|
||||||
|
placeholder="Muster, z. B. cc-paket"
|
||||||
|
value={pattern}
|
||||||
|
onChange={(event) => setPattern(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<select value={target} onChange={(event) => setTarget(event.target.value as ProductTarget)}>
|
||||||
|
<option value="CC_PACKAGE">CC-Paket</option>
|
||||||
|
<option value="REDESIGN_PACKAGE">Redesign-Paket</option>
|
||||||
|
<option value="IGNORE">Ignorieren</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Hinzufuegen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Muster</th>
|
||||||
|
<th>Bedeutung</th>
|
||||||
|
<th className="num">Prioritaet</th>
|
||||||
|
<th>Aktiv</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((row) => (
|
||||||
|
<tr key={row.id} className={row.isActive ? undefined : 'is-skipped'}>
|
||||||
|
<td className="mono">{row.matchPattern}</td>
|
||||||
|
<td>{TARGET_LABEL[row.target]}</td>
|
||||||
|
<td className="num muted">{row.priority}</td>
|
||||||
|
<td className="muted">{row.isActive ? 'ja' : 'nein'}</td>
|
||||||
|
<td className="row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-tiny btn-danger"
|
||||||
|
onClick={() => void remove(row)}
|
||||||
|
>
|
||||||
|
Loeschen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="empty">
|
||||||
|
Noch keine Mappings.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditTab({ onError }: { onError: (message: string) => void }) {
|
||||||
|
const [items, setItems] = useState<AuditEntry[]>([]);
|
||||||
|
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 (
|
||||||
|
<section className="block">
|
||||||
|
<h2>Audit-Log</h2>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="search"
|
||||||
|
placeholder="Nach Aktion filtern, z. B. credit. oder character."
|
||||||
|
value={filter}
|
||||||
|
onChange={(event) => setFilter(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="table-wrap scroll">
|
||||||
|
<table className="table small">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Zeitpunkt</th>
|
||||||
|
<th>Wer</th>
|
||||||
|
<th>Aktion</th>
|
||||||
|
<th>Ziel</th>
|
||||||
|
<th>Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((entry) => (
|
||||||
|
<tr key={entry.id}>
|
||||||
|
<td className="muted small">{formatDate(entry.createdAt)}</td>
|
||||||
|
<td>{entry.actor?.username ?? entry.actorName ?? '—'}</td>
|
||||||
|
<td className="mono">{entry.action}</td>
|
||||||
|
<td className="muted">
|
||||||
|
{entry.targetType}
|
||||||
|
{entry.targetId ? ` #${entry.targetId}` : ''}
|
||||||
|
</td>
|
||||||
|
<td className="muted small details">
|
||||||
|
{entry.details ? JSON.stringify(entry.details) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="empty">
|
||||||
|
Keine Eintraege.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{total > items.length && (
|
||||||
|
<p className="muted">
|
||||||
|
{items.length} von {total} Eintraegen angezeigt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<span className={`mini-counter${value < 0 ? ' is-negative' : ''}`} title={title}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { can } = useSession();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [result, setResult] = useState<SearchResult>({ total: 0, items: [] });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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<SearchResult>(`/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 (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-head">
|
||||||
|
<h1>Spieler</h1>
|
||||||
|
{can('EDITOR') && (
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>
|
||||||
|
Spieler anlegen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Name, SteamID oder Forum-Name suchen …"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <Notice kind="error">{error}</Notice>}
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Spieler</th>
|
||||||
|
<th>SteamID</th>
|
||||||
|
<th className="num">CC</th>
|
||||||
|
<th className="num">Redesign</th>
|
||||||
|
<th className="num">Kulanz</th>
|
||||||
|
<th className="num">Aktive CCs</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.items.map((player) => (
|
||||||
|
<tr key={player.id}>
|
||||||
|
<td>
|
||||||
|
<Link to={`/players/${player.id}`} className="link-strong">
|
||||||
|
{player.displayName}
|
||||||
|
</Link>
|
||||||
|
{player.forumName && <span className="muted"> · {player.forumName}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="mono muted">{player.steamId ?? '—'}</td>
|
||||||
|
<td className="num">
|
||||||
|
<Counter value={player.balances.CC} title="CC-Guthaben" />
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
<Counter value={player.balances.REDESIGN_PAID} title="Redesign-Guthaben" />
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
<Counter value={player.balances.REDESIGN_KULANZ} title="Kulanz-Guthaben" />
|
||||||
|
</td>
|
||||||
|
<td className="num muted">
|
||||||
|
{player.activeCharacterCount} / {player.characterCount}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!loading && result.items.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="empty">
|
||||||
|
{query ? 'Kein Treffer.' : 'Noch keine Spieler erfasst.'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <p className="muted">Lade …</p>}
|
||||||
|
{!loading && result.total > result.items.length && (
|
||||||
|
<p className="muted">
|
||||||
|
{result.items.length} von {result.total} angezeigt — Suche eingrenzen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<CreatePlayerModal
|
||||||
|
onClose={() => setCreating(false)}
|
||||||
|
onCreated={() => {
|
||||||
|
setCreating(false);
|
||||||
|
void load(query);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
const [steamId, setSteamId] = useState('');
|
||||||
|
const [forumName, setForumName] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<Modal title="Spieler anlegen" onClose={onClose}>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
{error && <Notice kind="error">{error}</Notice>}
|
||||||
|
|
||||||
|
<label htmlFor="displayName">Anzeigename</label>
|
||||||
|
<input
|
||||||
|
id="displayName"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(event) => setDisplayName(event.target.value)}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="steamId">SteamID (optional)</label>
|
||||||
|
<input id="steamId" value={steamId} onChange={(event) => setSteamId(event.target.value)} />
|
||||||
|
|
||||||
|
<label htmlFor="forumName">Forum-Name (optional)</label>
|
||||||
|
<input
|
||||||
|
id="forumName"
|
||||||
|
value={forumName}
|
||||||
|
onChange={(event) => setForumName(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Lege an …' : 'Anlegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, string>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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<keyof Omit<MappingForm, 'createMissingPlayers'>, 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<UploadResult | null>(null);
|
||||||
|
const [mapping, setMapping] = useState<MappingForm>(EMPTY_MAPPING);
|
||||||
|
const [preview, setPreview] = useState<{
|
||||||
|
summary: Record<ImportPlan, number>;
|
||||||
|
import: ImportRecord;
|
||||||
|
} | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [flash, setFlash] = useState<string | null>(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<UploadResult>('/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<ImportPlan, number>; 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 (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-head">
|
||||||
|
<h1>Import</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
||||||
|
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
|
||||||
|
|
||||||
|
{!upload && (
|
||||||
|
<section className="block">
|
||||||
|
<Notice kind="info">
|
||||||
|
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.
|
||||||
|
</Notice>
|
||||||
|
|
||||||
|
<label className="file-drop">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.json,.tsv,.txt"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) void handleUpload(file);
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<span>{busy ? 'Lade hoch …' : 'Datei auswaehlen'}</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{upload && !preview && (
|
||||||
|
<section className="block">
|
||||||
|
<h2>Spalten zuordnen</h2>
|
||||||
|
<p className="muted">
|
||||||
|
{upload.rowCount} Zeilen aus <strong>{upload.columns.length}</strong> Spalten gelesen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handlePreview}>
|
||||||
|
<div className="grid-2">
|
||||||
|
<MappingSelect
|
||||||
|
label="Spieler-Kennung (Pflicht)"
|
||||||
|
value={mapping.playerKey}
|
||||||
|
columns={upload.columns}
|
||||||
|
required
|
||||||
|
onChange={(value) => setMapping({ ...mapping, playerKey: value })}
|
||||||
|
/>
|
||||||
|
<MappingSelect
|
||||||
|
label="Produktname (Pflicht)"
|
||||||
|
value={mapping.product}
|
||||||
|
columns={upload.columns}
|
||||||
|
required
|
||||||
|
onChange={(value) => setMapping({ ...mapping, product: value })}
|
||||||
|
/>
|
||||||
|
<MappingSelect
|
||||||
|
label="Anzeigename"
|
||||||
|
value={mapping.playerName}
|
||||||
|
columns={upload.columns}
|
||||||
|
onChange={(value) => setMapping({ ...mapping, playerName: value })}
|
||||||
|
/>
|
||||||
|
<MappingSelect
|
||||||
|
label="Bestell-ID (fuer Dedup)"
|
||||||
|
value={mapping.orderId}
|
||||||
|
columns={upload.columns}
|
||||||
|
onChange={(value) => setMapping({ ...mapping, orderId: value })}
|
||||||
|
/>
|
||||||
|
<MappingSelect
|
||||||
|
label="Kaufdatum"
|
||||||
|
value={mapping.purchasedAt}
|
||||||
|
columns={upload.columns}
|
||||||
|
onChange={(value) => setMapping({ ...mapping, purchasedAt: value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={mapping.createMissingPlayers}
|
||||||
|
onChange={(event) =>
|
||||||
|
setMapping({ ...mapping, createMissingPlayers: event.target.checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>Unbekannte Spieler automatisch anlegen</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{!mapping.orderId && (
|
||||||
|
<Notice kind="warn">
|
||||||
|
Ohne Bestell-ID kann ein zweiter Import dieselben Kaeufe erneut buchen.
|
||||||
|
</Notice>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => void handleDiscard()}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Berechne …' : 'Vorschau erzeugen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3>Beispielzeilen</h3>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table small">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{upload.columns.map((column) => (
|
||||||
|
<th key={column}>{column}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{upload.sample.map((row, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
{upload.columns.map((column) => (
|
||||||
|
<td key={column} className="muted">
|
||||||
|
{row[column] ?? ''}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<section className="block">
|
||||||
|
<h2>Vorschau</h2>
|
||||||
|
|
||||||
|
<div className="summary-grid">
|
||||||
|
{(Object.keys(PLAN_LABEL) as ImportPlan[]).map((plan) => (
|
||||||
|
<div
|
||||||
|
key={plan}
|
||||||
|
className={`summary-card${
|
||||||
|
plan === 'BOOK' || plan === 'CREATE_PLAYER_AND_BOOK' ? ' is-active' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="summary-value">{preview.summary[plan] ?? 0}</span>
|
||||||
|
<span className="summary-label">{PLAN_LABEL[plan]}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bookable === 0 ? (
|
||||||
|
<Notice kind="warn">
|
||||||
|
Keine Zeile wuerde gebucht. Pruefe die Spaltenzuordnung oder die Produkt-Mappings
|
||||||
|
in der Verwaltung.
|
||||||
|
</Notice>
|
||||||
|
) : (
|
||||||
|
<Notice kind="info">
|
||||||
|
{bookable} Zeilen werden gebucht. Ein CC-Paket erzeugt dabei automatisch ein
|
||||||
|
CC-Guthaben <em>und</em> ein bezahltes Redesign-Guthaben.
|
||||||
|
</Notice>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="table-wrap scroll">
|
||||||
|
<table className="table small">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Spieler</th>
|
||||||
|
<th>Produkt</th>
|
||||||
|
<th>Bestell-ID</th>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Ergebnis</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(preview.import.rows ?? []).map((row) => (
|
||||||
|
<tr key={row.id} className={row.plan.startsWith('SKIP') ? 'is-skipped' : undefined}>
|
||||||
|
<td className="muted">{row.lineNo}</td>
|
||||||
|
<td>
|
||||||
|
{row.playerName || row.playerKey || '—'}
|
||||||
|
{row.resolvedPlayerId === null && row.plan === 'CREATE_PLAYER_AND_BOOK' && (
|
||||||
|
<span className="badge badge-new">neu</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="muted">{row.productName ?? '—'}</td>
|
||||||
|
<td className="mono muted">{row.externalId ?? '—'}</td>
|
||||||
|
<td className="muted">{formatDate(row.purchasedAt)}</td>
|
||||||
|
<td className={row.plan.startsWith('SKIP') ? 'muted' : 'pos'}>
|
||||||
|
{PLAN_LABEL[row.plan]}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => void handleDiscard()}>
|
||||||
|
Verwerfen
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => setPreview(null)}>
|
||||||
|
Zuordnung aendern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={busy || bookable === 0}
|
||||||
|
onClick={() => void handleCommit()}
|
||||||
|
>
|
||||||
|
{busy ? 'Buche …' : `${bookable} Zeilen buchen`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MappingSelect({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
columns,
|
||||||
|
required,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
columns: string[];
|
||||||
|
required?: boolean;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="field">
|
||||||
|
<label>{label}</label>
|
||||||
|
<select value={value} onChange={(event) => onChange(event.target.value)} required={required}>
|
||||||
|
<option value="">— nicht zuordnen —</option>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<option key={column} value={column}>
|
||||||
|
{column}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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<LoginResponse>('/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 (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1>CC-Manager</h1>
|
||||||
|
<p className="login-sub">
|
||||||
|
{challenge ? 'Bestaetigungscode eingeben' : 'Bitte anmelden'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <Notice kind="error">{error}</Notice>}
|
||||||
|
|
||||||
|
{challenge ? (
|
||||||
|
<form onSubmit={submitTotp}>
|
||||||
|
<label htmlFor="code">Code aus der Authenticator-App</label>
|
||||||
|
<input
|
||||||
|
id="code"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
value={code}
|
||||||
|
onChange={(event) => setCode(event.target.value)}
|
||||||
|
maxLength={10}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-primary btn-block" disabled={busy}>
|
||||||
|
{busy ? 'Pruefe …' : 'Bestaetigen'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-block"
|
||||||
|
onClick={() => {
|
||||||
|
setChallenge(null);
|
||||||
|
setCode('');
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zurueck
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={submitPassword}>
|
||||||
|
<label htmlFor="username">Benutzername</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
autoComplete="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="password">Passwort</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-primary btn-block" disabled={busy}>
|
||||||
|
{busy ? 'Melde an …' : 'Anmelden'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Detail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [flash, setFlash] = useState<string | null>(null);
|
||||||
|
const [dialog, setDialog] = useState<DialogState>({ type: 'none' });
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setDetail(await api<Detail>(`/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<unknown>, 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 <p className="muted">Lade …</p>;
|
||||||
|
if (!detail) return <Notice kind="error">{error ?? 'Spieler nicht gefunden'}</Notice>;
|
||||||
|
|
||||||
|
const editor = can('EDITOR');
|
||||||
|
const active = detail.characters.filter((c) => c.status === 'ACTIVE');
|
||||||
|
const inactive = detail.characters.filter((c) => c.status === 'INACTIVE');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<Link to="/" className="back-link">
|
||||||
|
← Alle Spieler
|
||||||
|
</Link>
|
||||||
|
<h1>{detail.player.displayName}</h1>
|
||||||
|
<p className="muted mono">
|
||||||
|
{detail.player.steamId ?? 'keine SteamID'}
|
||||||
|
{detail.player.forumName && ` · Forum: ${detail.player.forumName}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
||||||
|
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
|
||||||
|
|
||||||
|
<BalanceCards balances={detail.balances} />
|
||||||
|
|
||||||
|
{editor && (
|
||||||
|
<section className="action-bar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void run(
|
||||||
|
() => api(`/api/players/${playerId}/credits/cc-package`, { method: 'POST', body: {} }),
|
||||||
|
'CC-Paket eingetragen: +1 CC und +1 Redesign.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ CC-Paket
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void run(
|
||||||
|
() =>
|
||||||
|
api(`/api/players/${playerId}/credits/redesign-package`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {},
|
||||||
|
}),
|
||||||
|
'Redesign-Paket eingetragen: +1 Redesign.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ Redesign-Paket
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void run(
|
||||||
|
() => api(`/api/players/${playerId}/credits/kulanz`, { method: 'POST', body: {} }),
|
||||||
|
'Kulanz-Redesign eingetragen: +1 Kulanz.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ Kulanz
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setDialog({ type: 'newCharacter' })}
|
||||||
|
>
|
||||||
|
Neuer CC
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="block">
|
||||||
|
<h2>Aktive CCs ({active.length})</h2>
|
||||||
|
{active.length === 0 ? (
|
||||||
|
<p className="muted">Keine aktiven CCs.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="cc-list">
|
||||||
|
{active.map((character) => (
|
||||||
|
<li key={character.id} className="cc-item">
|
||||||
|
<div>
|
||||||
|
<strong>{character.name}</strong>
|
||||||
|
{character.predecessor && (
|
||||||
|
<span className="muted"> · Nachfolger von {character.predecessor.name}</span>
|
||||||
|
)}
|
||||||
|
{character.loreText && <p className="lore">{character.loreText}</p>}
|
||||||
|
<span className="muted small">Seit {formatDate(character.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
{editor && (
|
||||||
|
<div className="cc-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-small"
|
||||||
|
onClick={() => setDialog({ type: 'redesign', character })}
|
||||||
|
>
|
||||||
|
Redesign
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-small btn-ghost"
|
||||||
|
onClick={() => setDialog({ type: 'deactivate', character })}
|
||||||
|
>
|
||||||
|
Deaktivieren
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-small btn-danger"
|
||||||
|
onClick={() => setDialog({ type: 'revert', character })}
|
||||||
|
>
|
||||||
|
Zuruecknehmen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="block">
|
||||||
|
<h2>Inaktive CCs ({inactive.length})</h2>
|
||||||
|
{inactive.length === 0 ? (
|
||||||
|
<p className="muted">Keine inaktiven CCs.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="cc-list">
|
||||||
|
{inactive.map((character) => {
|
||||||
|
const free = character.inactiveReason === 'PLAYER_INACTIVITY';
|
||||||
|
return (
|
||||||
|
<li key={character.id} className="cc-item is-inactive">
|
||||||
|
<div>
|
||||||
|
<strong>{character.name}</strong>
|
||||||
|
<span className={`badge ${free ? 'badge-free' : 'badge-paid'}`}>
|
||||||
|
{character.inactiveReason
|
||||||
|
? REASON_LABEL[character.inactiveReason]
|
||||||
|
: 'Unbekannt'}
|
||||||
|
</span>
|
||||||
|
{character.successors.length > 0 && (
|
||||||
|
<span className="muted">
|
||||||
|
{' '}
|
||||||
|
· Ersetzt durch {character.successors.map((s) => s.name).join(', ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<p className="muted small">
|
||||||
|
{free
|
||||||
|
? 'Kommt gratis zurueck — kostet kein Guthaben.'
|
||||||
|
: 'Reaktivierung kostet ein Redesign- oder CC-Guthaben.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{editor && (
|
||||||
|
<div className="cc-actions">
|
||||||
|
{free ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-small btn-primary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void run(
|
||||||
|
() =>
|
||||||
|
api(`/api/characters/${character.id}/reactivate-free`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {},
|
||||||
|
}),
|
||||||
|
`"${character.name}" ist wieder aktiv — kostenlos.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Gratis reaktivieren
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-small"
|
||||||
|
onClick={() => setDialog({ type: 'reactivatePaid', character })}
|
||||||
|
>
|
||||||
|
Bezahlt reaktivieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="block">
|
||||||
|
<h2>Guthaben-Historie</h2>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Zeitpunkt</th>
|
||||||
|
<th>Topf</th>
|
||||||
|
<th className="num">Menge</th>
|
||||||
|
<th>Anlass</th>
|
||||||
|
<th>Notiz</th>
|
||||||
|
<th>Von</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{detail.ledger.map((entry) => (
|
||||||
|
<tr key={entry.id} className={entry.reversedBy ? 'is-reversed' : undefined}>
|
||||||
|
<td className="muted small">{formatDate(entry.createdAt)}</td>
|
||||||
|
<td>{KIND_LABEL[entry.kind]}</td>
|
||||||
|
<td className={`num ${entry.amount < 0 ? 'neg' : 'pos'}`}>
|
||||||
|
{formatAmount(entry.amount)}
|
||||||
|
</td>
|
||||||
|
<td>{SOURCE_LABEL[entry.source]}</td>
|
||||||
|
<td className="muted small">
|
||||||
|
{entry.note ?? '—'}
|
||||||
|
{entry.character && ` (${entry.character.name})`}
|
||||||
|
</td>
|
||||||
|
<td className="muted small">{entry.createdBy?.username ?? 'System'}</td>
|
||||||
|
<td className="num">
|
||||||
|
{editor &&
|
||||||
|
!entry.reversedBy &&
|
||||||
|
entry.source !== 'CONSUME' &&
|
||||||
|
entry.source !== 'REVERSAL' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-tiny btn-ghost"
|
||||||
|
onClick={() => setDialog({ type: 'reverse', entry })}
|
||||||
|
>
|
||||||
|
Storno
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{entry.reversedBy && <span className="muted small">storniert</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{detail.ledger.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="empty">
|
||||||
|
Noch keine Buchungen.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{detail.ledgerTotal > detail.ledger.length && (
|
||||||
|
<p className="muted">
|
||||||
|
{detail.ledger.length} von {detail.ledgerTotal} Buchungen angezeigt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{dialog.type === 'newCharacter' && (
|
||||||
|
<CharacterFormModal
|
||||||
|
title="Neuen CC anlegen"
|
||||||
|
submitLabel="Anlegen"
|
||||||
|
hint="Kostet 1 CC-Guthaben. Kulanz-Guthaben ist dafuer nicht einsetzbar."
|
||||||
|
busy={busy}
|
||||||
|
onClose={() => 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' && (
|
||||||
|
<CharacterFormModal
|
||||||
|
title={`Redesign von "${dialog.character.name}"`}
|
||||||
|
submitLabel="Redesign durchfuehren"
|
||||||
|
hint={`"${dialog.character.name}" wird inaktiv (redesignt), der neue CC tritt an seine Stelle.`}
|
||||||
|
busy={busy}
|
||||||
|
withKind={['REDESIGN_KULANZ', 'REDESIGN_PAID']}
|
||||||
|
balances={detail.balances}
|
||||||
|
onClose={() => 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' && (
|
||||||
|
<DeactivateModal
|
||||||
|
character={dialog.character}
|
||||||
|
busy={busy}
|
||||||
|
onClose={() => 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' && (
|
||||||
|
<KindChoiceModal
|
||||||
|
title={`"${dialog.character.name}" reaktivieren`}
|
||||||
|
hint="Dieser CC existiert im Original nicht mehr. Die Reaktivierung kostet ein Guthaben."
|
||||||
|
kinds={['REDESIGN_KULANZ', 'REDESIGN_PAID', 'CC']}
|
||||||
|
balances={detail.balances}
|
||||||
|
busy={busy}
|
||||||
|
onClose={() => 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' && (
|
||||||
|
<ReasonModal
|
||||||
|
title={`"${dialog.character.name}" zuruecknehmen`}
|
||||||
|
hint="Der CC wird geloescht, verbrauchte Guthaben werden zurueckgebucht. Fuer Fehleintraege gedacht."
|
||||||
|
confirmLabel="Zuruecknehmen"
|
||||||
|
danger
|
||||||
|
busy={busy}
|
||||||
|
onClose={() => 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' && (
|
||||||
|
<ReasonModal
|
||||||
|
title="Buchung stornieren"
|
||||||
|
hint="Es wird eine Gegenbuchung erzeugt. Bei einem CC-Paket betrifft das beide Zeilen."
|
||||||
|
confirmLabel="Stornieren"
|
||||||
|
busy={busy}
|
||||||
|
onClose={() => setDialog({ type: 'none' })}
|
||||||
|
onSubmit={(reason) =>
|
||||||
|
void run(
|
||||||
|
() =>
|
||||||
|
api(`/api/credits/${dialog.entry.id}/reverse`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { reason },
|
||||||
|
}),
|
||||||
|
'Buchung storniert.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<CreditKind>(withKind?.[0] ?? 'CC');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={title} onClose={onClose}>
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit({
|
||||||
|
name,
|
||||||
|
...(loreText.trim() ? { loreText: loreText.trim() } : {}),
|
||||||
|
...(withKind ? { useKind } : {}),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Notice kind="info">{hint}</Notice>
|
||||||
|
|
||||||
|
<label htmlFor="cc-name">Name des CCs</label>
|
||||||
|
<input
|
||||||
|
id="cc-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="cc-lore">Lore / Notiz (optional)</label>
|
||||||
|
<textarea
|
||||||
|
id="cc-lore"
|
||||||
|
rows={3}
|
||||||
|
value={loreText}
|
||||||
|
onChange={(event) => setLoreText(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{withKind && balances && (
|
||||||
|
<>
|
||||||
|
<label>Welches Guthaben soll verbraucht werden?</label>
|
||||||
|
<div className="radio-group">
|
||||||
|
{withKind.map((kind) => (
|
||||||
|
<label key={kind} className="radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="useKind"
|
||||||
|
value={kind}
|
||||||
|
checked={useKind === kind}
|
||||||
|
onChange={() => setUseKind(kind)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{KIND_LABEL[kind]}
|
||||||
|
<span className={balances[kind] < 1 ? 'neg' : 'muted'}>
|
||||||
|
{' '}
|
||||||
|
({balances[kind]} verfuegbar)
|
||||||
|
</span>
|
||||||
|
</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}>
|
||||||
|
{busy ? 'Speichere …' : submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KindChoiceModal({
|
||||||
|
title,
|
||||||
|
hint,
|
||||||
|
kinds,
|
||||||
|
balances,
|
||||||
|
busy,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
hint: string;
|
||||||
|
kinds: CreditKind[];
|
||||||
|
balances: { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number };
|
||||||
|
busy: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (kind: CreditKind) => void;
|
||||||
|
}) {
|
||||||
|
const [useKind, setUseKind] = useState<CreditKind>(kinds[0] ?? 'CC');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={title} onClose={onClose}>
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit(useKind);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Notice kind="info">{hint}</Notice>
|
||||||
|
|
||||||
|
<div className="radio-group">
|
||||||
|
{kinds.map((kind) => (
|
||||||
|
<label key={kind} className="radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="useKind"
|
||||||
|
value={kind}
|
||||||
|
checked={useKind === kind}
|
||||||
|
onChange={() => setUseKind(kind)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{KIND_LABEL[kind]}
|
||||||
|
<span className={balances[kind] < 1 ? 'neg' : 'muted'}>
|
||||||
|
{' '}
|
||||||
|
({balances[kind]} verfuegbar)
|
||||||
|
</span>
|
||||||
|
</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}>
|
||||||
|
{busy ? 'Speichere …' : 'Bestaetigen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeactivateModal({
|
||||||
|
character,
|
||||||
|
busy,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
character: Character;
|
||||||
|
busy: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (reason: 'PLAYER_INACTIVITY' | 'OTHER') => void;
|
||||||
|
}) {
|
||||||
|
const [reason, setReason] = useState<'PLAYER_INACTIVITY' | 'OTHER'>('PLAYER_INACTIVITY');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={`"${character.name}" deaktivieren`} onClose={onClose}>
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit(reason);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Notice kind="info">
|
||||||
|
Der Grund entscheidet, ob die spaetere Reaktivierung kostenlos ist.
|
||||||
|
</Notice>
|
||||||
|
|
||||||
|
<div className="radio-group">
|
||||||
|
<label className="radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="reason"
|
||||||
|
checked={reason === 'PLAYER_INACTIVITY'}
|
||||||
|
onChange={() => setReason('PLAYER_INACTIVITY')}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Spieler-Inaktivitaet</strong>
|
||||||
|
<span className="muted"> — vom Server geputzt, kommt gratis zurueck</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="reason"
|
||||||
|
checked={reason === 'OTHER'}
|
||||||
|
onChange={() => setReason('OTHER')}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Sonstiger Grund</strong>
|
||||||
|
<span className="muted"> — Reaktivierung kostet ein Guthaben</span>
|
||||||
|
</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}>
|
||||||
|
{busy ? 'Speichere …' : 'Deaktivieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReasonModal({
|
||||||
|
title,
|
||||||
|
hint,
|
||||||
|
confirmLabel,
|
||||||
|
danger,
|
||||||
|
busy,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
hint: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
danger?: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (reason: string) => void;
|
||||||
|
}) {
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={title} onClose={onClose}>
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit(reason);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Notice kind={danger ? 'warn' : 'info'}>{hint}</Notice>
|
||||||
|
|
||||||
|
<label htmlFor="reason">Begruendung</label>
|
||||||
|
<input
|
||||||
|
id="reason"
|
||||||
|
value={reason}
|
||||||
|
onChange={(event) => setReason(event.target.value)}
|
||||||
|
placeholder="z. B. versehentlich doppelt eingetragen"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`btn ${danger ? 'btn-danger' : 'btn-primary'}`}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? 'Speichere …' : confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,819 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #12141a;
|
||||||
|
--bg-raised: #1a1d26;
|
||||||
|
--bg-input: #21252f;
|
||||||
|
--border: #2c3140;
|
||||||
|
--border-strong: #3a4154;
|
||||||
|
--text: #e6e8ee;
|
||||||
|
--text-muted: #9099ad;
|
||||||
|
--accent: #5b8cff;
|
||||||
|
--accent-hover: #6d99ff;
|
||||||
|
--positive: #4ac28a;
|
||||||
|
--negative: #f2626b;
|
||||||
|
--warn: #e0a34a;
|
||||||
|
--radius: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
margin: 0 0 0.2rem;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 1.5rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boot {
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Rahmen --- */
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-inner {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0.7rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.brand:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.nav a:hover {
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.nav a.active {
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.user-chip:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-tag {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.75rem 1.5rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page.narrow {
|
||||||
|
max-width: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Bausteine --- */
|
||||||
|
|
||||||
|
.block {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.block-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.small {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.pos {
|
||||||
|
color: var(--positive);
|
||||||
|
}
|
||||||
|
.neg {
|
||||||
|
color: var(--negative);
|
||||||
|
}
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.link-strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Guthaben --- */
|
||||||
|
|
||||||
|
.balances {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.1rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card.is-negative {
|
||||||
|
border-color: var(--negative);
|
||||||
|
background: color-mix(in srgb, var(--negative) 8%, var(--bg-raised));
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-value {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card.is-negative .balance-value {
|
||||||
|
color: var(--negative);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eligibility {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.eligibility .ok {
|
||||||
|
color: var(--positive);
|
||||||
|
}
|
||||||
|
.eligibility .no {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overdrawn-hint {
|
||||||
|
margin: 0.6rem 0 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-counter {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 1.9rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.mini-counter.is-negative {
|
||||||
|
background: color-mix(in srgb, var(--negative) 20%, transparent);
|
||||||
|
color: var(--negative);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- CC-Listen --- */
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-item.is-inactive {
|
||||||
|
opacity: 0.85;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lore {
|
||||||
|
margin: 0.35rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 60ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.badge-free {
|
||||||
|
border-color: var(--positive);
|
||||||
|
color: var(--positive);
|
||||||
|
}
|
||||||
|
.badge-paid {
|
||||||
|
border-color: var(--warn);
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
.badge-new {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Tabellen --- */
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.table-wrap.scroll {
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.925rem;
|
||||||
|
}
|
||||||
|
.table.small {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.table tbody tr:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 5%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-reversed td {
|
||||||
|
opacity: 0.45;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
.is-skipped td {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
max-width: 40ch;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Formulare --- */
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin: 0.85rem 0 0.3rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text'],
|
||||||
|
input[type='password'],
|
||||||
|
input[type='search'],
|
||||||
|
input:not([type]),
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.925rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -1px;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.inline-form input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.inline-form select {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox,
|
||||||
|
.radio {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.55rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-weight: 400;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox input,
|
||||||
|
.radio input {
|
||||||
|
width: auto;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-drop {
|
||||||
|
display: block;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
padding: 2.5rem;
|
||||||
|
border: 2px dashed var(--border-strong);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.file-drop:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.file-drop input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr {
|
||||||
|
display: block;
|
||||||
|
margin: 1rem 0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #fff;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Buttons --- */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
border-color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--negative);
|
||||||
|
color: var(--negative);
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--negative) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 0.3rem 0.65rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.btn-tiny {
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.btn-block {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Hinweise --- */
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin: 0.85rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-error {
|
||||||
|
background: color-mix(in srgb, var(--negative) 12%, transparent);
|
||||||
|
border-color: var(--negative);
|
||||||
|
color: var(--negative);
|
||||||
|
}
|
||||||
|
.notice-success {
|
||||||
|
background: color-mix(in srgb, var(--positive) 12%, transparent);
|
||||||
|
border-color: var(--positive);
|
||||||
|
color: var(--positive);
|
||||||
|
}
|
||||||
|
.notice-warn {
|
||||||
|
background: color-mix(in srgb, var(--warn) 12%, transparent);
|
||||||
|
border-color: var(--warn);
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
.notice-info {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Modal --- */
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
z-index: 100;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.modal-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 0.5rem 1.25rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-foot {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Tabs --- */
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.tab.is-active {
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Import-Zusammenfassung --- */
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.summary-card.is-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Login --- */
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 370px;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-sub {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Schmale Viewports --- */
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.balance-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.grid-2 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.topbar-inner {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.cc-item {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.cc-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 1.25rem 1rem 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user