Einzelne Gruppenmitglieder, Austritt per Redesign, Ladebalken beim Abgleich

Bei einem CC mit mehreren Nutzern gibt es zwei Ebenen, und bisher liess sich
nur die obere bedienen. Jetzt beide:

Ein Spieler kann seinen Anteil redesignen und steigt damit aus - er bekommt
einen eigenen CC, die Gruppe laeuft unberuehrt weiter. Fuer ihn ist es ein
Tausch wie jeder andere: der Gruppenplatz geht weg, der eigene CC kommt an
seine Stelle, es kommt keiner hinzu. Wohin er gewechselt ist, bleibt an der
Mitgliedschaft festgehalten und steht bei den frueheren Nutzern. Das Redesign
der ganzen Gruppe, bei dem alle mitwandern, gibt es unveraendert daneben.

Ebenso koennen einzelne Nutzer ausfallen, ohne die uebrigen zu beruehren: bei
einem Trio kann einer im Rollenspiel sterben, waehrend die anderen
weiterspielen, und ebenso koennen alle nacheinander wegfallen. Grund und
Begruendung haengen jetzt auch an der Mitgliedschaft. Die Rueckkehr folgt
denselben Regeln wie beim eigenen CC - geputzt gratis, entzogen gegen
Guthaben, gestorben nur durch einen Administrator mit Begruendung. Wer
ausgefallen ist, sieht seinen CC weiter auf der Spielerseite; sonst gaebe es
nichts, worueber er zurueckkehren koennte. Ein freiwilliger Austritt bleibt
davon getrennt: dort gibt es nichts zurueckzuholen, ein Wiedereintritt kostet
neu.

Der GExtension-Abgleich hat denselben Ladebalken wie der Forumslauf bekommen,
fuer alle Spieler wie fuer einzelne, samt Protokoll. Beide teilen sich jetzt
dieselbe Anzeige.

