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:
+33
-30
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user