This repository has been archived on 2026-05-06. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tippspiel/frontend/src/pages/AdminPage.tsx
T
Ronny Mueller e27a62a37b 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>
2026-04-03 23:37:38 +02:00

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>
);
}