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:
+1
-1
@@ -84,7 +84,7 @@ export default function App() {
|
||||
<Route
|
||||
path="/import"
|
||||
element={
|
||||
<Protected minimum="ADMIN">
|
||||
<Protected minimum="EDITOR">
|
||||
<Layout>
|
||||
<Import />
|
||||
</Layout>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user