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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user