Tote CCs, Entzug und Gruppen-CCs

Bisher entschied allein das Redesign darueber, ob eine Rueckkehr etwas kostet.
Es fehlten zwei Faelle: ein Entzug ohne Anspruch auf kostenlose Rueckkehr, und
der Tod im Rollenspiel. Ein toter CC laesst sich weder gratis noch gegen
Guthaben auf dem normalen Weg zurueckholen - das entscheidet ein Administrator
ueber einen eigenen Weg, mit Pflicht-Begruendung, und waehlt dabei, ob ein
Guthaben fliesst. Schon das Eintragen des Todes verlangt eine Notiz.

Dazu koennen sich mehrere Spieler einen CC teilen. Die Zugehoerigkeit laeuft
ab jetzt durchgehend ueber eine eigene Tabelle, auch bei Einzel-CCs; die
Migration traegt bestehende CCs als erstes Mitglied ihres Erstellers nach.
Ohne das waere nach dem Update kein CC mehr beim Spieler aufgetaucht.

Fuer jeden Mitnutzer gelten dieselben Regeln wie fuer einen eigenen CC: der
Platz kostet ein eigenes Guthaben, wahlweise aus dem CC-Paket oder ueber ein
Redesign. Ein Austritt gibt nichts zurueck, denn bis dahin wurde der CC
genutzt; fuer eine Fehleingabe ist das Storno der richtige Weg. Der letzte
Nutzer laesst sich nicht austragen - dann wird der CC deaktiviert. Ein
Redesign nimmt die Gruppe mit.
This commit is contained in:
2026-09-20 23:03:22 +02:00
parent 3a55ef1b2f
commit e78a57e35e
11 changed files with 1193 additions and 53 deletions
+422 -38
View File
@@ -6,16 +6,26 @@ import AliasBox from '../components/AliasBox';
import Modal from '../components/Modal';
import Notice from '../components/Notice';
import { ApiError, api } from '../lib/api';
import { KIND_LABEL, REASON_LABEL, SOURCE_LABEL, formatAmount, formatDate } from '../lib/format';
import {
KIND_LABEL,
REASON_LABEL,
SOURCE_LABEL,
formatAmount,
formatDate,
rueckkehrArt,
} from '../lib/format';
import { useSession } from '../lib/session';
import type {
Character,
PlayerSummary,
CreditKind,
LedgerEntry,
PlayerDetail as Detail,
ScrapeSummary,
} from '../lib/types';
type DeactivateReason = 'PLAYER_INACTIVITY' | 'OTHER' | 'NO_FREE_CLAIM' | 'DEAD';
type DialogState =
| { type: 'none' }
| { type: 'newCharacter' }
@@ -26,7 +36,9 @@ type DialogState =
| { type: 'reverse'; entry: LedgerEntry }
| { type: 'editPlayer' }
| { type: 'deletePlayer' }
| { type: 'addCredit'; kind: 'cc-package' | 'redesign-package' | 'kulanz' };
| { type: 'addCredit'; kind: 'cc-package' | 'redesign-package' | 'kulanz' }
| { type: 'addMember'; character: Character }
| { type: 'reviveDead'; character: Character };
export default function PlayerDetail() {
const { id } = useParams<{ id: string }>();
@@ -236,10 +248,22 @@ export default function PlayerDetail() {
<li key={character.id} className="cc-item">
<div>
<strong>{character.name}</strong>
{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 && (
@@ -251,6 +275,13 @@ export default function PlayerDetail() {
>
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"
@@ -280,13 +311,21 @@ export default function PlayerDetail() {
) : (
<ul className="cc-list">
{inactive.map((character) => {
// Nur ein redesignter CC existiert im Original nicht mehr.
const free = character.inactiveReason !== 'REWORKED';
const art = rueckkehrArt(character.inactiveReason);
const free = art === 'gratis';
return (
<li key={character.id} className="cc-item is-inactive">
<div>
<strong>{character.name}</strong>
<span className={`badge ${free ? 'badge-free' : 'badge-paid'}`}>
<span
className={`badge ${
art === 'gratis'
? 'badge-free'
: art === 'admin'
? 'badge-dead'
: 'badge-paid'
}`}
>
{character.inactiveReason
? REASON_LABEL[character.inactiveReason]
: 'Unbekannt'}
@@ -516,6 +555,43 @@ export default function PlayerDetail() {
/>
)}
{dialog.type === 'addMember' && (
<AddMemberModal
character={dialog.character}
busy={busy}
onClose={() => setDialog({ type: 'none' })}
onSubmit={(values) =>
void run(
() =>
api(`/api/characters/${dialog.character.id}/members`, {
method: 'POST',
body: values,
}),
`Nutzer zu "${dialog.character.name}" hinzugefügt.`,
)
}
/>
)}
{dialog.type === 'reviveDead' && (
<ReviveDeadModal
character={dialog.character}
balances={detail.balances}
busy={busy}
onClose={() => setDialog({ type: 'none' })}
onSubmit={(values) =>
void run(
() =>
api(`/api/characters/${dialog.character.id}/revive-dead`, {
method: 'POST',
body: values,
}),
`"${dialog.character.name}" ist zurück.`,
)
}
/>
)}
{dialog.type === 'addCredit' && (
<AddCreditModal
kind={dialog.kind}
@@ -855,10 +931,12 @@ function DeactivateModal({
character: Character;
busy: boolean;
onClose: () => void;
onSubmit: (reason: 'PLAYER_INACTIVITY' | 'OTHER', note: string) => void;
onSubmit: (reason: DeactivateReason, note: string) => void;
}) {
const [reason, setReason] = useState<'PLAYER_INACTIVITY' | 'OTHER'>('PLAYER_INACTIVITY');
const [reason, setReason] = useState<DeactivateReason>('PLAYER_INACTIVITY');
const [note, setNote] = useState('');
// Ein Tod im Rollenspiel ist eine Ausnahme und will erklärt werden.
const notizPflicht = reason === 'DEAD';
return (
<Modal title={`"${character.name}" deaktivieren`} onClose={onClose}>
@@ -868,53 +946,70 @@ function DeactivateModal({
onSubmit(reason, note);
}}
>
<Notice kind="info">
Der CC bleibt unverändert und kommt später in beiden Fällen gratis zurück. Nur ein
Redesign macht ihn kostenpflichtig - das läuft aber über die Redesign-Aktion.
</Notice>
<Notice kind="info">Der Grund entscheidet, wie der CC später zurückkommt.</Notice>
<div className="radio-group">
<label className="radio">
<input
type="radio"
name="reason"
checked={reason === 'PLAYER_INACTIVITY'}
onChange={() => setReason('PLAYER_INACTIVITY')}
/>
<span>
<strong>Spieler-Inaktivität</strong>
<span className="muted"> - vom Server geputzt, weil der Spieler länger weg war</span>
</span>
</label>
<label className="radio">
<input
type="radio"
name="reason"
checked={reason === 'OTHER'}
onChange={() => setReason('OTHER')}
/>
<span>
<strong>Anderer Grund</strong>
<span className="muted"> - etwa eine vorübergehend entfernte Einheit</span>
</span>
</label>
{(
[
[
'PLAYER_INACTIVITY',
'Spieler-Inaktivität',
'vom Server geputzt - kommt gratis zurück',
],
[
'OTHER',
'Anderer Grund',
'etwa eine vorübergehend entfernte Einheit - kommt gratis zurück',
],
[
'NO_FREE_CLAIM',
'Entzogen',
'kein Anspruch auf kostenlose Rückkehr - kostet ein Guthaben',
],
[
'DEAD',
'Im Rollenspiel gestorben',
'Rückkehr nur durch einen Administrator, mit Begründung',
],
] as const
).map(([wert, label, hinweis]) => (
<label key={wert} className="radio">
<input
type="radio"
name="reason"
checked={reason === wert}
onChange={() => setReason(wert)}
/>
<span>
<strong>{label}</strong>
<span className="muted"> - {hinweis}</span>
</span>
</label>
))}
</div>
<label htmlFor="deactivate-note">
Notiz {reason === 'OTHER' ? '(empfohlen)' : '(optional)'}
Notiz {notizPflicht ? '(Pflicht)' : reason === 'OTHER' ? '(empfohlen)' : '(optional)'}
</label>
<input
id="deactivate-note"
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="z. B. Einheit bis März entfernt"
placeholder={
notizPflicht ? 'Was ist im Rollenspiel passiert?' : 'z. B. Einheit bis März entfernt'
}
required={notizPflicht}
/>
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={busy}>
<button
type="submit"
className="btn btn-primary"
disabled={busy || (notizPflicht && !note.trim())}
>
{busy ? 'Speichere …' : 'Deaktivieren'}
</button>
</div>
@@ -1112,3 +1207,292 @@ function DeletePlayerModal({
</Modal>
);
}
/**
* 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);
const ehemalig = alle.filter((m) => m.leftAt);
if (aktiv.length <= 1 && 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');
}
};
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>
))}
{ehemalig.length > 0 && (
<span className="muted small">
früher: {ehemalig.map((m) => m.player.displayName).join(', ')}
</span>
)}
</div>
);
}
function AddMemberModal({
character,
busy,
onClose,
onSubmit,
}: {
character: Character;
busy: boolean;
onClose: () => void;
onSubmit: (values: { playerId: number; useKind: CreditKind; note?: string }) => void;
}) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<PlayerSummary[]>([]);
const [gewaehlt, setGewaehlt] = useState<PlayerSummary | null>(null);
const [useKind, setUseKind] = useState<CreditKind>('CC');
const [note, setNote] = useState('');
useEffect(() => {
const timer = setTimeout(async () => {
try {
const params = new URLSearchParams({ q: query, take: '20' });
const data = await api<{ items: PlayerSummary[] }>(`/api/players?${params}`);
setResults(data.items.filter((p) => p.id !== character.playerId));
} catch {
// Ein Suchfehler blockiert den Dialog nicht.
}
}, 250);
return () => clearTimeout(timer);
}, [query, character.playerId]);
return (
<Modal title={`Nutzer zu "${character.name}" hinzufügen`} onClose={onClose}>
<Notice kind="info">
Für den neuen Nutzer gilt dasselbe wie für einen eigenen CC: der Platz kostet ihn ein
eigenes Guthaben. Ob über sein CC-Paket oder ein Redesign, entscheidest du hier.
</Notice>
{!gewaehlt ? (
<>
<label htmlFor="member-search">Spieler suchen</label>
<input
id="member-search"
value={query}
onChange={(event) => setQuery(event.target.value)}
autoFocus
/>
<ul className="cc-list" style={{ marginTop: '0.75rem' }}>
{results.map((player) => (
<li key={player.id} className="cc-item">
<div>
<strong>{player.displayName}</strong>
<p className="muted small">
CC {player.balances.CC} · Redesign {player.balances.redesignTotal}
</p>
</div>
<div className="cc-actions">
<button
type="button"
className="btn btn-small btn-primary"
onClick={() => setGewaehlt(player)}
>
Auswählen
</button>
</div>
</li>
))}
{results.length === 0 && <p className="muted">Kein Treffer.</p>}
</ul>
</>
) : (
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit({
playerId: gewaehlt.id,
useKind,
...(note.trim() ? { note: note.trim() } : {}),
});
}}
>
<Notice kind="info">
<strong>{gewaehlt.displayName}</strong> - CC-Guthaben {gewaehlt.balances.CC}, Redesign{' '}
{gewaehlt.balances.redesignTotal}
</Notice>
<label>Womit bezahlt er seinen Platz?</label>
<div className="radio-group">
{(
[
['CC', 'CC-Guthaben', gewaehlt.balances.CC],
['REDESIGN_KULANZ', 'Kulanz-Redesign', gewaehlt.balances.REDESIGN_KULANZ],
['REDESIGN_PAID', 'Bezahltes Redesign', gewaehlt.balances.REDESIGN_PAID],
] as const
).map(([wert, label, stand]) => (
<label key={wert} className="radio">
<input
type="radio"
name="joinKind"
checked={useKind === wert}
onChange={() => setUseKind(wert)}
/>
<span>
{label}
<span className={stand < 1 ? 'neg' : 'muted'}> ({stand} verfügbar)</span>
</span>
</label>
))}
</div>
<label htmlFor="member-note">Notiz (optional)</label>
<input
id="member-note"
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="z. B. Forums-Antrag oder Absprache"
/>
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={() => setGewaehlt(null)}>
Anderen wählen
</button>
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Speichere …' : 'Aufnehmen'}
</button>
</div>
</form>
)}
</Modal>
);
}
function ReviveDeadModal({
character,
balances,
busy,
onClose,
onSubmit,
}: {
character: Character;
balances: { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number };
busy: boolean;
onClose: () => void;
onSubmit: (values: { reason: string; useKind?: CreditKind }) => void;
}) {
const [reason, setReason] = useState('');
const [kostet, setKostet] = useState(false);
const [useKind, setUseKind] = useState<CreditKind>('CC');
return (
<Modal title={`"${character.name}" zurückholen`} onClose={onClose}>
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit({ reason: reason.trim(), ...(kostet ? { useKind } : {}) });
}}
>
<Notice kind="warn">
Dieser CC ist im Rollenspiel gestorben. Seine Rückkehr ist eine Ausnahme und wird mit
Begründung im Protokoll festgehalten.
{character.inactiveNote && (
<>
<br />
<span className="muted">Vermerkt war: {character.inactiveNote}</span>
</>
)}
</Notice>
<label htmlFor="revive-reason">Begründung</label>
<input
id="revive-reason"
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="z. B. Rückholung nach Absprache mit der Leitung"
autoFocus
required
/>
<label className="checkbox">
<input
type="checkbox"
checked={kostet}
onChange={(event) => setKostet(event.target.checked)}
/>
<span>Ein Guthaben dafür verbrauchen</span>
</label>
{kostet && (
<div className="radio-group">
{(
[
['CC', 'CC-Guthaben', balances.CC],
['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ],
['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID],
] as const
).map(([wert, label, stand]) => (
<label key={wert} className="radio">
<input
type="radio"
name="reviveKind"
checked={useKind === wert}
onChange={() => setUseKind(wert)}
/>
<span>
{label}
<span className={stand < 1 ? 'neg' : 'muted'}> ({stand} verfügbar)</span>
</span>
</label>
))}
</div>
)}
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen
</button>
<button type="submit" className="btn btn-danger" disabled={busy || !reason.trim()}>
{busy ? 'Speichere …' : 'Zurückholen'}
</button>
</div>
</form>
</Modal>
);
}