Slots durchsetzen, Abgleiche nach Zeitplan, Kaufdatum im Kontobuch
Ein CC-Paket ist ein Platz fuer einen gleichzeitig aktiven CC - ein Redesign tauscht einen CC aus, statt einen Platz hinzuzufuegen. Beim Gruppen-Beitritt galt das seit dem letzten Stand, an anderer Stelle aber noch nicht: ein redesignter CC liess sich mit einem Redesign-Guthaben zurueckholen, waehrend sein Nachfolger aktiv blieb. Zwei aktive CCs auf ein einziges Paket. Geprueft wird das jetzt an einer Stelle statt an jedem Weg einzeln, fuer Erschaffen, Redesign, Reaktivierung, Rueckkehr und Beitritt. Der Massstab ist die Deckung, nicht der Kontostand: jeder aktive CC braucht eine eigene Buchung im CC-Topf. Ein bewusst ueberzogenes Konto bleibt damit moeglich - die Buchung ist da, nur das Guthaben fehlt, und das Minus steht sichtbar auf der Spielerseite. Beide Abgleiche laufen jetzt von allein: die Kaeufe alle zwei Stunden, bei Spielern ohne Kauf in den letzten drei Jahren alle sechs, das Forum einmal nachts. Das spart den Grossteil der Anfragen, denn ruhende Konten sind mit der Zeit die Mehrheit. Gebucht wird dabei nichts - neue Pakete landen in der Vorschau, neue Antraege in der Liste. Der Stand steht in der Datenbank, damit ein Neustart nicht alles erneut anstoesst. Unter Verwaltung steht, wann zuletzt gelaufen wurde; auf der Uebersicht, wie viele Pakete auf eine Buchung warten und bei wem. Ausserdem: - Die Guthaben-Historie zeigt bei Importen den Kauf im Shop, nicht den Zeitpunkt der Eingabe hier. Bereits gebuchte Importe werden nachgetragen. - Forums-Antraege tragen ihr Datum, im Forum ausgelesen statt geraten. - Die Hinweisbox ueber den Antraegen beim Spieler faellt weg. - Die Aktionsspalte der Nutzerverwaltung war per display:flex aus dem Tabellenlayout gefallen, daher die versetzte Trennlinie. - Die Zeitzone steht jetzt im Compose-Stack, sonst laege "nachts um vier" nach UTC und im Sommer zwei Stunden daneben.
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import Notice from './Notice';
|
||||
import { ClassifyModal } from './ThreadModals';
|
||||
import { BOARD_LABEL, CLASSIFICATION_LABEL, formatDate } from '../lib/format';
|
||||
import type { ForumThread } from '../lib/types';
|
||||
@@ -38,14 +37,6 @@ export default function ThreadsBox({
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{offen.length > 0 && (
|
||||
<Notice kind="warn">
|
||||
{offen.length} {offen.length === 1 ? 'Antrag wartet' : 'Anträge warten'} auf eine
|
||||
Entscheidung. Was hier entschieden wird, steht danach in der Anträge-Liste nicht
|
||||
mehr offen.
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
<ul className="cc-list">
|
||||
{threads.map((thread) => (
|
||||
<li
|
||||
@@ -69,7 +60,8 @@ export default function ThreadsBox({
|
||||
</span>
|
||||
)}
|
||||
<p className="muted small">
|
||||
von {thread.authorName ?? 'unbekannt'}
|
||||
{thread.postedAt ? formatDate(thread.postedAt) : 'ohne Datum'} · von{' '}
|
||||
{thread.authorName ?? 'unbekannt'}
|
||||
{thread.labels && ` · ${thread.labels}`}
|
||||
{thread.linkedCharacter && ` · CC: ${thread.linkedCharacter.name}`}
|
||||
{thread.classifiedAt && ` · entschieden ${formatDate(thread.classifiedAt)}`}
|
||||
|
||||
@@ -144,6 +144,8 @@ export interface LedgerEntry {
|
||||
characterId: number | null;
|
||||
character: CharacterRef | null;
|
||||
createdAt: string;
|
||||
/// Wann es tatsächlich passierte - beim Import das Kaufdatum aus dem Shop.
|
||||
occurredAt: string;
|
||||
createdBy: { username: string } | null;
|
||||
reversedBy: { id: number } | null;
|
||||
reversalOfId: number | null;
|
||||
@@ -259,6 +261,8 @@ export interface ForumThread {
|
||||
playerId: number | null;
|
||||
player: { id: number; displayName: string } | null;
|
||||
currentBoard: ForumBoard;
|
||||
/// Wann der Antrag im Forum gestellt wurde.
|
||||
postedAt: string | null;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
lastBoardChangeAt: string | null;
|
||||
@@ -322,3 +326,24 @@ export interface PendingRow {
|
||||
externalId: string | null;
|
||||
import: { id: number; filename: string; createdAt: string };
|
||||
}
|
||||
|
||||
/// Stand der wiederkehrenden Abgleiche.
|
||||
export interface ScheduleRun {
|
||||
name: string;
|
||||
lastRunAt: string | null;
|
||||
lastStatus: string | null;
|
||||
lastNote: string | null;
|
||||
}
|
||||
|
||||
export interface ScheduleState {
|
||||
enabled: boolean;
|
||||
forumHour: number;
|
||||
runs: ScheduleRun[];
|
||||
}
|
||||
|
||||
/// Pakete, die noch in einer offenen Import-Vorschau liegen.
|
||||
export interface PendingOverview {
|
||||
total: number;
|
||||
withoutPlayer: number;
|
||||
players: { playerId: number; displayName: string; count: number }[];
|
||||
}
|
||||
|
||||
+96
-2
@@ -4,9 +4,16 @@ import Notice from '../components/Notice';
|
||||
import { ApiError, api } from '../lib/api';
|
||||
import { formatDate } from '../lib/format';
|
||||
import { useSession } from '../lib/session';
|
||||
import type { AdminUser, AuditEntry, ProductMapping, ProductTarget, Role } from '../lib/types';
|
||||
import type {
|
||||
AdminUser,
|
||||
AuditEntry,
|
||||
ProductMapping,
|
||||
ProductTarget,
|
||||
Role,
|
||||
ScheduleState,
|
||||
} from '../lib/types';
|
||||
|
||||
type Tab = 'users' | 'mappings' | 'audit';
|
||||
type Tab = 'users' | 'mappings' | 'audit' | 'schedule';
|
||||
|
||||
const TARGET_LABEL: Record<ProductTarget, string> = {
|
||||
CC_PACKAGE: 'CC-Paket',
|
||||
@@ -52,6 +59,13 @@ export default function Admin() {
|
||||
>
|
||||
Audit-Log
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={tab === 'schedule' ? 'tab is-active' : 'tab'}
|
||||
onClick={() => setTab('schedule')}
|
||||
>
|
||||
Zeitplan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
||||
@@ -60,6 +74,7 @@ export default function Admin() {
|
||||
{tab === 'users' && <UsersTab onError={setError} onDone={notify} />}
|
||||
{tab === 'mappings' && <MappingsTab onError={setError} onDone={notify} />}
|
||||
{tab === 'audit' && <AuditTab onError={setError} />}
|
||||
{tab === 'schedule' && <ScheduleTab onError={setError} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -505,3 +520,82 @@ function AuditTab({ onError }: { onError: (message: string) => void }) {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Was die wiederkehrenden Abgleiche zuletzt getan haben. Sie laufen ohne
|
||||
* Zutun, deshalb muss nachlesbar sein, ob sie überhaupt laufen - ein still
|
||||
* stehender Zeitplan fällt sonst erst auf, wenn Wochen fehlen.
|
||||
*/
|
||||
function ScheduleTab({ onError }: { onError: (message: string) => void }) {
|
||||
const [stand, setStand] = useState<ScheduleState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
setStand(await api<ScheduleState>('/api/admin/schedule'));
|
||||
} catch (err) {
|
||||
onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
|
||||
}
|
||||
})();
|
||||
}, [onError]);
|
||||
|
||||
const LABEL: Record<string, string> = {
|
||||
gextension: 'Käufe bei GExtension',
|
||||
forum: 'Forums-Anträge',
|
||||
letzter_fehler: 'Zuletzt gescheitert',
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="block">
|
||||
<h2>Zeitplan</h2>
|
||||
|
||||
{stand === null ? (
|
||||
<p className="muted">Lade …</p>
|
||||
) : (
|
||||
<>
|
||||
{stand.enabled ? (
|
||||
<Notice kind="info">
|
||||
Die Käufe werden alle zwei Stunden geprüft, bei Spielern ohne Kauf in den letzten
|
||||
drei Jahren alle sechs. Das Forum wird täglich gegen {stand.forumHour} Uhr gelesen.
|
||||
Gebucht wird dabei nichts von allein: neue Pakete landen in der Import-Vorschau,
|
||||
neue Anträge in der Liste.
|
||||
</Notice>
|
||||
) : (
|
||||
<Notice kind="warn">
|
||||
Der Zeitplan ist abgeschaltet (<code className="mono">SCHEDULE_ENABLED=false</code>).
|
||||
Beide Abgleiche laufen nur, wenn sie von Hand angestoßen werden.
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{stand.runs.length === 0 ? (
|
||||
<p className="muted">Noch kein Lauf verzeichnet.</p>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lauf</th>
|
||||
<th>Zuletzt</th>
|
||||
<th>Ergebnis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stand.runs.map((run) => (
|
||||
<tr key={run.name}>
|
||||
<td>{LABEL[run.name] ?? run.name}</td>
|
||||
<td className="muted small">{formatDate(run.lastRunAt)}</td>
|
||||
<td className="muted small">
|
||||
{run.lastStatus === 'FAILED' && <span className="neg">Fehler: </span>}
|
||||
{run.lastNote ?? '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@ import Modal from '../components/Modal';
|
||||
import Notice from '../components/Notice';
|
||||
import { ApiError, api } from '../lib/api';
|
||||
import { useSession } from '../lib/session';
|
||||
import type { PlayerSummary, ScrapeSummary, UnmatchedOverview } from '../lib/types';
|
||||
import type {
|
||||
PendingOverview,
|
||||
PlayerSummary,
|
||||
ScrapeSummary,
|
||||
UnmatchedOverview,
|
||||
} from '../lib/types';
|
||||
|
||||
interface SearchResult {
|
||||
total: number;
|
||||
@@ -31,6 +36,11 @@ export default function Dashboard() {
|
||||
const vorgabe = params.get('anlegen');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [flagged, setFlagged] = useState<UnmatchedOverview>({ total: 0, players: [] });
|
||||
const [offenePakete, setOffenePakete] = useState<PendingOverview>({
|
||||
total: 0,
|
||||
withoutPlayer: 0,
|
||||
players: [],
|
||||
});
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<string | null>(null);
|
||||
/// Nur die Fälle zeigen, bei denen noch etwas offen ist.
|
||||
@@ -58,6 +68,11 @@ export default function Dashboard() {
|
||||
} catch {
|
||||
// Der Hinweis ist Beiwerk - ein Fehler hier darf die Liste nicht stören.
|
||||
}
|
||||
try {
|
||||
setOffenePakete(await api<PendingOverview>('/api/imports/pending/overview'));
|
||||
} catch {
|
||||
// Ebenso: ein Viewer darf das nicht sehen, das ist kein Fehler.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -142,6 +157,38 @@ export default function Dashboard() {
|
||||
{error && <Notice kind="error">{error}</Notice>}
|
||||
{syncResult && <Notice kind="success" onDismiss={() => setSyncResult(null)}>{syncResult}</Notice>}
|
||||
|
||||
{offenePakete.total > 0 && (
|
||||
<Notice kind="warn">
|
||||
<strong>
|
||||
{offenePakete.total} {offenePakete.total === 1 ? 'Paket wartet' : 'Pakete warten'} auf
|
||||
eine Buchung
|
||||
</strong>{' '}
|
||||
{offenePakete.players.length > 0 && (
|
||||
<>
|
||||
bei{' '}
|
||||
{offenePakete.players.slice(0, 8).map((entry, index) => (
|
||||
<span key={entry.playerId}>
|
||||
{index > 0 && ', '}
|
||||
<Link to={`/players/${entry.playerId}`}>
|
||||
{entry.displayName} ({entry.count})
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
{offenePakete.players.length > 8 && ` und ${offenePakete.players.length - 8} weiteren`}
|
||||
</>
|
||||
)}
|
||||
{offenePakete.withoutPlayer > 0 && (
|
||||
<>
|
||||
{offenePakete.players.length > 0 && '; '}
|
||||
<Link to="/import">
|
||||
{offenePakete.withoutPlayer} ohne zugeordneten Spieler
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
. Gebucht wird beim Spieler oder in der Import-Vorschau.
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{flagged.total > 0 && (
|
||||
<Notice kind="warn">
|
||||
<strong>
|
||||
|
||||
@@ -321,7 +321,7 @@ export default function Forum() {
|
||||
) : (
|
||||
<span className="neg">kein Spieler zugeordnet</span>
|
||||
)}{' '}
|
||||
· gesehen {formatDate(thread.lastSeenAt)}
|
||||
· gestellt {thread.postedAt ? formatDate(thread.postedAt) : 'unbekannt'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="cc-actions">
|
||||
@@ -468,7 +468,8 @@ export default function Forum() {
|
||||
</a>
|
||||
<span className="badge">{BOARD_LABEL[thread.currentBoard]}</span>
|
||||
<p className="muted small">
|
||||
von {thread.authorName ?? 'unbekannt'}
|
||||
{thread.postedAt ? formatDate(thread.postedAt) : 'ohne Datum'} · von{' '}
|
||||
{thread.authorName ?? 'unbekannt'}
|
||||
{thread.labels && ` · ${thread.labels}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -490,7 +490,16 @@ export default function PlayerDetail() {
|
||||
<tbody>
|
||||
{detail.ledger.map((entry) => (
|
||||
<tr key={entry.id} className={entry.reversedBy ? 'is-reversed' : undefined}>
|
||||
<td className="muted small">{formatDate(entry.createdAt)}</td>
|
||||
<td
|
||||
className="muted small"
|
||||
title={
|
||||
entry.occurredAt !== entry.createdAt
|
||||
? `Eingetragen ${formatDate(entry.createdAt)}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{formatDate(entry.occurredAt)}
|
||||
</td>
|
||||
<td>{KIND_LABEL[entry.kind]}</td>
|
||||
<td className={`num ${entry.amount < 0 ? 'neg' : 'pos'}`}>
|
||||
{formatAmount(entry.amount)}
|
||||
|
||||
+13
-3
@@ -415,10 +415,20 @@ a:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Eine Tabellenzelle bleibt eine Tabellenzelle: mit display:flex fällt sie
|
||||
aus dem Tabellenlayout, und ihre Trennlinie sitzt nicht mehr auf einer
|
||||
Höhe mit der restlichen Zeile. */
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
justify-content: flex-end;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-actions > * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.row-actions > * + * {
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
|
||||
.details {
|
||||
|
||||
Reference in New Issue
Block a user