Ketten mit der Maus ordnen, Verweise an CCs, Umzug per Tabelle

Die Spielerseite zeigt CCs nicht mehr als zwei Listen "aktiv" und "inaktiv",
sondern als das, was sie sind: je Platz eine Kette, von unten nach oben
gelesen. Unten der erste CC, darueber seine Redesigns.

Gefunden wird selten alles auf einmal. Ein Redesign taucht Jahre spaeter auf,
weil sein Antrag in einem anderen Board lag oder weil der Spieler es fuer den
Beitritt zu einem fremden Gruppen-CC benutzt hat. Dann steht in der Kette
schon "3 auf 5", und 4 fehlt. Deshalb laesst sich jeder Eintrag mit der Maus
an seinen Platz ziehen - auch mitten in eine Kette und in jede andere Kette
desselben Spielers. Das Verschieben ordnet nur ein: es bucht nichts und
aendert keinen Status.

Dafuer traegt jeder CC jetzt eine Position in seiner Kette. Sie laesst sich
verschieben, ohne die ganze Kette neu zu haengen; der Vorgaenger-Verweis, an
dem der Rest der Anwendung haengt, wird danach aus den Positionen neu
abgeleitet. So koennen beide Sichten nicht auseinanderlaufen. Der Bestand
bekommt seine Positionen in der Migration aus der vorhandenen Verkettung.

An jedem CC haengen jetzt beliebig viele Verweise: ein Name und eine Adresse,
der Name wird zum Link. Nur http und https - ein "javascript:"-Link in einer
Oberflaeche, die mehrere Leute bedienen, waere ein offenes Scheunentor.

Die SteamID fuehrt auf der Spielerseite zum GExtension-Profil, der Forum-Name
zum Forumsprofil. Dessen Adresse kommt aus dem Abgleich: die Profile liegen
nicht unter dem Forums-Pfad, sondern eine Ebene darueber, und das laesst sich
nicht verlaesslich erraten - also wird uebernommen, worauf das Forum selbst
verweist.

