This commit is contained in:
2026-09-18 21:32:58 +02:00
parent 6c50d32d2b
commit 3a55ef1b2f
9 changed files with 68 additions and 68 deletions
+31 -31
View File
@@ -17,14 +17,14 @@ Jeder Spieler hat drei Guthaben-Töpfe:
Zwei Regeln sind fest verdrahtet: Zwei Regeln sind fest verdrahtet:
1. **Ein CC-Paket erzeugt beide Guthaben.** Der inklusive Redesign wird automatisch mitgebucht, 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 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 Wurde er dagegen redesignt, existiert das Original nicht mehr und die Reaktivierung kostet ein
Redesign- oder CC-Guthaben. Redesign- oder CC-Guthaben.
Guthaben werden als **Kontobuch** geführt: jede Bewegung ist eine eigene Zeile, der Stand eines 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 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. 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. 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 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 ## Schnellstart
@@ -59,7 +59,7 @@ docker compose logs -f app
``` ```
Beim ersten Start legt die App einen Administrator an. Ist `BOOTSTRAP_ADMIN_PASSWORD` leer, 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 Ausgabe oben. Beim ersten Login ist ein Passwortwechsel Pflicht; bis dahin sind alle anderen
Seiten gesperrt. 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. 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. 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`: 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 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 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. anderweitig davorsetzen.
> **Zu `$` in der `.env`:** Docker Compose deutet `$name` als Variable und ersetzt es durch > **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 > 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. > 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` 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. 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 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 `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: 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: 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 2. Spalten zuordnen (Spieler-Kennung, Produktname, optional Bestell-ID und Datum). Die üblichen
Spaltennamen werden vorausgewählt. Spaltennamen werden vorausgewählt.
3. **Vorschau** prüfen: für jede Zeile steht da, was passieren würde. 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 Produkt-Mappings* (Teiltreffer, Groß-/Kleinschreibung egal). Neue Produkte lassen sich dort
jederzeit ergänzen, ohne den Code anzufassen. 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. 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. 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 > **Auf eine bestimmte Instanz zugeschnitten.** Dieser Teil liest Profile von
> `gextension.senseigaming.de` und ist auf deren Seitenaufbau gebaut. Läuft bei euch eine > `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 > 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 > `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 > läuft auch ohne diesen Teil; Pakete lassen sich immer von Hand oder per Datei-Import
> eintragen. > 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 **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 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. Admin.
**Was er nicht tut:** Er sucht keine neuen Spieler. Wer nicht in der Datenbank steht, wird nie **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 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. 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 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. automatisch auch das inklusive Redesign-Guthaben.
**Verbrauchszähler:** Meldet GExtension „CC-Paket oder Redesign genutzt | N Mal", wird das beim **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. dem eigenen Ledger, mehr nicht.
Der Abgleich hält eine Sekunde Abstand zwischen den Anfragen und weist sich per User-Agent aus. 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. sauberere Weg, falls er zu haben ist.
## Forums-Anträge ## 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 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. 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 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. 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, 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 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 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 — angenommenen Fälle lösen exakt dieselben Buchungen aus wie die Knöpfe auf der Spielerseite -
es gibt keinen zweiten Buchungsweg mit eigenen Regeln. 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 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. 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 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. Spieler nach, dann nimmt ihn der nächste Abgleich auf.
Angepinnte Ankündigungen (Changelogs, Regelwerke) liegen mit in den Boards. Sie werden über ihre 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 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 kannst du dort den Thread-Link angeben. Der Antrag gilt damit als erledigt und taucht beim
nächsten Abgleich nicht erneut auf. 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 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 ins Archiv, erscheint das oben als Hinweis. Es ändert nie automatisch den CC-Status: dafür ist
das Signal zu unzuverlässig. 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. Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an.
- **Viewer** — darf nur lesen. - **Viewer** - darf nur lesen.
- **Editor** — Spieler anlegen und bearbeiten, gekaufte Pakete eintragen, CCs erstellen, - **Editor** - Spieler anlegen und bearbeiten, gekaufte Pakete eintragen, CCs erstellen,
redesignen und reaktivieren, Datei-Import, GExtension- und Forums-Abgleich samt Entscheidung redesignen und reaktivieren, Datei-Import, GExtension- und Forums-Abgleich samt Entscheidung
über Anträge. ü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. 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 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 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* Jede ändernde Aktion landet im Audit-Log. Für Administratoren lässt sich unter *Mein Konto*
eine Zwei-Faktor-Anmeldung per TOTP einrichten. 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 Das Frontend leitet `/api` im Entwicklungsbetrieb an den API-Port weiter, Anmeldung und Cookies
funktionieren also wie in Produktion. 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 ```bash
npm test npm test
@@ -273,7 +273,7 @@ Nach Änderungen am Datenmodell:
npm run migrate:dev -- --name kurze-beschreibung 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 ## Sicherheit
+1 -1
View File
@@ -76,7 +76,7 @@ export default function AliasBox({
{aliases.length === 0 ? ( {aliases.length === 0 ? (
<p className="muted small"> <p className="muted small">
Keine hinterlegt. Hat der Spieler früher einen anderen Steam- oder Forumsaccount 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> </p>
) : ( ) : (
<div className="alias-groups"> <div className="alias-groups">
+2 -2
View File
@@ -39,9 +39,9 @@ const dateFormat = new Intl.DateTimeFormat('de-DE', {
}); });
export function formatDate(value: string | null | undefined): string { export function formatDate(value: string | null | undefined): string {
if (!value) return '—'; if (!value) return '-';
const parsed = new Date(value); 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 { export function formatAmount(amount: number): string {
+1 -1
View File
@@ -97,7 +97,7 @@ export default function Account() {
{user?.mustChangePassword && ( {user?.mustChangePassword && (
<Notice kind="warn"> <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. übrigen Seiten sind bis dahin gesperrt.
</Notice> </Notice>
)} )}
+5 -5
View File
@@ -290,9 +290,9 @@ function CreateUserModal({
value={role} value={role}
onChange={(event) => setRole(event.target.value as Role)} onChange={(event) => setRole(event.target.value as Role)}
> >
<option value="VIEWER">Viewer — nur lesen</option> <option value="VIEWER">Viewer - nur lesen</option>
<option value="EDITOR">Editor — Spieler, Guthaben, CCs</option> <option value="EDITOR">Editor - Spieler, Guthaben, CCs</option>
<option value="ADMIN">Admin — alles inkl. Import und Nutzer</option> <option value="ADMIN">Admin - alles inkl. Import und Nutzer</option>
</select> </select>
<div className="form-actions"> <div className="form-actions">
@@ -475,14 +475,14 @@ function AuditTab({ onError }: { onError: (message: string) => void }) {
{items.map((entry) => ( {items.map((entry) => (
<tr key={entry.id}> <tr key={entry.id}>
<td className="muted small">{formatDate(entry.createdAt)}</td> <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="mono">{entry.action}</td>
<td className="muted"> <td className="muted">
{entry.targetType} {entry.targetType}
{entry.targetId ? ` #${entry.targetId}` : ''} {entry.targetId ? ` #${entry.targetId}` : ''}
</td> </td>
<td className="muted small details"> <td className="muted small details">
{entry.details ? JSON.stringify(entry.details) : '—'} {entry.details ? JSON.stringify(entry.details) : '-'}
</td> </td>
</tr> </tr>
))} ))}
+2 -2
View File
@@ -164,7 +164,7 @@ export default function Dashboard() {
</Link> </Link>
{player.forumName && <span className="muted"> · {player.forumName}</span>} {player.forumName && <span className="muted"> · {player.forumName}</span>}
</td> </td>
<td className="mono muted">{player.steamId ?? '—'}</td> <td className="mono muted">{player.steamId ?? '-'}</td>
<td className="num"> <td className="num">
<Counter value={player.balances.CC} title="CC-Guthaben" /> <Counter value={player.balances.CC} title="CC-Guthaben" />
</td> </td>
@@ -194,7 +194,7 @@ export default function Dashboard() {
{loading && <p className="muted">Lade …</p>} {loading && <p className="muted">Lade …</p>}
{!loading && result.total > result.items.length && ( {!loading && result.total > result.items.length && (
<p className="muted"> <p className="muted">
{result.items.length} von {result.total} angezeigt — Suche eingrenzen. {result.items.length} von {result.total} angezeigt - Suche eingrenzen.
</p> </p>
)} )}
+9 -9
View File
@@ -83,7 +83,7 @@ export default function Forum() {
<div> <div>
<h1>Forums-Anträge</h1> <h1>Forums-Anträge</h1>
<p className="muted"> <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, Signal und lösen nie eine Buchung aus. Aufgenommen werden nur Threads von Spielern,
die hier mit passendem Forum-Namen erfasst sind. die hier mit passendem Forum-Namen erfasst sind.
</p> </p>
@@ -103,7 +103,7 @@ export default function Forum() {
<h2>Verschoben nach der Entscheidung ({notices.length})</h2> <h2>Verschoben nach der Entscheidung ({notices.length})</h2>
<Notice kind="warn"> <Notice kind="warn">
Diese Anträge wurden bereits entschieden, ihr Thread liegt inzwischen aber in einem 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> </Notice>
<ul className="cc-list"> <ul className="cc-list">
@@ -312,7 +312,7 @@ function ClassifyModal({
/> />
<span> <span>
<strong>{label}</strong> <strong>{label}</strong>
<span className="muted"> — {hint}</span> <span className="muted"> - {hint}</span>
</span> </span>
</label> </label>
))} ))}
@@ -333,7 +333,7 @@ function ClassifyModal({
onChange={(event) => setName(event.target.value)} onChange={(event) => setName(event.target.value)}
required 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)} onChange={(event) => setReviveId(Number(event.target.value) || null)}
required required
> >
<option value="">— bitte wählen —</option> <option value="">- bitte wählen -</option>
{inactive.map((character) => ( {inactive.map((character) => (
<option key={character.id} value={character.id}> <option key={character.id} value={character.id}>
{character.name} {character.name}
{character.inactiveReason === 'REWORKED' ? ' (redesignt — kostet)' : ' (gratis)'} {character.inactiveReason === 'REWORKED' ? ' (redesignt - kostet)' : ' (gratis)'}
</option> </option>
))} ))}
</select> </select>
@@ -360,7 +360,7 @@ function ClassifyModal({
{gewaehlterCC && !reaktivierungKostet && ( {gewaehlterCC && !reaktivierungKostet && (
<Notice kind="success"> <Notice kind="success">
Kommt gratis zurück — der CC existiert unverändert weiter. Kommt gratis zurück - der CC existiert unverändert weiter.
</Notice> </Notice>
)} )}
@@ -405,7 +405,7 @@ function ClassifyModal({
onChange={(event) => setPredecessorId(Number(event.target.value) || null)} onChange={(event) => setPredecessorId(Number(event.target.value) || null)}
required required
> >
<option value="">— bitte wählen —</option> <option value="">- bitte wählen -</option>
{characters.map((character) => ( {characters.map((character) => (
<option key={character.id} value={character.id}> <option key={character.id} value={character.id}>
{character.name} {character.name}
@@ -500,7 +500,7 @@ function AssignModal({
<Modal title="Spieler zuordnen" onClose={onClose}> <Modal title="Spieler zuordnen" onClose={onClose}>
<Notice kind="info"> <Notice kind="info">
Forumsname des Antragstellers: <strong>{thread.authorName ?? 'unbekannt'}</strong>. Passt 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. ihn von allein.
</Notice> </Notice>
+6 -6
View File
@@ -200,7 +200,7 @@ export default function Import() {
<h2>Wartet auf Bestätigung ({open.length})</h2> <h2>Wartet auf Bestätigung ({open.length})</h2>
<Notice kind="warn"> <Notice kind="warn">
Diese Importe sind angelegt, aber noch nicht gebucht. Der GExtension-Abgleich landet 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> </Notice>
<ul className="cc-list"> <ul className="cc-list">
@@ -234,7 +234,7 @@ export default function Import() {
<section className="block"> <section className="block">
<Notice kind="info"> <Notice kind="info">
CSV oder JSON hochladen. Danach werden die Spalten zugeordnet und eine Vorschau 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. werden automatisch übersprungen.
</Notice> </Notice>
@@ -396,13 +396,13 @@ export default function Import() {
<tr key={row.id} className={row.plan.startsWith('SKIP') ? 'is-skipped' : undefined}> <tr key={row.id} className={row.plan.startsWith('SKIP') ? 'is-skipped' : undefined}>
<td className="muted">{row.lineNo}</td> <td className="muted">{row.lineNo}</td>
<td> <td>
{row.playerName || row.playerKey || '—'} {row.playerName || row.playerKey || '-'}
{row.resolvedPlayerId === null && row.plan === 'CREATE_PLAYER_AND_BOOK' && ( {row.resolvedPlayerId === null && row.plan === 'CREATE_PLAYER_AND_BOOK' && (
<span className="badge badge-new">neu</span> <span className="badge badge-new">neu</span>
)} )}
</td> </td>
<td className="muted">{row.productName ?? '—'}</td> <td className="muted">{row.productName ?? '-'}</td>
<td className="mono muted">{row.externalId ?? '—'}</td> <td className="mono muted">{row.externalId ?? '-'}</td>
<td className="muted">{formatDate(row.purchasedAt)}</td> <td className="muted">{formatDate(row.purchasedAt)}</td>
<td className={row.plan.startsWith('SKIP') ? 'muted' : 'pos'}> <td className={row.plan.startsWith('SKIP') ? 'muted' : 'pos'}>
{PLAN_LABEL[row.plan]} {PLAN_LABEL[row.plan]}
@@ -452,7 +452,7 @@ function MappingSelect({
<div className="field"> <div className="field">
<label>{label}</label> <label>{label}</label>
<select value={value} onChange={(event) => onChange(event.target.value)} required={required}> <select value={value} onChange={(event) => onChange(event.target.value)} required={required}>
<option value="">— nicht zuordnen —</option> <option value="">- nicht zuordnen -</option>
{columns.map((column) => ( {columns.map((column) => (
<option key={column} value={column}> <option key={column} value={column}>
{column} {column}
+11 -11
View File
@@ -95,9 +95,9 @@ export default function PlayerDetail() {
'bereits unter Import auf Bestätigung.', 'bereits unter Import auf Bestätigung.',
); );
} else if (result.unclear > 0) { } 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) { } 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) { } else if (result.failedPlayers.length > 0) {
setError(`Profil nicht abrufbar: ${result.failedPlayers[0]?.reason ?? 'unbekannt'}`); setError(`Profil nicht abrufbar: ${result.failedPlayers[0]?.reason ?? 'unbekannt'}`);
} else { } else {
@@ -165,7 +165,7 @@ export default function PlayerDetail() {
GExtension meldet <strong>{detail.player.gextensionUsage}</strong> Mal verbraucht GExtension meldet <strong>{detail.player.gextensionUsage}</strong> Mal verbraucht
{detail.player.gextensionSyncedAt && {detail.player.gextensionSyncedAt &&
` (Stand ${formatDate(detail.player.gextensionSyncedAt)})`} ` (Stand ${formatDate(detail.player.gextensionSyncedAt)})`}
. Nur zum Abgleich — wird nicht automatisch verrechnet. . Nur zum Abgleich - wird nicht automatisch verrechnet.
</p> </p>
)} )}
@@ -302,7 +302,7 @@ export default function PlayerDetail() {
)} )}
<p className="muted small"> <p className="muted small">
{free {free
? 'Kommt gratis zurück — kostet kein Guthaben.' ? 'Kommt gratis zurück - kostet kein Guthaben.'
: 'Reaktivierung kostet ein Redesign- oder CC-Guthaben.'} : 'Reaktivierung kostet ein Redesign- oder CC-Guthaben.'}
</p> </p>
</div> </div>
@@ -320,7 +320,7 @@ export default function PlayerDetail() {
method: 'POST', method: 'POST',
body: {}, body: {},
}), }),
`"${character.name}" ist wieder aktiv — kostenlos.`, `"${character.name}" ist wieder aktiv - kostenlos.`,
) )
} }
> >
@@ -376,7 +376,7 @@ export default function PlayerDetail() {
</td> </td>
<td>{SOURCE_LABEL[entry.source]}</td> <td>{SOURCE_LABEL[entry.source]}</td>
<td className="muted small"> <td className="muted small">
{entry.note ?? '—'} {entry.note ?? '-'}
{entry.character && ` (${entry.character.name})`} {entry.character && ` (${entry.character.name})`}
</td> </td>
<td className="muted small">{entry.createdBy?.username ?? 'System'}</td> <td className="muted small">{entry.createdBy?.username ?? 'System'}</td>
@@ -428,7 +428,7 @@ export default function PlayerDetail() {
method: 'POST', method: 'POST',
body: values, 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"> <Notice kind="info">
Der CC bleibt unverändert und kommt später in beiden Fällen gratis zurück. Nur ein 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> </Notice>
<div className="radio-group"> <div className="radio-group">
@@ -883,7 +883,7 @@ function DeactivateModal({
/> />
<span> <span>
<strong>Spieler-Inaktivität</strong> <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> </span>
</label> </label>
<label className="radio"> <label className="radio">
@@ -895,7 +895,7 @@ function DeactivateModal({
/> />
<span> <span>
<strong>Anderer Grund</strong> <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> </span>
</label> </label>
</div> </div>
@@ -1083,7 +1083,7 @@ function DeletePlayerModal({
{characterCount === 1 ? 'CC' : 'CCs'} und <strong>{ledgerCount}</strong>{' '} {characterCount === 1 ? 'CC' : 'CCs'} und <strong>{ledgerCount}</strong>{' '}
{ledgerCount === 1 ? 'Buchung' : 'Buchungen'} mit. Auch Spuren in Importen und in den {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 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> </Notice>
<label htmlFor="confirm-name"> <label htmlFor="confirm-name">