Dabei aufgefallen: Zeitplan und Handklick konnten denselben Abgleich doppelt
starten und haetten dieselben Profile zweimal abgerufen. Die Sperre sitzt
jetzt im Lauf selbst statt im Starter, damit sie fuer beide Wege gilt.
This commit is contained in:
2026-09-21 12:50:40 +02:00
parent dbde5b7d11
commit 029d380abf
17 changed files with 1368 additions and 172 deletions
+54
View File
@@ -0,0 +1,54 @@
/**
* Fortschritt eines laufenden Abgleichs.
*
* Steht der Umfang noch nicht fest - beim Forum, solange nicht jedes Board
* seine Seitenzahl gemeldet hat -, läuft der Balken durch, statt einen
* erfundenen Anteil zu behaupten.
*/
export default function RunProgress({
done,
total,
current,
log,
hint,
}: {
done: number;
total: number;
/// Woran gerade gearbeitet wird, fertig formuliert.
current: string | null;
log: string[];
hint: string;
}) {
const bekannt = total > 0;
const anteil = bekannt ? Math.min(100, Math.round((done / total) * 100)) : 0;
const letzte = log.slice(-6);
return (
<section className="block progress">
<div className="progress-head">
<strong>{bekannt ? `${done} von ${total}` : `Umfang wird ermittelt … (${done})`}</strong>
<span className="muted">
{current ?? 'wird gespeichert'}
{bekannt && ` · ${anteil} %`}
</span>
</div>
<div className="progress-bar">
<div
className={bekannt ? 'progress-fill' : 'progress-fill is-unknown'}
style={bekannt ? { width: `${anteil}%` } : undefined}
/>
</div>
{letzte.length > 0 && (
<ul className="progress-log">
{letzte.map((zeile, index) => (
<li key={`${index}-${zeile}`}>{zeile}</li>
))}
</ul>
)}
<p className="muted small">{hint}</p>
</section>
);
}
+18
View File
@@ -113,6 +113,12 @@ export interface CharacterMember {
paidWith: CreditKind | null;
/// Der eigene CC, den der Spieler für den Platz aufgegeben hat.
predecessorId: number | null;
/// Der CC, in den er von hier aus gewechselt ist.
successorId: number | null;
successor?: CharacterRef | null;
/// Warum er den CC nicht mehr nutzt. Leer bei einem freiwilligen Austritt.
inactiveReason: InactiveReason | null;
inactiveNote: string | null;
note: string | null;
}
@@ -347,3 +353,15 @@ export interface PendingOverview {
withoutPlayer: number;
players: { playerId: number; displayName: string; count: number }[];
}
export interface ScrapeState {
running: boolean;
startedAt: string | null;
finishedAt: string | null;
currentPlayer: string | null;
playersRead: number;
totalPlayers: number;
log: string[];
summary: ScrapeSummary | null;
error: string | null;
}
+89
View File
@@ -0,0 +1,89 @@
import { useCallback, useEffect, useState } from 'react';
import { ApiError, api } from './api';
import type { ScrapeState, ScrapeSummary } from './types';
/** Was am Ende eines Laufs in der Meldung steht. */
export function scrapeBericht(result: ScrapeSummary): string {
const parts = [`${result.scannedPlayers} Profile geprüft`];
if (result.newRows > 0) parts.push(`${result.newRows} neue Pakete gefunden`);
if (result.awaitingConfirmation > 0) parts.push(`${result.awaitingConfirmation} warten schon`);
if (result.unclear > 0) parts.push(`${result.unclear} ungeklärt`);
if (result.duplicates > 0) parts.push(`${result.duplicates} schon gebucht`);
if (result.failedPlayers.length > 0) {
parts.push(`${result.failedPlayers.length} nicht erreichbar`);
}
const offen = result.newRows + result.awaitingConfirmation;
return (
parts.join(', ') +
(offen > 0 ? '. Beim Spieler oder unter Import buchen, damit die Guthaben entstehen.' : '.')
);
}
/**
* Stößt einen GExtension-Abgleich an und verfolgt ihn.
*
* Der Lauf geht über dutzende Profile, zwischen denen gewartet wird - er
* läuft deshalb im Hintergrund weiter, auch wenn die Seite gewechselt wird.
* Hier wird nur nachgefragt, wie weit er ist.
*/
export function useScrape(options: {
onDone: (bericht: string) => void;
onError: (message: string) => void;
onFinished?: () => void | Promise<void>;
}) {
const { onDone, onError, onFinished } = options;
const [state, setState] = useState<ScrapeState | null>(null);
const [running, setRunning] = useState(false);
// Ein Lauf aus einer anderen Sitzung - oder vom Zeitplan - soll hier
// ebenfalls sichtbar sein.
useEffect(() => {
void (async () => {
try {
const aktuell = await api<ScrapeState>('/api/scrape/status');
setState(aktuell);
if (aktuell.running) setRunning(true);
} catch {
// Ohne Stand bleibt der Knopf einfach bedienbar.
}
})();
}, []);
useEffect(() => {
if (!running) return;
const abfragen = async () => {
try {
const aktuell = await api<ScrapeState>('/api/scrape/status');
setState(aktuell);
if (aktuell.running) return;
setRunning(false);
if (aktuell.error) onError(aktuell.error);
else if (aktuell.summary) onDone(scrapeBericht(aktuell.summary));
await onFinished?.();
} catch (err) {
setRunning(false);
onError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
}
};
const timer = window.setInterval(() => void abfragen(), 1500);
return () => window.clearInterval(timer);
}, [running, onDone, onError, onFinished]);
const start = useCallback(
async (pfad: string) => {
try {
setState(await api<ScrapeState>(pfad, { method: 'POST', body: {} }));
setRunning(true);
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
}
},
[onError],
);
return { state, running, start };
}
+33 -30
View File
@@ -3,11 +3,12 @@ import { Link, useSearchParams } from 'react-router-dom';
import Modal from '../components/Modal';
import Notice from '../components/Notice';
import { ApiError, api } from '../lib/api';
import RunProgress from '../components/RunProgress';
import { useScrape } from '../lib/useScrape';
import { useSession } from '../lib/session';
import type {
PendingOverview,
PlayerSummary,
ScrapeSummary,
UnmatchedOverview,
} from '../lib/types';
@@ -41,7 +42,6 @@ export default function Dashboard() {
withoutPlayer: 0,
players: [],
});
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<string | null>(null);
/// Nur die Fälle zeigen, bei denen noch etwas offen ist.
const [nurProbleme, setNurProbleme] = useState(false);
@@ -79,33 +79,19 @@ export default function Dashboard() {
void loadFlagged();
}, [loadFlagged]);
const runSync = async () => {
setSyncing(true);
setSyncResult(null);
try {
const result = await api<ScrapeSummary>('/api/scrape/run', { method: 'POST', body: {} });
const parts = [`${result.scannedPlayers} Profile geprüft`];
if (result.newRows > 0) parts.push(`${result.newRows} neue Pakete gefunden`);
if (result.awaitingConfirmation > 0) {
parts.push(`${result.awaitingConfirmation} warten schon`);
}
if (result.unclear > 0) parts.push(`${result.unclear} ungeklärt`);
if (result.duplicates > 0) parts.push(`${result.duplicates} schon gebucht`);
if (result.failedPlayers.length > 0) parts.push(`${result.failedPlayers.length} nicht erreichbar`);
const nachlesen = useCallback(async () => {
await Promise.all([load(query, nurProbleme), loadFlagged()]);
}, [load, loadFlagged, query, nurProbleme]);
const offen = result.newRows + result.awaitingConfirmation;
setSyncResult(
parts.join(', ') +
(offen > 0
? '. Unter Import bestätigen, damit die Guthaben entstehen.'
: '.'),
);
await Promise.all([load(query, nurProbleme), loadFlagged()]);
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
} finally {
setSyncing(false);
}
const scrape = useScrape({
onDone: (bericht) => setSyncResult(bericht),
onError: setError,
onFinished: nachlesen,
});
const runSync = async () => {
setSyncResult(null);
await scrape.start('/api/scrape/run');
};
// Tippen entprellen, damit nicht jeder Anschlag eine Anfrage auslöst.
@@ -124,8 +110,15 @@ export default function Dashboard() {
<h1>Spieler</h1>
<div className="head-actions">
{can('EDITOR') && (
<button type="button" className="btn" disabled={syncing} onClick={() => void runSync()}>
{syncing ? 'Gleiche ab …' : 'GExtension abgleichen'}
<button
type="button"
className="btn"
disabled={scrape.running}
onClick={() => void runSync()}
>
{scrape.running
? `Gleiche ab … (${scrape.state?.playersRead ?? 0})`
: 'GExtension abgleichen'}
</button>
)}
{can('EDITOR') && (
@@ -157,6 +150,16 @@ export default function Dashboard() {
{error && <Notice kind="error">{error}</Notice>}
{syncResult && <Notice kind="success" onDismiss={() => setSyncResult(null)}>{syncResult}</Notice>}
{scrape.running && (
<RunProgress
done={scrape.state?.playersRead ?? 0}
total={scrape.state?.totalPlayers ?? 0}
current={scrape.state?.currentPlayer ? `liest ${scrape.state.currentPlayer}` : null}
log={scrape.state?.log ?? []}
hint="Zwischen den Profilen wird bewusst gewartet - GExtension gehört der Community, nicht uns. Der Lauf geht im Hintergrund weiter, auch wenn diese Seite geschlossen wird."
/>
)}
{offenePakete.total > 0 && (
<Notice kind="warn">
<strong>
+14 -52
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import Notice from '../components/Notice';
import RunProgress from '../components/RunProgress';
import { AssignModal, ClassifyModal } from '../components/ThreadModals';
import { ApiError, api } from '../lib/api';
import { BOARD_LABEL, CLASSIFICATION_LABEL, formatDate } from '../lib/format';
@@ -232,7 +233,19 @@ export default function Forum() {
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
{busy && <ScanProgress state={scanZustand} />}
{busy && (
<RunProgress
done={scanZustand?.pagesRead ?? 0}
total={scanZustand?.totalPages ?? 0}
current={
scanZustand?.currentBoard
? `liest ${BOARD_PFAD[scanZustand.currentBoard] ?? scanZustand.currentBoard}`
: null
}
log={scanZustand?.log ?? []}
hint="Zwischen den Abrufen wird bewusst gewartet, damit das Forum den Zugriff nicht bremst. Der Lauf geht im Hintergrund weiter, auch wenn diese Seite geschlossen wird."
/>
)}
{notices.length > 0 && (
<section className="block block-flag">
@@ -545,54 +558,3 @@ export default function Forum() {
</div>
);
}
/**
* Was der Abgleich gerade tut. Der Umfang steht erst fest, wenn von jedem
* Board die erste Seite gelesen ist - bis dahin läuft der Balken durch,
* statt einen erfundenen Anteil zu behaupten.
*/
function ScanProgress({ state }: { state: ScanState | null }) {
const gelesen = state?.pagesRead ?? 0;
const gesamt = state?.totalPages ?? 0;
const bekannt = gesamt > 0;
const anteil = bekannt ? Math.min(100, Math.round((gelesen / gesamt) * 100)) : 0;
const letzte = state?.log.slice(-6) ?? [];
return (
<section className="block progress">
<div className="progress-head">
<strong>
{bekannt
? `${gelesen} von ${gesamt} Seiten gelesen`
: `Umfang wird ermittelt … (${gelesen} Seiten)`}
</strong>
<span className="muted">
{state?.currentBoard
? `liest ${BOARD_PFAD[state.currentBoard] ?? state.currentBoard}`
: 'wird gespeichert'}
{bekannt && ` · ${anteil} %`}
</span>
</div>
<div className="progress-bar">
<div
className={bekannt ? 'progress-fill' : 'progress-fill is-unknown'}
style={bekannt ? { width: `${anteil}%` } : undefined}
/>
</div>
{letzte.length > 0 && (
<ul className="progress-log">
{letzte.map((zeile, index) => (
<li key={`${index}-${zeile}`}>{zeile}</li>
))}
</ul>
)}
<p className="muted small">
Zwischen den Abrufen wird bewusst gewartet, damit das Forum den Zugriff nicht bremst. Der
Lauf geht im Hintergrund weiter, auch wenn diese Seite geschlossen wird.
</p>
</section>
);
}
+216 -54
View File
@@ -16,19 +16,27 @@ import {
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,
LedgerEntry,
PlayerDetail as Detail,
ScrapeSummary,
} from '../lib/types';
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.
@@ -63,7 +71,9 @@ type DialogState =
| { type: 'addCredit'; kind: 'cc-package' | 'redesign-package' | 'kulanz' }
| { type: 'addMember'; character: Character }
| { type: 'reviveDead'; character: Character }
| { type: 'problem' };
| { type: 'problem' }
| { type: 'memberRedesign'; character: Character }
| { type: 'memberDeactivate'; character: Character };
export default function PlayerDetail() {
const { id } = useParams<{ id: string }>();
@@ -110,42 +120,22 @@ export default function PlayerDetail() {
};
// Der Abgleich bucht nichts, er legt eine Vorschau an. Das muss die Meldung
// auch sagen, sonst wirkt der Knopf wirkungslos.
const syncFromGextension = async () => {
setBusy(true);
setError(null);
try {
const result = await api<ScrapeSummary>(`/api/scrape/player/${playerId}`, {
method: 'POST',
body: {},
});
await load();
// auch sagen, sonst wirkt der Knopf wirkungslos. Gelesen werden alle Konten
// des Spielers, deshalb kann auch ein einzelner Lauf dauern.
const melden = useCallback((text: string) => {
setFlash(text);
window.setTimeout(() => setFlash(null), 8000);
}, []);
if (result.newRows > 0) {
setFlash(
`${result.newRows} ${result.newRows === 1 ? 'Paket' : 'Pakete'} gefunden. ` +
'Unter Import bestätigen, damit die Guthaben entstehen.',
);
} else if (result.awaitingConfirmation > 0) {
setFlash(
`${result.awaitingConfirmation} ${result.awaitingConfirmation === 1 ? 'Paket wartet' : 'Pakete warten'} ` +
'bereits unter Import auf Bestätigung.',
);
} else if (result.unclear > 0) {
setFlash(`${result.unclear} unklare Einträge - siehe Kasten oben.`);
} else if (result.duplicates > 0) {
setFlash('Nichts Neues - alle Pakete sind bereits gebucht.');
} else if (result.failedPlayers.length > 0) {
setError(`Profil nicht abrufbar: ${result.failedPlayers[0]?.reason ?? 'unbekannt'}`);
} else {
setFlash('Keine CC-relevanten Pakete im Profil gefunden.');
}
window.setTimeout(() => setFlash(null), 8000);
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
} finally {
setBusy(false);
}
const scrape = useScrape({
onDone: melden,
onError: setError,
onFinished: load,
});
const syncFromGextension = async () => {
setError(null);
await scrape.start(`/api/scrape/player/${playerId}`);
};
if (loading) return <p className="muted">Lade …</p>;
@@ -156,6 +146,8 @@ export default function PlayerDetail() {
const active = detail.characters.filter((c) => c.status === 'ACTIVE');
const inactive = detail.characters.filter((c) => c.status === 'INACTIVE');
const playerName = detail.player.displayName;
return (
<div className="page">
<div className="page-head">
@@ -203,6 +195,16 @@ export default function PlayerDetail() {
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
{scrape.running && (
<RunProgress
done={scrape.state?.playersRead ?? 0}
total={scrape.state?.totalPlayers ?? 0}
current={scrape.state?.currentPlayer ? `liest ${scrape.state.currentPlayer}` : null}
log={scrape.state?.log ?? []}
hint="Gelesen werden alle Konten dieses Spielers, auch die früheren. Zwischen den Abrufen wird bewusst gewartet."
/>
)}
{detail.player.isProblem && (
<Notice kind="warn">
<strong>Offener Problemfall.</strong>{' '}
@@ -285,11 +287,11 @@ export default function PlayerDetail() {
<button
type="button"
className="btn"
disabled={busy}
title="Pakete dieses Spielers von GExtension abrufen"
disabled={busy || scrape.running}
title="Pakete dieses Spielers von GExtension abrufen, auch die früherer Konten"
onClick={() => void syncFromGextension()}
>
GExtension abgleichen
{scrape.running ? 'Gleiche ab …' : 'GExtension abgleichen'}
</button>
)}
</section>
@@ -325,13 +327,42 @@ export default function PlayerDetail() {
</div>
{editor && (
<div className="cc-actions">
<button
type="button"
className="btn btn-small"
onClick={() => setDialog({ type: 'redesign', character })}
>
Redesign
</button>
{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"
@@ -586,6 +617,49 @@ export default function PlayerDetail() {
/>
)}
{dialog.type === 'memberRedesign' && (
<CharacterFormModal
title={`Anteil an "${dialog.character.name}" redesignen`}
submitLabel="Redesign durchführen"
hint={`${playerName} steigt aus "${dialog.character.name}" aus und bekommt dafür einen eigenen CC. Die übrigen Nutzer bleiben unberührt. Ein Redesign tauscht einen Platz, es kommt keiner hinzu.`}
busy={busy}
withKind={['REDESIGN_KULANZ', 'REDESIGN_PAID']}
balances={detail.balances}
onClose={() => setDialog({ type: 'none' })}
onSubmit={(values) =>
void run(
() =>
api(`/api/characters/${dialog.character.id}/members/${playerId}/redesign`, {
method: 'POST',
body: values,
}),
`${playerName} ist aus "${dialog.character.name}" ausgestiegen: "${values.name}".`,
)
}
/>
)}
{dialog.type === 'memberDeactivate' && (
<DeactivateModal
character={dialog.character}
busy={busy}
admin={admin}
scope="member"
playerName={playerName}
onClose={() => setDialog({ type: 'none' })}
onSubmit={(reason, note) =>
void run(
() =>
api(`/api/characters/${dialog.character.id}/members/${playerId}/deactivate`, {
method: 'POST',
body: { reason, ...(note ? { note } : {}) },
}),
`${playerName} nutzt "${dialog.character.name}" nicht mehr.`,
)
}
/>
)}
{dialog.type === 'deactivate' && (
<DeactivateModal
character={dialog.character}
@@ -1033,11 +1107,19 @@ function KindChoiceModal({
function DeactivateModal({
character,
busy,
admin = true,
scope = 'character',
playerName,
onClose,
onSubmit,
}: {
character: Character;
busy: boolean;
/// Ein Tod im Rollenspiel bleibt Administratoren vorbehalten.
admin?: boolean;
/// Ob der ganze CC betroffen ist oder nur der Platz eines Nutzers.
scope?: 'character' | 'member';
playerName?: string;
onClose: () => void;
onSubmit: (reason: DeactivateReason, note: string) => void;
}) {
@@ -1045,16 +1127,28 @@ function DeactivateModal({
const [note, setNote] = useState('');
// Ein Tod im Rollenspiel ist eine Ausnahme und will erklärt werden.
const notizPflicht = reason === 'DEAD';
const einzeln = scope === 'member';
return (
<Modal title={`"${character.name}" deaktivieren`} onClose={onClose}>
<Modal
title={
einzeln
? `${playerName ?? 'Dieser Nutzer'} verlässt "${character.name}"`
: `"${character.name}" deaktivieren`
}
onClose={onClose}
>
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit(reason, note);
}}
>
<Notice kind="info">Der Grund entscheidet, wie der CC später zurückkommt.</Notice>
<Notice kind="info">
{einzeln
? `Nur dieser Platz fällt weg - "${character.name}" läuft mit den übrigen Nutzern weiter. Der Grund entscheidet, wie die Rückkehr aussieht.`
: 'Der Grund entscheidet, wie der CC später zurückkommt.'}
</Notice>
<div className="radio-group">
{(
@@ -1080,7 +1174,9 @@ function DeactivateModal({
'Rückkehr nur durch einen Administrator, mit Begründung',
],
] as const
).map(([wert, label, hinweis]) => (
)
.filter(([wert]) => wert !== 'DEAD' || admin)
.map(([wert, label, hinweis]) => (
<label key={wert} className="radio">
<input
type="radio"
@@ -1093,7 +1189,7 @@ function DeactivateModal({
<span className="muted"> - {hinweis}</span>
</span>
</label>
))}
))}
</div>
<label htmlFor="deactivate-note">
@@ -1335,9 +1431,11 @@ function MemberList({
}) {
const alle = character.members ?? [];
const aktiv = alle.filter((m) => !m.leftAt);
const ehemalig = 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 && ehemalig.length === 0) return null;
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;
@@ -1349,6 +1447,38 @@ function MemberList({
}
};
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) => (
@@ -1371,9 +1501,41 @@ function MemberList({
</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.player.displayName).join(', ')}
früher:{' '}
{ehemalig
.map((m) =>
m.successor ? `${m.player.displayName} (jetzt ${m.successor.name})` : m.player.displayName,
)
.join(', ')}
</span>
)}
</div>
+6
View File
@@ -1097,3 +1097,9 @@ textarea {
flex: 0 0 auto;
width: auto;
}
/* Ein Nutzer, der gerade ausfällt - der CC selbst läuft weiter. */
.member-chip.is-inactive {
opacity: 0.75;
border-style: dashed;
}