Rollen neu geschnitten, Seitenzahl korrigiert, frühere Konten

Der Forums-Abgleich las von 25 Archivseiten nur zwei. WoltLab verlinkt in der
Blätterleiste nur die Nachbarseiten und führt die Gesamtzahl in einem eigenen
Element; ausgewertet wurden aber die Links. Alles jenseits von Seite zwei war
damit unsichtbar - etwa ein Antrag auf Seite vier, der deshalb nie ankam.

Die Rollen verlaufen jetzt zwischen erfassen und gewähren statt zwischen
Alltag und Verwaltung: Editoren erfassen Käufe und führen Datei-Import,
GExtension- und Forums-Abgleich samt Entscheidung über Anträge durch. Kulanz
zu vergeben bleibt Administratoren, ebenso das Auflösen unklarer Pakete, weil
dabei Kulanz entstehen kann.

Dazu lassen sich frühere Steam- und Forumskonten am Spieler hinterlegen.
Abgleich und Import prüfen sie mit, sodass ein Kontowechsel die Zuordnung
nicht abreißen lässt. Steam-Kennungen werden dabei auf dieselbe Form gebracht
wie die Hauptangabe, und eine Kennung kann nicht zwei Spielern gehören.
This commit is contained in:
2026-09-18 20:07:56 +02:00
parent 3344592c6e
commit 6c50d32d2b
24 changed files with 639 additions and 62 deletions
+1 -1
View File
@@ -84,7 +84,7 @@ export default function App() {
<Route
path="/import"
element={
<Protected minimum="ADMIN">
<Protected minimum="EDITOR">
<Layout>
<Import />
</Layout>
+176
View File
@@ -0,0 +1,176 @@
import { useState } from 'react';
import Notice from './Notice';
import { ApiError, api } from '../lib/api';
import type { AliasType, PlayerAlias } from '../lib/types';
/**
* Frühere Steam- und Forumskonten. Abgleich und Import prüfen sie mit, damit
* ein Kontowechsel die Zuordnung nicht abreißen lässt.
*/
export default function AliasBox({
playerId,
aliases,
canEdit,
onChange,
}: {
playerId: number;
aliases: PlayerAlias[];
canEdit: boolean;
onChange: () => void;
}) {
const [type, setType] = useState<AliasType>('STEAM_ID');
const [value, setValue] = useState('');
const [note, setNote] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [offen, setOffen] = useState(false);
const steam = aliases.filter((a) => a.type === 'STEAM_ID');
const forum = aliases.filter((a) => a.type === 'FORUM_NAME');
const hinzufuegen = async (event: React.FormEvent) => {
event.preventDefault();
setBusy(true);
setError(null);
try {
await api(`/api/players/${playerId}/aliases`, {
method: 'POST',
body: { type, value: value.trim(), ...(note.trim() ? { note: note.trim() } : {}) },
});
setValue('');
setNote('');
onChange();
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Eintragen fehlgeschlagen');
} finally {
setBusy(false);
}
};
const entfernen = async (alias: PlayerAlias) => {
if (!window.confirm(`"${alias.value}" wirklich entfernen?`)) return;
try {
await api(`/api/players/aliases/${alias.id}`, { method: 'DELETE' });
onChange();
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Entfernen fehlgeschlagen');
}
};
// Ohne Einträge und ohne Rechte gibt es nichts zu zeigen.
if (aliases.length === 0 && !canEdit) return null;
return (
<section className="block">
<div className="block-head">
<h2>Frühere Konten ({aliases.length})</h2>
{canEdit && (
<button type="button" className="btn btn-small" onClick={() => setOffen(!offen)}>
{offen ? 'Schliessen' : 'Konto nachtragen'}
</button>
)}
</div>
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
{aliases.length === 0 ? (
<p className="muted small">
Keine hinterlegt. Hat der Spieler früher einen anderen Steam- oder Forumsaccount
genutzt, trag ihn hier nach — Abgleich und Import finden ihn dann mit.
</p>
) : (
<div className="alias-groups">
{steam.length > 0 && (
<div>
<h3>SteamIDs</h3>
<ul className="alias-list">
{steam.map((alias) => (
<li key={alias.id}>
<span className="mono">{alias.value}</span>
{alias.note && <span className="muted small"> · {alias.note}</span>}
{canEdit && (
<button
type="button"
className="btn btn-tiny btn-ghost"
onClick={() => void entfernen(alias)}
>
Entfernen
</button>
)}
</li>
))}
</ul>
</div>
)}
{forum.length > 0 && (
<div>
<h3>Forum-Namen</h3>
<ul className="alias-list">
{forum.map((alias) => (
<li key={alias.id}>
<span>{alias.value}</span>
{alias.note && <span className="muted small"> · {alias.note}</span>}
{canEdit && (
<button
type="button"
className="btn btn-tiny btn-ghost"
onClick={() => void entfernen(alias)}
>
Entfernen
</button>
)}
</li>
))}
</ul>
</div>
)}
</div>
)}
{canEdit && offen && (
<form className="alias-form" onSubmit={hinzufuegen}>
<div className="grid-2">
<div className="field">
<label htmlFor="alias-type">Art</label>
<select
id="alias-type"
value={type}
onChange={(event) => setType(event.target.value as AliasType)}
>
<option value="STEAM_ID">Frühere SteamID</option>
<option value="FORUM_NAME">Früherer Forum-Name</option>
</select>
</div>
<div className="field">
<label htmlFor="alias-value">
{type === 'STEAM_ID' ? 'SteamID' : 'Forum-Name'}
</label>
<input
id="alias-value"
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={type === 'STEAM_ID' ? '76561198... oder STEAM_0:1:...' : 'Alter Name im Forum'}
required
/>
</div>
</div>
<label htmlFor="alias-note">Notiz (optional)</label>
<input
id="alias-note"
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="z. B. Account bis 2024"
/>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Speichere …' : 'Nachtragen'}
</button>
</div>
</form>
)}
</section>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ export default function Layout({ children }: { children: ReactNode }) {
Spieler
</NavLink>
<NavLink to="/forum">Anträge</NavLink>
{can('ADMIN') && <NavLink to="/import">Import</NavLink>}
{can('EDITOR') && <NavLink to="/import">Import</NavLink>}
{can('ADMIN') && <NavLink to="/admin">Verwaltung</NavLink>}
</nav>
+12
View File
@@ -116,6 +116,17 @@ export interface LedgerEntry {
reversalOfId: number | null;
}
export type AliasType = 'STEAM_ID' | 'FORUM_NAME';
export interface PlayerAlias {
id: number;
playerId: number;
type: AliasType;
value: string;
note: string | null;
createdAt: string;
}
export interface PlayerDetail {
player: Player;
balances: Balances;
@@ -123,6 +134,7 @@ export interface PlayerDetail {
ledger: LedgerEntry[];
ledgerTotal: number;
unmatched: UnmatchedPackage[];
aliases: PlayerAlias[];
}
export interface AdminUser {
+1 -1
View File
@@ -101,7 +101,7 @@ export default function Dashboard() {
<div className="page-head">
<h1>Spieler</h1>
<div className="head-actions">
{can('ADMIN') && (
{can('EDITOR') && (
<button type="button" className="btn" disabled={syncing} onClick={() => void runSync()}>
{syncing ? 'Gleiche ab …' : 'GExtension abgleichen'}
</button>
+6 -6
View File
@@ -16,7 +16,7 @@ import type {
export default function Forum() {
const { can } = useSession();
const admin = can('ADMIN');
const darfEntscheiden = can('EDITOR');
const [threads, setThreads] = useState<ForumThread[]>([]);
const [notices, setNotices] = useState<ForumThread[]>([]);
@@ -88,7 +88,7 @@ export default function Forum() {
die hier mit passendem Forum-Namen erfasst sind.
</p>
</div>
{admin && (
{darfEntscheiden && (
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void scan()}>
{busy ? 'Lese Forum …' : 'Forum abgleichen'}
</button>
@@ -124,7 +124,7 @@ export default function Forum() {
<a className="btn btn-small btn-ghost" href={thread.url} target="_blank" rel="noreferrer">
Thread öffnen
</a>
{admin && (
{darfEntscheiden && (
<button type="button" className="btn btn-small" onClick={() => void dismiss(thread)}>
Erledigt
</button>
@@ -141,7 +141,7 @@ export default function Forum() {
{threads.length === 0 ? (
<p className="muted">
Keine offenen Anträge. {admin && 'Über „Forum abgleichen" nach neuen suchen.'}
Keine offenen Anträge. {darfEntscheiden && 'Über „Forum abgleichen" nach neuen suchen.'}
</p>
) : (
<ul className="cc-list">
@@ -170,12 +170,12 @@ export default function Forum() {
</p>
</div>
<div className="cc-actions">
{admin && !thread.player && (
{darfEntscheiden && !thread.player && (
<button type="button" className="btn btn-small" onClick={() => setAssigning(thread)}>
Spieler zuordnen
</button>
)}
{admin && (
{darfEntscheiden && (
<button
type="button"
className="btn btn-small btn-primary"
+20 -9
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import BalanceCards from '../components/BalanceCards';
import UnmatchedBox from '../components/UnmatchedBox';
import AliasBox from '../components/AliasBox';
import Modal from '../components/Modal';
import Notice from '../components/Notice';
import { ApiError, api } from '../lib/api';
@@ -192,14 +193,17 @@ export default function PlayerDetail() {
>
+ Redesign-Paket
</button>
<button
type="button"
className="btn"
disabled={busy}
onClick={() => setDialog({ type: 'addCredit', kind: 'kulanz' })}
>
+ Kulanz
</button>
{/* Kulanz vergibt das Team, sie steht in keinem Kauf - daher nur Admins. */}
{can('ADMIN') && (
<button
type="button"
className="btn"
disabled={busy}
onClick={() => setDialog({ type: 'addCredit', kind: 'kulanz' })}
>
+ Kulanz
</button>
)}
<button
type="button"
className="btn btn-primary"
@@ -208,7 +212,7 @@ export default function PlayerDetail() {
>
Neuer CC
</button>
{can('ADMIN') && detail.player.steamId && (
{can('EDITOR') && detail.player.steamId && (
<button
type="button"
className="btn"
@@ -340,6 +344,13 @@ export default function PlayerDetail() {
)}
</section>
<AliasBox
playerId={playerId}
aliases={detail.aliases ?? []}
canEdit={editor}
onChange={() => void load()}
/>
<section className="block">
<h2>Guthaben-Historie</h2>
<div className="table-wrap">
+47
View File
@@ -910,3 +910,50 @@ textarea {
margin-top: -2.25rem;
}
}
/* --- Frühere Konten --- */
.alias-groups {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1.25rem;
}
.alias-groups h3 {
margin: 0 0 0.4rem;
font-size: 0.85rem;
color: var(--text-muted);
font-weight: 600;
}
.alias-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.alias-list li {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.alias-list li > :first-child {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.alias-form {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}