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:
@@ -19,8 +19,21 @@ export const REASON_LABEL: Record<InactiveReason, string> = {
|
||||
PLAYER_INACTIVITY: 'Spieler-Inaktivität',
|
||||
REWORKED: 'Redesignt',
|
||||
OTHER: 'Anderer Grund',
|
||||
NO_FREE_CLAIM: 'Entzogen',
|
||||
DEAD: 'Im RP gestorben',
|
||||
};
|
||||
|
||||
/**
|
||||
* Welchen Weg die Rückkehr nimmt. Gratis, solange der CC unverändert
|
||||
* weiterexistiert; bezahlt, wenn er ersetzt oder entzogen wurde; und eine
|
||||
* Entscheidung des Administrators, wenn er im Rollenspiel gestorben ist.
|
||||
*/
|
||||
export function rueckkehrArt(reason: InactiveReason | null): 'gratis' | 'bezahlt' | 'admin' {
|
||||
if (reason === 'DEAD') return 'admin';
|
||||
if (reason === 'REWORKED' || reason === 'NO_FREE_CLAIM') return 'bezahlt';
|
||||
return 'gratis';
|
||||
}
|
||||
|
||||
export const PLAN_LABEL: Record<ImportPlan, string> = {
|
||||
BOOK: 'Wird gebucht',
|
||||
CREATE_PLAYER_AND_BOOK: 'Spieler anlegen + buchen',
|
||||
|
||||
+18
-1
@@ -8,7 +8,12 @@ export type CreditSource =
|
||||
| 'CONSUME'
|
||||
| 'REVERSAL';
|
||||
export type CharacterStatus = 'ACTIVE' | 'INACTIVE';
|
||||
export type InactiveReason = 'PLAYER_INACTIVITY' | 'REWORKED' | 'OTHER';
|
||||
export type InactiveReason =
|
||||
| 'PLAYER_INACTIVITY'
|
||||
| 'REWORKED'
|
||||
| 'OTHER'
|
||||
| 'NO_FREE_CLAIM'
|
||||
| 'DEAD';
|
||||
export type ProductTarget = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'IGNORE';
|
||||
|
||||
export interface SessionUser {
|
||||
@@ -85,6 +90,17 @@ export interface CharacterRef {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CharacterMember {
|
||||
id: number;
|
||||
characterId: number;
|
||||
playerId: number;
|
||||
player: { id: number; displayName: string; steamId?: string | null };
|
||||
joinedAt: string;
|
||||
leftAt: string | null;
|
||||
paidWith: CreditKind | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface Character {
|
||||
id: number;
|
||||
playerId: number;
|
||||
@@ -97,6 +113,7 @@ export interface Character {
|
||||
predecessorId: number | null;
|
||||
predecessor: CharacterRef | null;
|
||||
successors: CharacterRef[];
|
||||
members?: CharacterMember[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
||||
+422
-38
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -957,3 +957,42 @@ textarea {
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* --- Gruppen-CCs --- */
|
||||
|
||||
.member-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
|
||||
.member-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.member-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0 0 0 0.1rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.member-remove:hover {
|
||||
color: var(--negative);
|
||||
}
|
||||
|
||||
.badge-dead {
|
||||
border-color: var(--negative);
|
||||
color: var(--negative);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user