e27a62a37b
- 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>
165 lines
5.2 KiB
TypeScript
165 lines
5.2 KiB
TypeScript
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 [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);
|
|
setSyncResult(null);
|
|
try {
|
|
const res = await api.syncMatches();
|
|
setSyncResult({ success: true, timestamp: new Date(), message: `${res.total} Spiele geladen — ${res.created} neu, ${res.updated} aktualisiert` });
|
|
} catch (e) {
|
|
setSyncResult({ success: false, timestamp: new Date(), message: (e as Error).message });
|
|
} finally {
|
|
setSyncing(false);
|
|
}
|
|
};
|
|
|
|
const handleEvaluate = async () => {
|
|
setEvaluating(true);
|
|
setEvalResult(null);
|
|
try {
|
|
const res = await api.evaluateTips();
|
|
setEvalResult({ success: true, timestamp: new Date(), message: `${res.matchesEvaluated} Spiele ausgewertet — ${res.tipsUpdated} Tipps bewertet` });
|
|
} catch (e) {
|
|
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}>
|
|
|
|
{/* 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}>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|