feat: Stadium Elite Design, Rangliste, Profil-Team, User-Upsert & n8n Cronjob

- MatchCard + TipModal: Uhrzeit statt VS zwischen Flaggen, Gruppe zentriert
- LeaderboardPage: Podium (2./1./3.), DU-Badge, Trend-Pfeile, Team-Zeile, CTA-Card
- AdminPage: Stadium Elite Redesign mit Result-Bar und Inline-Spinner
- ProfilePage: Team-Feld inline editierbar (PATCH /api/profile/team)
- User-Upsert beim ersten App-Aufruf (Matches-Route) statt erst beim Tipp
- DB Migration 002: team-Spalte in users, Leaderboard View aktualisiert
- Leaderboard-Refresh automatisch nach Tipps-Auswertung
- n8n Workflow angelegt: stündlicher Sync + Auswertung (ID: t3SDspIGDXwkfOt3)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ronny Mueller
2026-04-03 23:37:38 +02:00
parent e967f36f6c
commit e27a62a37b
20 changed files with 1515 additions and 297 deletions
+129 -38
View File
@@ -2,20 +2,28 @@ import { useState } from 'react';
import { api } from '../api/client';
import styles from './AdminPage.module.css';
interface ActionResult {
message: string;
success: boolean;
timestamp: Date;
}
export default function AdminPage() {
const [syncStatus, setSyncStatus] = useState<string | null>(null);
const [evalStatus, setEvalStatus] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
const [evaluating, setEvaluating] = useState(false);
const [syncResult, setSyncResult] = useState<ActionResult | null>(null);
const [evalResult, setEvalResult] = useState<ActionResult | null>(null);
const [refreshResult, setRefreshResult] = useState<ActionResult | null>(null);
const [syncing, setSyncing] = useState(false);
const [evaluating, setEvaluating] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const handleSync = async () => {
setSyncing(true);
setSyncStatus(null);
setSyncResult(null);
try {
const res = await api.syncMatches();
setSyncStatus(`${res.total} Spiele geladen — ${res.created} neu, ${res.updated} aktualisiert`);
setSyncResult({ success: true, timestamp: new Date(), message: `${res.total} Spiele geladen — ${res.created} neu, ${res.updated} aktualisiert` });
} catch (e) {
setSyncStatus(`⚠️ Fehler: ${(e as Error).message}`);
setSyncResult({ success: false, timestamp: new Date(), message: (e as Error).message });
} finally {
setSyncing(false);
}
@@ -23,51 +31,134 @@ export default function AdminPage() {
const handleEvaluate = async () => {
setEvaluating(true);
setEvalStatus(null);
setEvalResult(null);
try {
const res = await api.evaluateTips();
setEvalStatus(`${res.matchesEvaluated} Spiele ausgewertet — ${res.tipsUpdated} Tipps bewertet`);
setEvalResult({ success: true, timestamp: new Date(), message: `${res.matchesEvaluated} Spiele ausgewertet — ${res.tipsUpdated} Tipps bewertet` });
} catch (e) {
setEvalStatus(`⚠️ Fehler: ${(e as Error).message}`);
setEvalResult({ success: false, timestamp: new Date(), message: (e as Error).message });
} finally {
setEvaluating(false);
}
};
const handleRefreshLeaderboard = async () => {
setRefreshing(true);
setRefreshResult(null);
try {
await fetch('/api/admin/refresh-leaderboard', { method: 'POST' });
setRefreshResult({ success: true, timestamp: new Date(), message: 'Materialized View aktualisiert' });
} catch (e) {
setRefreshResult({ success: false, timestamp: new Date(), message: (e as Error).message });
} finally {
setRefreshing(false);
}
};
function formatTime(d: Date) {
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
return (
<div className={styles.page}>
<h1 className={`font-display ${styles.title}`}> Administration</h1>
<p className={styles.hint}>Diese Seite ist nur für Editoren. Nach der Staffbase-Integration wird sie durch Rollenprüfung geschützt.</p>
{/* Header */}
<div className={styles.pageHeader}>
<h1 className={`font-display ${styles.title}`}>Administration</h1>
<div className={styles.roleBadge}>Editor</div>
</div>
<p className={styles.hint}>
Nur für Editoren sichtbar. Nach Staffbase-Freischaltung wird diese Seite durch Rollenprüfung geschützt.
</p>
{/* Action Cards */}
<div className={styles.cards}>
<div className={`card ${styles.actionCard}`}>
<div className={styles.cardIcon}>🔄</div>
<h2 className={styles.cardTitle}>Spiele synchronisieren</h2>
<p className={styles.cardDesc}>Lädt alle WM 2026-Spiele von football-data.org und speichert sie in der Datenbank. Täglich ausführen oder nach Spielplan-Änderungen.</p>
{syncStatus && (
<div className={`${styles.status} ${syncStatus.startsWith('✓') ? styles.success : styles.error}`}>
{syncStatus}
</div>
)}
<button className="btn-primary" onClick={handleSync} disabled={syncing}>
{syncing ? '⏳ Wird synchronisiert…' : '🔄 Jetzt synchronisieren'}
</button>
</div>
<div className={`card ${styles.actionCard}`}>
<div className={styles.cardIcon}>🧮</div>
<h2 className={styles.cardTitle}>Tipps auswerten</h2>
<p className={styles.cardDesc}>Berechnet Punkte für alle abgeschlossenen Spiele und aktualisiert die Rangliste. Nach jedem Spieltag ausführen.</p>
{evalStatus && (
<div className={`${styles.status} ${evalStatus.startsWith('✓') ? styles.success : styles.error}`}>
{evalStatus}
</div>
)}
<button className="btn-primary" onClick={handleEvaluate} disabled={evaluating}>
{evaluating ? '⏳ Wird ausgewertet…' : '🧮 Tipps auswerten'}
</button>
</div>
{/* Sync */}
<ActionCard
icon="↻"
title="Spiele synchronisieren"
desc="Lädt alle WM 2026-Spiele von football-data.org und aktualisiert die Datenbank."
result={syncResult}
loading={syncing}
loadingLabel="Wird synchronisiert…"
actionLabel="Jetzt synchronisieren"
onAction={handleSync}
formatTime={formatTime}
/>
{/* Evaluate */}
<ActionCard
icon="◈"
title="Tipps auswerten"
desc="Berechnet Punkte für alle abgeschlossenen Spiele und aktualisiert die Rangliste."
result={evalResult}
loading={evaluating}
loadingLabel="Wird ausgewertet…"
actionLabel="Tipps auswerten"
onAction={handleEvaluate}
formatTime={formatTime}
/>
{/* Refresh Leaderboard */}
<ActionCard
icon="⟳"
title="Rangliste aktualisieren"
desc="Aktualisiert die Materialized View manuell — normalerweise automatisch nach Auswertung."
result={refreshResult}
loading={refreshing}
loadingLabel="Wird aktualisiert…"
actionLabel="Rangliste neu berechnen"
onAction={handleRefreshLeaderboard}
formatTime={formatTime}
/>
</div>
</div>
);
}
/* ── Sub-component ── */
function ActionCard({
icon, title, desc, result, loading, loadingLabel, actionLabel, onAction, formatTime,
}: {
icon: string;
title: string;
desc: string;
result: ActionResult | null;
loading: boolean;
loadingLabel: string;
actionLabel: string;
onAction: () => void;
formatTime: (d: Date) => string;
}) {
return (
<div className={`card ${styles.actionCard}`}>
<div className={styles.cardTop}>
<div className={styles.cardIcon}>{icon}</div>
<div>
<div className={styles.cardTitle}>{title}</div>
<div className={styles.cardDesc}>{desc}</div>
</div>
</div>
{result && (
<div className={`${styles.resultBar} ${result.success ? styles.resultSuccess : styles.resultError}`}>
<span className={styles.resultDot} />
<span className={styles.resultMsg}>{result.message}</span>
<span className={styles.resultTime}>{formatTime(result.timestamp)}</span>
</div>
)}
<button
className={`${styles.actionBtn} ${loading ? styles.actionBtnLoading : ''}`}
onClick={onAction}
disabled={loading}
>
{loading ? (
<><span className={styles.spinner} />{loadingLabel}</>
) : actionLabel}
</button>
</div>
);
}