Zuletzt der Umzug: unter Import gibt es fuer Administratoren den gesamten
Bestand als eine Tabelle - Spieler, Guthaben, CCs samt Ketten, frueherer
Konten, Mitgliedschaften und Verweise. Eingespielt wird ergaenzend: was da
ist, bleibt unangetastet, zweimal dieselbe Datei aendert nichts, und krumme
Zeilen werden mit Zeilennummer gemeldet statt den Lauf abzubrechen. Ein Test
spielt den vollen Weg durch - exportieren, Bestand loeschen, zurueckholen.
This commit is contained in:
2026-09-21 13:18:11 +02:00
parent 029d380abf
commit db37d3388a
22 changed files with 2472 additions and 357 deletions
+121 -1
View File
@@ -2,7 +2,8 @@ import { useCallback, useEffect, 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';
import { useSession } from '../lib/session';
import type { BackupReport, ImportPlan, ImportRecord } from '../lib/types';
interface UploadResult {
importId: number;
@@ -53,6 +54,8 @@ function guessMapping(columns: string[]): MappingForm {
}
export default function Import() {
const { can } = useSession();
const admin = can('ADMIN');
const [upload, setUpload] = useState<UploadResult | null>(null);
const [mapping, setMapping] = useState<MappingForm>(EMPTY_MAPPING);
const [preview, setPreview] = useState<{
@@ -253,6 +256,8 @@ export default function Import() {
</section>
)}
{!upload && admin && <BackupBox onError={setError} onFlash={setFlash} />}
{upload && !preview && (
<section className="block">
<h2>Spalten zuordnen</h2>
@@ -462,3 +467,118 @@ function MappingSelect({
</div>
);
}
/**
* Vollständige Sicherung: alles in eine Tabelle, und zurück.
*
* Gedacht für den Umzug auf einen anderen Server oder den Wechsel auf ein
* anderes Programm - nicht für den täglichen Gebrauch. Deshalb steht es
* unten und bleibt Administratoren vorbehalten.
*/
function BackupBox({
onError,
onFlash,
}: {
onError: (message: string) => void;
onFlash: (message: string) => void;
}) {
const [busy, setBusy] = useState(false);
const [bericht, setBericht] = useState<BackupReport | null>(null);
const einspielen = async (file: File) => {
if (
!window.confirm(
`"${file.name}" einspielen?\n\nWas es hier schon gibt, bleibt unangetastet - ergänzt wird nur, was fehlt.`,
)
) {
return;
}
setBusy(true);
setBericht(null);
try {
const ergebnis = await uploadFile<BackupReport>('/api/backup/import', file);
setBericht(ergebnis);
const angelegt = Object.values(ergebnis.angelegt).reduce((a, b) => a + b, 0);
onFlash(
angelegt > 0
? `${angelegt} Einträge ergänzt.`
: 'Nichts zu ergänzen - der Bestand war vollständig.',
);
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Einspielen fehlgeschlagen');
} finally {
setBusy(false);
}
};
return (
<section className="block">
<h2>Alles sichern und zurückholen</h2>
<Notice kind="info">
Der gesamte Bestand als eine Tabelle: Spieler, Guthaben, CCs, frühere Konten,
Mitgliedschaften und Verweise. Für den Umzug auf einen anderen Server oder den Wechsel auf
ein anderes Programm. Beim Einspielen wird ergänzt, was fehlt - vorhandene Einträge
bleiben, wie sie sind.
</Notice>
<div className="form-actions" style={{ justifyContent: 'flex-start' }}>
<a className="btn" href="/api/backup/export" download>
Alles herunterladen
</a>
<label className="btn">
<input
type="file"
accept=".csv"
style={{ display: 'none' }}
disabled={busy}
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void einspielen(file);
event.target.value = '';
}}
/>
{busy ? 'Spiele ein …' : 'Sicherung einspielen'}
</label>
</div>
{bericht && (
<div className="table-wrap" style={{ marginTop: '0.75rem' }}>
<table className="table small">
<thead>
<tr>
<th>Satzart</th>
<th className="num">Ergänzt</th>
<th className="num">War schon da</th>
</tr>
</thead>
<tbody>
{Object.keys(bericht.angelegt).map((satzart) => (
<tr key={satzart}>
<td>{satzart}</td>
<td className="num">{bericht.angelegt[satzart] ?? 0}</td>
<td className="num muted">{bericht.uebersprungen[satzart] ?? 0}</td>
</tr>
))}
</tbody>
</table>
{bericht.fehler.length > 0 && (
<Notice kind="warn">
<strong>{bericht.fehler.length} Zeilen liessen sich nicht zuordnen:</strong>
<ul>
{bericht.fehler.slice(0, 10).map((f) => (
<li key={f.zeile}>
Zeile {f.zeile}: {f.grund}
</li>
))}
</ul>
{bericht.fehler.length > 10 && <p>… und {bericht.fehler.length - 10} weitere.</p>}
</Notice>
)}
</div>
)}
</section>
);
}
+69 -346
View File
@@ -3,6 +3,8 @@ import { Link, useNavigate, useParams } from 'react-router-dom';
import BalanceCards from '../components/BalanceCards';
import UnmatchedBox from '../components/UnmatchedBox';
import AliasBox from '../components/AliasBox';
import ChainView from '../components/ChainView';
import LinksModal from '../components/LinksModal';
import PendingRowsBox from '../components/PendingRowsBox';
import ThreadsBox from '../components/ThreadsBox';
import Modal from '../components/Modal';
@@ -14,14 +16,12 @@ import {
SOURCE_LABEL,
formatAmount,
formatDate,
rueckkehrArt,
} from '../lib/format';
import RunProgress from '../components/RunProgress';
import { useScrape } from '../lib/useScrape';
import { useSession } from '../lib/session';
import type {
Character,
CharacterMember,
Player,
PlayerSummary,
CreditKind,
@@ -31,32 +31,7 @@ import type {
type DeactivateReason = 'PLAYER_INACTIVITY' | 'OTHER' | 'NO_FREE_CLAIM' | 'DEAD';
/// Ein CC mit mehr als einem aktiven Nutzer. Dort gibt es zwei Ebenen: den CC
/// als Ganzes und den Anteil eines Einzelnen.
function istGruppe(character: Character): boolean {
return (character.members ?? []).filter((m) => !m.leftAt).length > 1;
}
/**
* Der Name eines CCs. Hängt ein Forums-Antrag daran, führt ein Klick direkt
* dorthin - den Thread sonst wiederzufinden kostet jedes Mal Sucherei.
*/
function CharacterName({ character }: { character: Character }) {
const thread = character.forumThreads?.[0];
if (!thread) return <strong>{character.name}</strong>;
return (
<a
href={thread.url}
target="_blank"
rel="noreferrer"
className="link-strong"
title={`Antrag im Forum öffnen: ${thread.title}`}
>
{character.name}
</a>
);
}
type DialogState =
| { type: 'none' }
@@ -73,12 +48,13 @@ type DialogState =
| { type: 'reviveDead'; character: Character }
| { type: 'problem' }
| { type: 'memberRedesign'; character: Character }
| { type: 'memberDeactivate'; character: Character };
| { type: 'memberDeactivate'; character: Character }
| { type: 'links'; character: Character };
export default function PlayerDetail() {
const { id } = useParams<{ id: string }>();
const playerId = Number(id);
const { can } = useSession();
const { can, gextensionBaseUrl } = useSession();
const navigate = useNavigate();
const [detail, setDetail] = useState<Detail | null>(null);
@@ -143,8 +119,6 @@ export default function PlayerDetail() {
const editor = can('EDITOR');
const admin = can('ADMIN');
const active = detail.characters.filter((c) => c.status === 'ACTIVE');
const inactive = detail.characters.filter((c) => c.status === 'INACTIVE');
const playerName = detail.player.displayName;
@@ -157,8 +131,39 @@ export default function PlayerDetail() {
</Link>
<h1>{detail.player.displayName}</h1>
<p className="muted mono">
{detail.player.steamId ?? 'keine SteamID'}
{detail.player.forumName && ` · Forum: ${detail.player.forumName}`}
{detail.player.steamId ? (
gextensionBaseUrl ? (
<a
href={`${gextensionBaseUrl.replace(/\/+$/, '')}/index.php?t=user&id=${detail.player.steamId}`}
target="_blank"
rel="noreferrer"
title="GExtension-Profil öffnen"
>
{detail.player.steamId}
</a>
) : (
detail.player.steamId
)
) : (
'keine SteamID'
)}
{detail.player.forumName && (
<>
{' · Forum: '}
{detail.player.forumProfileUrl ? (
<a
href={detail.player.forumProfileUrl}
target="_blank"
rel="noreferrer"
title="Forumsprofil öffnen"
>
{detail.player.forumName}
</a>
) : (
detail.player.forumName
)}
</>
)}
</p>
{detail.player.notes && <p className="lore">{detail.player.notes}</p>}
</div>
@@ -297,193 +302,28 @@ export default function PlayerDetail() {
</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>
<CharacterName character={character} />
{character.members && character.members.filter((m) => !m.leftAt).length > 1 && (
<span className="badge badge-new">
Gruppen-CC · {character.members.filter((m) => !m.leftAt).length}
</span>
)}
{character.predecessor && (
<span className="muted"> · Nachfolger von {character.predecessor.name}</span>
)}
{character.loreText && <p className="lore">{character.loreText}</p>}
<MemberList
character={character}
currentPlayerId={playerId}
canEdit={editor}
onChanged={() => void load()}
onError={setError}
/>
<span className="muted small">Seit {formatDate(character.createdAt)}</span>
</div>
{editor && (
<div className="cc-actions">
{istGruppe(character) ? (
<>
<button
type="button"
className="btn btn-small"
title="Nur dieser Spieler steigt aus und bekommt einen eigenen CC"
onClick={() => setDialog({ type: 'memberRedesign', character })}
>
Anteil redesignen
</button>
<button
type="button"
className="btn btn-small"
title="Die ganze Gruppe wandert in einen neuen CC"
onClick={() => setDialog({ type: 'redesign', character })}
>
Gruppe redesignen
</button>
<button
type="button"
className="btn btn-small btn-ghost"
title="Nur dieser Spieler fällt aus, die übrigen spielen weiter"
onClick={() => setDialog({ type: 'memberDeactivate', character })}
>
Nur diesen Nutzer
</button>
</>
) : (
<button
type="button"
className="btn btn-small"
onClick={() => setDialog({ type: 'redesign', character })}
>
Redesign
</button>
)}
<button
type="button"
className="btn btn-small"
onClick={() => setDialog({ type: 'addMember', character })}
>
Nutzer aufnehmen
</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 })}
>
Zurücknehmen
</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 art = rueckkehrArt(character.inactiveReason);
const free = art === 'gratis';
return (
<li key={character.id} className="cc-item is-inactive">
<div>
<CharacterName character={character} />
<span
className={`badge ${
art === 'gratis'
? 'badge-free'
: art === 'admin'
? 'badge-dead'
: '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>
)}
{character.inactiveNote && (
<p className="lore">{character.inactiveNote}</p>
)}
<p className="muted small">
{art === 'admin'
? 'Im Rollenspiel gestorben - die Rückkehr entscheidet ein Administrator.'
: free
? 'Kommt gratis zurück - kostet kein Guthaben.'
: 'Reaktivierung kostet ein Redesign- oder CC-Guthaben.'}
</p>
</div>
{editor && (
<div className="cc-actions">
{art === 'admin' ? (
admin ? (
<button
type="button"
className="btn btn-small btn-primary"
onClick={() => setDialog({ type: 'reviveDead', character })}
>
Zurückholen
</button>
) : (
<span className="muted small">Nur ein Administrator</span>
)
) : 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>
<ChainView
characters={detail.characters}
playerId={playerId}
canEdit={editor}
admin={admin}
onAction={(action, character) => {
if (action === 'reactivateFree') {
void run(
() =>
api(`/api/characters/${character.id}/reactivate-free`, {
method: 'POST',
body: {},
}),
`"${character.name}" ist wieder aktiv - kostenlos.`,
);
return;
}
setDialog({ type: action, character });
}}
onMoved={() => void load()}
onError={setError}
/>
<ThreadsBox
threads={detail.threads ?? []}
@@ -617,6 +457,15 @@ export default function PlayerDetail() {
/>
)}
{dialog.type === 'links' && (
<LinksModal
character={dialog.character}
canEdit={editor}
onClose={() => setDialog({ type: 'none' })}
onChanged={() => void load()}
/>
)}
{dialog.type === 'memberRedesign' && (
<CharacterFormModal
title={`Anteil an "${dialog.character.name}" redesignen`}
@@ -1416,132 +1265,6 @@ function DeletePlayerModal({
* Wer einen CC nutzt. Bei einem gewöhnlichen CC ist das eine Zeile und wird
* ausgeblendet; erst bei mehreren Nutzern lohnt die Liste.
*/
function MemberList({
character,
currentPlayerId,
canEdit,
onChanged,
onError,
}: {
character: Character;
currentPlayerId: number;
canEdit: boolean;
onChanged: () => void;
onError: (message: string) => void;
}) {
const alle = character.members ?? [];
const aktiv = alle.filter((m) => !m.leftAt);
// Wer ausgefallen ist, kann zurückkehren - wer gegangen ist, nicht.
const ruhend = alle.filter((m) => m.leftAt && m.inactiveReason);
const ehemalig = alle.filter((m) => m.leftAt && !m.inactiveReason);
if (aktiv.length <= 1 && ruhend.length === 0 && ehemalig.length === 0) return null;
const austragen = async (playerId: number, name: string) => {
if (!window.confirm(`"${name}" aus "${character.name}" austragen?`)) return;
try {
await api(`/api/characters/${character.id}/members/${playerId}`, { method: 'DELETE' });
onChanged();
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Austragen fehlgeschlagen');
}
};
const zurueckholen = async (mitglied: CharacterMember) => {
const art = rueckkehrArt(mitglied.inactiveReason);
const name = mitglied.player.displayName;
let reason: string | undefined;
if (art === 'admin') {
const eingabe = window.prompt(
`"${name}" ist im Rollenspiel gestorben. Womit kehrt er zurück?`,
);
if (!eingabe?.trim()) return;
reason = eingabe.trim();
}
let useKind: string | undefined;
if (art === 'bezahlt') {
const kulanz = window.confirm(
`Die Rückkehr von "${name}" kostet ein Guthaben.\n\nOK = Kulanz-Redesign, Abbrechen = bezahltes Redesign.`,
);
useKind = kulanz ? 'REDESIGN_KULANZ' : 'REDESIGN_PAID';
}
try {
await api(`/api/characters/${character.id}/members/${mitglied.playerId}/reactivate`, {
method: 'POST',
body: { ...(useKind ? { useKind } : {}), ...(reason ? { reason } : {}) },
});
onChanged();
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Rückkehr fehlgeschlagen');
}
};
return (
<div className="member-list">
{aktiv.map((mitglied) => (
<span key={mitglied.id} className="member-chip">
{mitglied.playerId === currentPlayerId ? (
<strong>{mitglied.player.displayName}</strong>
) : (
<Link to={`/players/${mitglied.playerId}`}>{mitglied.player.displayName}</Link>
)}
{canEdit && aktiv.length > 1 && (
<button
type="button"
className="member-remove"
title="Austragen"
onClick={() => void austragen(mitglied.playerId, mitglied.player.displayName)}
>
✕
</button>
)}
</span>
))}
{ruhend.map((mitglied) => (
<span key={mitglied.id} className="member-chip is-inactive">
<span className="muted">{mitglied.player.displayName}</span>
<span
className={`badge ${
rueckkehrArt(mitglied.inactiveReason) === 'gratis'
? 'badge-free'
: rueckkehrArt(mitglied.inactiveReason) === 'admin'
? 'badge-dead'
: 'badge-paid'
}`}
title={mitglied.inactiveNote ?? undefined}
>
{mitglied.inactiveReason ? REASON_LABEL[mitglied.inactiveReason] : 'Inaktiv'}
</span>
{canEdit && (
<button
type="button"
className="btn btn-tiny btn-ghost"
onClick={() => void zurueckholen(mitglied)}
>
Zurückholen
</button>
)}
</span>
))}
{ehemalig.length > 0 && (
<span className="muted small">
früher:{' '}
{ehemalig
.map((m) =>
m.successor ? `${m.player.displayName} (jetzt ${m.successor.name})` : m.player.displayName,
)
.join(', ')}
</span>
)}
</div>
);
}
function AddMemberModal({
character,
busy,