tja
This commit is contained in:
@@ -17,14 +17,14 @@ Jeder Spieler hat drei Guthaben-Töpfe:
|
||||
Zwei Regeln sind fest verdrahtet:
|
||||
|
||||
1. **Ein CC-Paket erzeugt beide Guthaben.** Der inklusive Redesign wird automatisch mitgebucht,
|
||||
in derselben Transaktion und mit gemeinsamer Gruppen-ID — ein Storno nimmt beide zurück.
|
||||
in derselben Transaktion und mit gemeinsamer Gruppen-ID - ein Storno nimmt beide zurück.
|
||||
2. **Nur redesignte CCs kosten bei der Rückkehr.** Solange ein CC unverändert existiert, kommt er
|
||||
gratis zurück — gleich ob der Spieler inaktiv war oder die Einheit zeitweise entfernt wurde.
|
||||
gratis zurück - gleich ob der Spieler inaktiv war oder die Einheit zeitweise entfernt wurde.
|
||||
Wurde er dagegen redesignt, existiert das Original nicht mehr und die Reaktivierung kostet ein
|
||||
Redesign- oder CC-Guthaben.
|
||||
|
||||
Guthaben werden als **Kontobuch** geführt: jede Bewegung ist eine eigene Zeile, der Stand eines
|
||||
Topfes ist deren Summe. Das macht jeden Stand nachvollziehbar und Korrekturen sauber — storniert
|
||||
Topfes ist deren Summe. Das macht jeden Stand nachvollziehbar und Korrekturen sauber - storniert
|
||||
wird per Gegenbuchung, nie durch Löschen. Ein überzogenes Konto ist erlaubt (kommt vor, wenn ein
|
||||
Redesign vor dem zugehörigen Paket erfasst wird), wird aber rot markiert.
|
||||
|
||||
@@ -33,7 +33,7 @@ Kette „A → B → C" sichtbar und die Reaktivierungsregel greift automatisch
|
||||
A zurück, ist A ein redesignter CC und kostet entsprechend.
|
||||
|
||||
Warum ein CC inaktiv wurde, lässt sich beim Deaktivieren als Klartext festhalten („Einheit bis
|
||||
März entfernt") — der Grund steht dann an der Karte, statt nur als Kategorie.
|
||||
März entfernt") - der Grund steht dann an der Karte, statt nur als Kategorie.
|
||||
|
||||
## Schnellstart
|
||||
|
||||
@@ -59,7 +59,7 @@ docker compose logs -f app
|
||||
```
|
||||
|
||||
Beim ersten Start legt die App einen Administrator an. Ist `BOOTSTRAP_ADMIN_PASSWORD` leer,
|
||||
erzeugt sie ein Einmal-Passwort und schreibt es **einmalig** ins Log — dort steht es in der
|
||||
erzeugt sie ein Einmal-Passwort und schreibt es **einmalig** ins Log - dort steht es in der
|
||||
Ausgabe oben. Beim ersten Login ist ein Passwortwechsel Pflicht; bis dahin sind alle anderen
|
||||
Seiten gesperrt.
|
||||
|
||||
@@ -73,7 +73,7 @@ Der App-Port wird bewusst **nicht** nach außen gemappt. Davor gehört ein Rever
|
||||
weiterleitet. So ist nur Port 443 offen und die Zertifikate laufen über Let's Encrypt.
|
||||
|
||||
Damit der Proxy die App erreicht, hängt der `app`-Service zusätzlich im Netzwerk des Proxys.
|
||||
Die `compose.yaml` erwartet es unter dem Namen `nginx_default` — so heißt es bei Nginx Proxy
|
||||
Die `compose.yaml` erwartet es unter dem Namen `nginx_default` - so heißt es bei Nginx Proxy
|
||||
Manager. Heißt es bei dir anders, setz `PROXY_NETWORK` in der `.env`:
|
||||
|
||||
```
|
||||
@@ -82,15 +82,15 @@ PROXY_NETWORK=mein-proxy-netz
|
||||
|
||||
Das Netzwerk muss existieren, bevor der Stack startet; angelegt wird es vom Proxy. Betreibst du
|
||||
gar keinen Proxy in Docker, entferne den `networks`-Block am Ende der `compose.yaml` samt der
|
||||
`networks`-Liste beim `app`-Service und mappe stattdessen einen Port — dann aber bitte HTTPS
|
||||
`networks`-Liste beim `app`-Service und mappe stattdessen einen Port - dann aber bitte HTTPS
|
||||
anderweitig davorsetzen.
|
||||
|
||||
> **Zu `$` in der `.env`:** Docker Compose deutet `$name` als Variable und ersetzt es durch
|
||||
> nichts, wenn es die Variable nicht gibt. Ein Passwort mit `$` kommt dadurch gekürzt in der App
|
||||
> an — ohne Fehler, nur mit einer leicht zu übersehenden Warnung. Entweder `$` vermeiden oder
|
||||
> an - ohne Fehler, nur mit einer leicht zu übersehenden Warnung. Entweder `$` vermeiden oder
|
||||
> als `$$` schreiben.
|
||||
|
||||
Läuft die App hinter einem Proxy, muss `TRUST_PROXY=true` gesetzt bleiben — sonst landen im
|
||||
Läuft die App hinter einem Proxy, muss `TRUST_PROXY=true` gesetzt bleiben - sonst landen im
|
||||
Audit-Log die Proxy-IPs statt der echten. In Produktion werden Cookies automatisch auf `Secure`
|
||||
gesetzt; nur zum Testen ohne TLS lässt sich das über `COOKIE_SECURE=false` abschalten.
|
||||
|
||||
@@ -98,7 +98,7 @@ gesetzt; nur zum Testen ohne TLS lässt sich das über `COOKIE_SECURE=false` abs
|
||||
|
||||
Der `backup`-Service im Stack schreibt beim Start und danach alle 24 Stunden einen
|
||||
`pg_dump` nach `./backups/` und räumt Dumps älter als 14 Tage weg. Die Daten bilden bezahlte
|
||||
Ansprüche ab — das Verzeichnis gehört auf eine Sicherung außerhalb des Hosts.
|
||||
Ansprüche ab - das Verzeichnis gehört auf eine Sicherung außerhalb des Hosts.
|
||||
|
||||
Wiederherstellen:
|
||||
|
||||
@@ -111,7 +111,7 @@ gunzip -c backups/ccmanager-2025-01-15-0300.sql.gz | \
|
||||
|
||||
Kaufdaten lassen sich als CSV oder JSON hochladen; das Format ist nicht festgelegt. Der Ablauf:
|
||||
|
||||
1. Datei hochladen — Spalten und Beispielzeilen werden angezeigt.
|
||||
1. Datei hochladen - Spalten und Beispielzeilen werden angezeigt.
|
||||
2. Spalten zuordnen (Spieler-Kennung, Produktname, optional Bestell-ID und Datum). Die üblichen
|
||||
Spaltennamen werden vorausgewählt.
|
||||
3. **Vorschau** prüfen: für jede Zeile steht da, was passieren würde.
|
||||
@@ -121,7 +121,7 @@ Welcher Produktname welches Guthaben ergibt, steht in der Mapping-Tabelle unter
|
||||
Produkt-Mappings* (Teiltreffer, Groß-/Kleinschreibung egal). Neue Produkte lassen sich dort
|
||||
jederzeit ergänzen, ohne den Code anzufassen.
|
||||
|
||||
Wird eine Bestell-ID zugeordnet, bucht ein zweiter Import dieselben Käufe **nicht** erneut —
|
||||
Wird eine Bestell-ID zugeordnet, bucht ein zweiter Import dieselben Käufe **nicht** erneut -
|
||||
Dubletten werden sowohl gegen bereits gebuchte Bestellungen als auch innerhalb der Datei erkannt.
|
||||
Ohne Bestell-ID entfällt dieser Schutz; darauf weist das Formular hin.
|
||||
|
||||
@@ -132,7 +132,7 @@ Kulanz-Redesigns tauchen in keinem Shop-Export auf und werden immer von Hand ein
|
||||
> **Auf eine bestimmte Instanz zugeschnitten.** Dieser Teil liest Profile von
|
||||
> `gextension.senseigaming.de` und ist auf deren Seitenaufbau gebaut. Läuft bei euch eine
|
||||
> andere GExtension-Instanz, genügt `GEXTENSION_BASE_URL` in der `.env`. Nutzt ihr ein
|
||||
> *anderes* Shop- oder Spendensystem, funktioniert der Abgleich **nicht** — dafür müsste
|
||||
> *anderes* Shop- oder Spendensystem, funktioniert der Abgleich **nicht** - dafür müsste
|
||||
> `server/src/lib/gextension.ts` angepasst werden. Der Rest der App ist davon unabhängig und
|
||||
> läuft auch ohne diesen Teil; Pakete lassen sich immer von Hand oder per Datei-Import
|
||||
> eintragen.
|
||||
@@ -142,12 +142,12 @@ damit sie nicht abgetippt werden müssen.
|
||||
|
||||
**Was er tut:** Für jeden Spieler, der im CC-Manager erfasst ist *und* eine SteamID hinterlegt
|
||||
hat, ruft er die öffentliche Profilseite ab und liest die Paket-Liste. Auslösen lässt sich das
|
||||
auf der Übersicht (alle Spieler) oder auf einer Spielerseite (nur dieser eine) — jeweils als
|
||||
auf der Übersicht (alle Spieler) oder auf einer Spielerseite (nur dieser eine) - jeweils als
|
||||
Admin.
|
||||
|
||||
**Was er nicht tut:** Er sucht keine neuen Spieler. Wer nicht in der Datenbank steht, wird nie
|
||||
abgefragt, auch wenn er bei GExtension gekauft hat. Neue Käufer legst du von Hand an, danach
|
||||
findet der Abgleich ihre Pakete. Gebucht wird ebenfalls nichts von allein — eindeutige Treffer
|
||||
findet der Abgleich ihre Pakete. Gebucht wird ebenfalls nichts von allein - eindeutige Treffer
|
||||
landen in derselben Vorschau wie der Datei-Import und warten auf Bestätigung.
|
||||
|
||||
Erkannt wird anhand von Titel *und* Bereich: Nur der `SCP-RP`-Bereich führt CC-Pakete, und ein
|
||||
@@ -161,11 +161,11 @@ CC-Paket, gekauftes Redesign, Kulanz oder „nichts davon" einordnet. Bei CC-Pak
|
||||
automatisch auch das inklusive Redesign-Guthaben.
|
||||
|
||||
**Verbrauchszähler:** Meldet GExtension „CC-Paket oder Redesign genutzt | N Mal", wird das beim
|
||||
Spieler angezeigt, aber **nie** automatisch verrechnet — es ist eine Lesehilfe zum Abgleich mit
|
||||
Spieler angezeigt, aber **nie** automatisch verrechnet - es ist eine Lesehilfe zum Abgleich mit
|
||||
dem eigenen Ledger, mehr nicht.
|
||||
|
||||
Der Abgleich hält eine Sekunde Abstand zwischen den Anfragen und weist sich per User-Agent aus.
|
||||
Die Seite gehört dem Serverbetreiber, nicht dieser App — ein offizieller Datenzugang wäre der
|
||||
Die Seite gehört dem Serverbetreiber, nicht dieser App - ein offizieller Datenzugang wäre der
|
||||
sauberere Weg, falls er zu haben ist.
|
||||
|
||||
## Forums-Anträge
|
||||
@@ -178,32 +178,32 @@ Während der GExtension-Abgleich die **Käufe** liefert, steht der **Verbrauch**
|
||||
ein Antrag angenommen, abgelehnt, war es ein neuer CC oder ein Redesign? Unter *Anträge* liest
|
||||
die App die drei CC-Boards aus und sammelt die Threads.
|
||||
|
||||
**Entschieden wird ausschließlich von Hand.** Titel und Board werden bewusst nicht ausgewertet —
|
||||
**Entschieden wird ausschließlich von Hand.** Titel und Board werden bewusst nicht ausgewertet -
|
||||
Threads werden nicht immer zuverlässig verschoben, ein längst genehmigter Antrag kann noch im
|
||||
Antrags-Board liegen. Das aktuelle Board steht deshalb nur als Hinweis an der Zeile.
|
||||
|
||||
Pro Antrag wählt ein Admin: *angenommen als neuer CC*, *angenommen als Redesign* (mit Auswahl,
|
||||
welcher CC ersetzt wurde und woraus bezahlt wird), *angenommen als Reaktivierung* (ein inaktiver
|
||||
CC kehrt zurück — gratis, sofern er nicht redesignt wurde), *abgelehnt* oder *kein Antrag*. Die beiden
|
||||
angenommenen Fälle lösen exakt dieselben Buchungen aus wie die Knöpfe auf der Spielerseite —
|
||||
CC kehrt zurück - gratis, sofern er nicht redesignt wurde), *abgelehnt* oder *kein Antrag*. Die beiden
|
||||
angenommenen Fälle lösen exakt dieselben Buchungen aus wie die Knöpfe auf der Spielerseite -
|
||||
es gibt keinen zweiten Buchungsweg mit eigenen Regeln.
|
||||
|
||||
Zugeordnet wird über den Forumsnamen des Erstellers gegen das Feld *Forum-Name* beim Spieler —
|
||||
Zugeordnet wird über den Forumsnamen des Erstellers gegen das Feld *Forum-Name* beim Spieler -
|
||||
und über die **früheren Konten**, die auf der Spielerseite nachgetragen werden können. Wer seinen
|
||||
Steam- oder Forumsaccount gewechselt hat, behält so seine Ansprüche.
|
||||
**Threads ohne zuordenbaren Spieler werden gar nicht erst erfasst** — das Archiv reicht Jahre
|
||||
**Threads ohne zuordenbaren Spieler werden gar nicht erst erfasst** - das Archiv reicht Jahre
|
||||
zurück und enthält vieles, was kein CC-Antrag ist. Fehlt jemand, trag seinen Forumsnamen beim
|
||||
Spieler nach, dann nimmt ihn der nächste Abgleich auf.
|
||||
|
||||
Angepinnte Ankündigungen (Changelogs, Regelwerke) liegen mit in den Boards. Sie werden über ihre
|
||||
Labels *Wichtig* und *Information* aussortiert. Der Titel wird dagegen **nie** ausgewertet: CC-Anträge
|
||||
heißen mal „CC Antrag | Name", mal „\"The Duke\" Rang Update" — daran ist nichts zu erkennen.
|
||||
heißen mal „CC Antrag | Name", mal „\"The Duke\" Rang Update" - daran ist nichts zu erkennen.
|
||||
|
||||
Trägst du einen Antrag von Hand ein — über *Neuer CC* oder *Redesign* auf der Spielerseite —
|
||||
Trägst du einen Antrag von Hand ein - über *Neuer CC* oder *Redesign* auf der Spielerseite -
|
||||
kannst du dort den Thread-Link angeben. Der Antrag gilt damit als erledigt und taucht beim
|
||||
nächsten Abgleich nicht erneut auf.
|
||||
|
||||
Ein Thread behält seine ID, auch wenn er zwischen den Boards verschoben wird — daran hängt die
|
||||
Ein Thread behält seine ID, auch wenn er zwischen den Boards verschoben wird - daran hängt die
|
||||
Wiedererkennung. Wandert ein **bereits entschiedener** Antrag später in ein anderes Board, etwa
|
||||
ins Archiv, erscheint das oben als Hinweis. Es ändert nie automatisch den CC-Status: dafür ist
|
||||
das Signal zu unzuverlässig.
|
||||
@@ -212,16 +212,16 @@ das Signal zu unzuverlässig.
|
||||
|
||||
Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an.
|
||||
|
||||
- **Viewer** — darf nur lesen.
|
||||
- **Editor** — Spieler anlegen und bearbeiten, gekaufte Pakete eintragen, CCs erstellen,
|
||||
- **Viewer** - darf nur lesen.
|
||||
- **Editor** - Spieler anlegen und bearbeiten, gekaufte Pakete eintragen, CCs erstellen,
|
||||
redesignen und reaktivieren, Datei-Import, GExtension- und Forums-Abgleich samt Entscheidung
|
||||
über Anträge.
|
||||
- **Admin** — zusätzlich Kulanz vergeben, Korrekturbuchungen, unklare Pakete auflösen, Spieler
|
||||
- **Admin** - zusätzlich Kulanz vergeben, Korrekturbuchungen, unklare Pakete auflösen, Spieler
|
||||
löschen, Nutzerverwaltung, Produkt-Mappings und Audit-Log.
|
||||
|
||||
Die Grenze verläuft zwischen *erfassen* und *gewähren*: Wer einen Kauf einträgt, hält fest, was
|
||||
ohnehin passiert ist. Kulanz dagegen ist eine Entscheidung des Teams und bleibt Administratoren
|
||||
vorbehalten — ebenso das Auflösen unklarer Pakete, weil dabei Kulanz vergeben werden kann.
|
||||
vorbehalten - ebenso das Auflösen unklarer Pakete, weil dabei Kulanz vergeben werden kann.
|
||||
|
||||
Jede ändernde Aktion landet im Audit-Log. Für Administratoren lässt sich unter *Mein Konto*
|
||||
eine Zwei-Faktor-Anmeldung per TOTP einrichten.
|
||||
@@ -240,7 +240,7 @@ npm run dev # API auf :3000, Frontend auf :5173
|
||||
Das Frontend leitet `/api` im Entwicklungsbetrieb an den API-Port weiter, Anmeldung und Cookies
|
||||
funktionieren also wie in Produktion.
|
||||
|
||||
Die Tests brauchen **keine** laufende Datenbank — sie starten eine Postgres-Instanz im Speicher:
|
||||
Die Tests brauchen **keine** laufende Datenbank - sie starten eine Postgres-Instanz im Speicher:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
@@ -273,7 +273,7 @@ Nach Änderungen am Datenmodell:
|
||||
npm run migrate:dev -- --name kurze-beschreibung
|
||||
```
|
||||
|
||||
Der Name wird zum Verzeichnisnamen der Migration — dort also besser bei ASCII bleiben.
|
||||
Der Name wird zum Verzeichnisnamen der Migration - dort also besser bei ASCII bleiben.
|
||||
|
||||
## Sicherheit
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function AliasBox({
|
||||
{aliases.length === 0 ? (
|
||||
<p className="muted small">
|
||||
Keine hinterlegt. Hat der Spieler früher einen anderen Steam- oder Forumsaccount
|
||||
genutzt, trag ihn hier nach — Abgleich und Import finden ihn dann mit.
|
||||
genutzt, trag ihn hier nach - Abgleich und Import finden ihn dann mit.
|
||||
</p>
|
||||
) : (
|
||||
<div className="alias-groups">
|
||||
|
||||
@@ -39,9 +39,9 @@ const dateFormat = new Intl.DateTimeFormat('de-DE', {
|
||||
});
|
||||
|
||||
export function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '—';
|
||||
if (!value) return '-';
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? '—' : dateFormat.format(parsed);
|
||||
return Number.isNaN(parsed.getTime()) ? '-' : dateFormat.format(parsed);
|
||||
}
|
||||
|
||||
export function formatAmount(amount: number): string {
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function Account() {
|
||||
|
||||
{user?.mustChangePassword && (
|
||||
<Notice kind="warn">
|
||||
Dieses Konto verwendet noch ein vergebenes Erstpasswort. Bitte jetzt ändern — die
|
||||
Dieses Konto verwendet noch ein vergebenes Erstpasswort. Bitte jetzt ändern - die
|
||||
übrigen Seiten sind bis dahin gesperrt.
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
@@ -290,9 +290,9 @@ function CreateUserModal({
|
||||
value={role}
|
||||
onChange={(event) => setRole(event.target.value as Role)}
|
||||
>
|
||||
<option value="VIEWER">Viewer — nur lesen</option>
|
||||
<option value="EDITOR">Editor — Spieler, Guthaben, CCs</option>
|
||||
<option value="ADMIN">Admin — alles inkl. Import und Nutzer</option>
|
||||
<option value="VIEWER">Viewer - nur lesen</option>
|
||||
<option value="EDITOR">Editor - Spieler, Guthaben, CCs</option>
|
||||
<option value="ADMIN">Admin - alles inkl. Import und Nutzer</option>
|
||||
</select>
|
||||
|
||||
<div className="form-actions">
|
||||
@@ -475,14 +475,14 @@ function AuditTab({ onError }: { onError: (message: string) => void }) {
|
||||
{items.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td className="muted small">{formatDate(entry.createdAt)}</td>
|
||||
<td>{entry.actor?.username ?? entry.actorName ?? '—'}</td>
|
||||
<td>{entry.actor?.username ?? entry.actorName ?? '-'}</td>
|
||||
<td className="mono">{entry.action}</td>
|
||||
<td className="muted">
|
||||
{entry.targetType}
|
||||
{entry.targetId ? ` #${entry.targetId}` : ''}
|
||||
</td>
|
||||
<td className="muted small details">
|
||||
{entry.details ? JSON.stringify(entry.details) : '—'}
|
||||
{entry.details ? JSON.stringify(entry.details) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function Dashboard() {
|
||||
</Link>
|
||||
{player.forumName && <span className="muted"> · {player.forumName}</span>}
|
||||
</td>
|
||||
<td className="mono muted">{player.steamId ?? '—'}</td>
|
||||
<td className="mono muted">{player.steamId ?? '-'}</td>
|
||||
<td className="num">
|
||||
<Counter value={player.balances.CC} title="CC-Guthaben" />
|
||||
</td>
|
||||
@@ -194,7 +194,7 @@ export default function Dashboard() {
|
||||
{loading && <p className="muted">Lade …</p>}
|
||||
{!loading && result.total > result.items.length && (
|
||||
<p className="muted">
|
||||
{result.items.length} von {result.total} angezeigt — Suche eingrenzen.
|
||||
{result.items.length} von {result.total} angezeigt - Suche eingrenzen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function Forum() {
|
||||
<div>
|
||||
<h1>Forums-Anträge</h1>
|
||||
<p className="muted">
|
||||
Jeder Antrag wird von Hand entschieden — Titel und Board sind kein verlässliches
|
||||
Jeder Antrag wird von Hand entschieden - Titel und Board sind kein verlässliches
|
||||
Signal und lösen nie eine Buchung aus. Aufgenommen werden nur Threads von Spielern,
|
||||
die hier mit passendem Forum-Namen erfasst sind.
|
||||
</p>
|
||||
@@ -103,7 +103,7 @@ export default function Forum() {
|
||||
<h2>Verschoben nach der Entscheidung ({notices.length})</h2>
|
||||
<Notice kind="warn">
|
||||
Diese Anträge wurden bereits entschieden, ihr Thread liegt inzwischen aber in einem
|
||||
anderen Board. Das ist nur ein Hinweis — geprüft und geklickt wird von Hand.
|
||||
anderen Board. Das ist nur ein Hinweis - geprüft und geklickt wird von Hand.
|
||||
</Notice>
|
||||
|
||||
<ul className="cc-list">
|
||||
@@ -312,7 +312,7 @@ function ClassifyModal({
|
||||
/>
|
||||
<span>
|
||||
<strong>{label}</strong>
|
||||
<span className="muted"> — {hint}</span>
|
||||
<span className="muted"> - {hint}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
@@ -333,7 +333,7 @@ function ClassifyModal({
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="muted small">Vorbelegt mit dem Thread-Titel — bitte anpassen.</p>
|
||||
<p className="muted small">Vorbelegt mit dem Thread-Titel - bitte anpassen.</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -346,11 +346,11 @@ function ClassifyModal({
|
||||
onChange={(event) => setReviveId(Number(event.target.value) || null)}
|
||||
required
|
||||
>
|
||||
<option value="">— bitte wählen —</option>
|
||||
<option value="">- bitte wählen -</option>
|
||||
{inactive.map((character) => (
|
||||
<option key={character.id} value={character.id}>
|
||||
{character.name}
|
||||
{character.inactiveReason === 'REWORKED' ? ' (redesignt — kostet)' : ' (gratis)'}
|
||||
{character.inactiveReason === 'REWORKED' ? ' (redesignt - kostet)' : ' (gratis)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -360,7 +360,7 @@ function ClassifyModal({
|
||||
|
||||
{gewaehlterCC && !reaktivierungKostet && (
|
||||
<Notice kind="success">
|
||||
Kommt gratis zurück — der CC existiert unverändert weiter.
|
||||
Kommt gratis zurück - der CC existiert unverändert weiter.
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
@@ -405,7 +405,7 @@ function ClassifyModal({
|
||||
onChange={(event) => setPredecessorId(Number(event.target.value) || null)}
|
||||
required
|
||||
>
|
||||
<option value="">— bitte wählen —</option>
|
||||
<option value="">- bitte wählen -</option>
|
||||
{characters.map((character) => (
|
||||
<option key={character.id} value={character.id}>
|
||||
{character.name}
|
||||
@@ -500,7 +500,7 @@ function AssignModal({
|
||||
<Modal title="Spieler zuordnen" onClose={onClose}>
|
||||
<Notice kind="info">
|
||||
Forumsname des Antragstellers: <strong>{thread.authorName ?? 'unbekannt'}</strong>. Passt
|
||||
kein Eintrag, trag den Forumsnamen beim Spieler nach — dann findet der nächste Abgleich
|
||||
kein Eintrag, trag den Forumsnamen beim Spieler nach - dann findet der nächste Abgleich
|
||||
ihn von allein.
|
||||
</Notice>
|
||||
|
||||
|
||||
@@ -200,7 +200,7 @@ export default function Import() {
|
||||
<h2>Wartet auf Bestätigung ({open.length})</h2>
|
||||
<Notice kind="warn">
|
||||
Diese Importe sind angelegt, aber noch nicht gebucht. Der GExtension-Abgleich landet
|
||||
ebenfalls hier — erst nach dem Bestätigen entstehen Guthaben.
|
||||
ebenfalls hier - erst nach dem Bestätigen entstehen Guthaben.
|
||||
</Notice>
|
||||
|
||||
<ul className="cc-list">
|
||||
@@ -234,7 +234,7 @@ export default function Import() {
|
||||
<section className="block">
|
||||
<Notice kind="info">
|
||||
CSV oder JSON hochladen. Danach werden die Spalten zugeordnet und eine Vorschau
|
||||
gezeigt — gebucht wird erst nach Bestätigung. Bereits importierte Bestell-IDs
|
||||
gezeigt - gebucht wird erst nach Bestätigung. Bereits importierte Bestell-IDs
|
||||
werden automatisch übersprungen.
|
||||
</Notice>
|
||||
|
||||
@@ -396,13 +396,13 @@ export default function Import() {
|
||||
<tr key={row.id} className={row.plan.startsWith('SKIP') ? 'is-skipped' : undefined}>
|
||||
<td className="muted">{row.lineNo}</td>
|
||||
<td>
|
||||
{row.playerName || row.playerKey || '—'}
|
||||
{row.playerName || row.playerKey || '-'}
|
||||
{row.resolvedPlayerId === null && row.plan === 'CREATE_PLAYER_AND_BOOK' && (
|
||||
<span className="badge badge-new">neu</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="muted">{row.productName ?? '—'}</td>
|
||||
<td className="mono muted">{row.externalId ?? '—'}</td>
|
||||
<td className="muted">{row.productName ?? '-'}</td>
|
||||
<td className="mono muted">{row.externalId ?? '-'}</td>
|
||||
<td className="muted">{formatDate(row.purchasedAt)}</td>
|
||||
<td className={row.plan.startsWith('SKIP') ? 'muted' : 'pos'}>
|
||||
{PLAN_LABEL[row.plan]}
|
||||
@@ -452,7 +452,7 @@ function MappingSelect({
|
||||
<div className="field">
|
||||
<label>{label}</label>
|
||||
<select value={value} onChange={(event) => onChange(event.target.value)} required={required}>
|
||||
<option value="">— nicht zuordnen —</option>
|
||||
<option value="">- nicht zuordnen -</option>
|
||||
{columns.map((column) => (
|
||||
<option key={column} value={column}>
|
||||
{column}
|
||||
|
||||
@@ -95,9 +95,9 @@ export default function PlayerDetail() {
|
||||
'bereits unter Import auf Bestätigung.',
|
||||
);
|
||||
} else if (result.unclear > 0) {
|
||||
setFlash(`${result.unclear} unklare Einträge — siehe Kasten oben.`);
|
||||
setFlash(`${result.unclear} unklare Einträge - siehe Kasten oben.`);
|
||||
} else if (result.duplicates > 0) {
|
||||
setFlash('Nichts Neues — alle Pakete sind bereits gebucht.');
|
||||
setFlash('Nichts Neues - alle Pakete sind bereits gebucht.');
|
||||
} else if (result.failedPlayers.length > 0) {
|
||||
setError(`Profil nicht abrufbar: ${result.failedPlayers[0]?.reason ?? 'unbekannt'}`);
|
||||
} else {
|
||||
@@ -165,7 +165,7 @@ export default function PlayerDetail() {
|
||||
GExtension meldet <strong>{detail.player.gextensionUsage}</strong> Mal verbraucht
|
||||
{detail.player.gextensionSyncedAt &&
|
||||
` (Stand ${formatDate(detail.player.gextensionSyncedAt)})`}
|
||||
. Nur zum Abgleich — wird nicht automatisch verrechnet.
|
||||
. Nur zum Abgleich - wird nicht automatisch verrechnet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -302,7 +302,7 @@ export default function PlayerDetail() {
|
||||
)}
|
||||
<p className="muted small">
|
||||
{free
|
||||
? 'Kommt gratis zurück — kostet kein Guthaben.'
|
||||
? 'Kommt gratis zurück - kostet kein Guthaben.'
|
||||
: 'Reaktivierung kostet ein Redesign- oder CC-Guthaben.'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -320,7 +320,7 @@ export default function PlayerDetail() {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
}),
|
||||
`"${character.name}" ist wieder aktiv — kostenlos.`,
|
||||
`"${character.name}" ist wieder aktiv - kostenlos.`,
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -376,7 +376,7 @@ export default function PlayerDetail() {
|
||||
</td>
|
||||
<td>{SOURCE_LABEL[entry.source]}</td>
|
||||
<td className="muted small">
|
||||
{entry.note ?? '—'}
|
||||
{entry.note ?? '-'}
|
||||
{entry.character && ` (${entry.character.name})`}
|
||||
</td>
|
||||
<td className="muted small">{entry.createdBy?.username ?? 'System'}</td>
|
||||
@@ -428,7 +428,7 @@ export default function PlayerDetail() {
|
||||
method: 'POST',
|
||||
body: values,
|
||||
}),
|
||||
`CC "${values.name}" angelegt — 1 CC-Guthaben verbraucht.`,
|
||||
`CC "${values.name}" angelegt - 1 CC-Guthaben verbraucht.`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -870,7 +870,7 @@ function DeactivateModal({
|
||||
>
|
||||
<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.
|
||||
Redesign macht ihn kostenpflichtig - das läuft aber über die Redesign-Aktion.
|
||||
</Notice>
|
||||
|
||||
<div className="radio-group">
|
||||
@@ -883,7 +883,7 @@ function DeactivateModal({
|
||||
/>
|
||||
<span>
|
||||
<strong>Spieler-Inaktivität</strong>
|
||||
<span className="muted"> — vom Server geputzt, weil der Spieler länger weg war</span>
|
||||
<span className="muted"> - vom Server geputzt, weil der Spieler länger weg war</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="radio">
|
||||
@@ -895,7 +895,7 @@ function DeactivateModal({
|
||||
/>
|
||||
<span>
|
||||
<strong>Anderer Grund</strong>
|
||||
<span className="muted"> — etwa eine vorübergehend entfernte Einheit</span>
|
||||
<span className="muted"> - etwa eine vorübergehend entfernte Einheit</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -1083,7 +1083,7 @@ function DeletePlayerModal({
|
||||
{characterCount === 1 ? 'CC' : 'CCs'} und <strong>{ledgerCount}</strong>{' '}
|
||||
{ledgerCount === 1 ? 'Buchung' : 'Buchungen'} mit. Auch Spuren in Importen und in den
|
||||
Details früherer Protokolleinträge werden entfernt. Das lässt sich nicht rückgängig
|
||||
machen — für eine Fehleingabe ist eher das Bearbeiten gedacht.
|
||||
machen - für eine Fehleingabe ist eher das Bearbeiten gedacht.
|
||||
</Notice>
|
||||
|
||||
<label htmlFor="confirm-name">
|
||||
|
||||
Reference in New Issue
Block a user