Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import JurorSelect from './pages/JurorSelect'
|
||||
import VotingDashboard from './pages/VotingDashboard'
|
||||
import TeamScoring from './pages/TeamScoring'
|
||||
import ResultsView from './pages/ResultsView'
|
||||
import ResultsDetailView from './pages/ResultsDetailView'
|
||||
import AdminPanel from './pages/AdminPanel'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="background-mesh" />
|
||||
<Routes>
|
||||
<Route path="/" element={<JurorSelect />} />
|
||||
<Route path="/vote" element={<VotingDashboard />} />
|
||||
<Route path="/vote/:teamId" element={<TeamScoring />} />
|
||||
<Route path="/results" element={<ResultsView />} />
|
||||
<Route path="/results/detail" element={<ResultsDetailView />} />
|
||||
<Route path="/admin" element={<AdminPanel />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface JurorProgress {
|
||||
completed: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays juror voting progress (X/11 teams completed per juror).
|
||||
* Fetches from GET /api/progress. No individual scores shown.
|
||||
*/
|
||||
export default function ProgressMatrix() {
|
||||
const [progress, setProgress] = useState<Record<string, JurorProgress>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProgress();
|
||||
const interval = setInterval(fetchProgress, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
function fetchProgress() {
|
||||
fetch('/api/progress')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setProgress(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="progress-loading">Lade Fortschritt...</div>;
|
||||
}
|
||||
|
||||
const entries = Object.entries(progress);
|
||||
const allComplete = entries.length > 0 && entries.every(([, p]) => p.completed === p.total);
|
||||
|
||||
return (
|
||||
<div className="progress-matrix">
|
||||
<h3 className="progress-title">Fortschritt</h3>
|
||||
|
||||
{allComplete && (
|
||||
<div className="progress-complete-badge">
|
||||
✓ Alle Bewertungen abgeschlossen
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="progress-list">
|
||||
{entries.map(([jurorId, { completed, total }]) => {
|
||||
const percent = total > 0 ? (completed / total) * 100 : 0;
|
||||
const isComplete = completed === total;
|
||||
|
||||
return (
|
||||
<div key={jurorId} className="progress-item">
|
||||
<div className="progress-item-header">
|
||||
<span className="progress-juror-name">
|
||||
{jurorId.charAt(0).toUpperCase() + jurorId.slice(1)}
|
||||
</span>
|
||||
<span className={`progress-count ${isComplete ? 'complete' : ''}`}>
|
||||
{completed}/{total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="progress-bar-track">
|
||||
<div
|
||||
className={`progress-bar-fill ${isComplete ? 'complete' : ''}`}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { TeamResult } from '../../shared/types';
|
||||
|
||||
interface RankingTableProps {
|
||||
results: TeamResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Beamer-optimized ranking table with animated transitions.
|
||||
* Displays ranked teams with scores, highlights top 3.
|
||||
*/
|
||||
export default function RankingTable({ results }: RankingTableProps) {
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className="ranking-empty">
|
||||
<p>Noch keine Bewertungen eingegangen.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getRankStyle = (rank: number): string => {
|
||||
if (rank === 1) return 'rank-gold';
|
||||
if (rank === 2) return 'rank-silver';
|
||||
if (rank === 3) return 'rank-bronze';
|
||||
return '';
|
||||
};
|
||||
|
||||
const getRankLabel = (rank: number): string => {
|
||||
if (rank === 1) return '🥇';
|
||||
if (rank === 2) return '🥈';
|
||||
if (rank === 3) return '🥉';
|
||||
return `${rank}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ranking-table">
|
||||
<div className="ranking-header">
|
||||
<span className="ranking-col-rank">Platz</span>
|
||||
<span className="ranking-col-team">Team</span>
|
||||
<span className="ranking-col-total">Gesamt</span>
|
||||
<span className="ranking-col-avg">Ø</span>
|
||||
</div>
|
||||
<div className="ranking-rows">
|
||||
{results.map((result, index) => (
|
||||
<div
|
||||
key={result.teamId}
|
||||
className={`ranking-row glass-panel ${getRankStyle(result.rank)}`}
|
||||
style={{
|
||||
order: index,
|
||||
transition: 'transform 0.6s ease, opacity 0.4s ease',
|
||||
}}
|
||||
>
|
||||
<span className="ranking-col-rank rank-number">
|
||||
{getRankLabel(result.rank)}
|
||||
</span>
|
||||
<span className="ranking-col-team team-name">
|
||||
{result.teamName}
|
||||
</span>
|
||||
<span className="ranking-col-total total-score">
|
||||
{result.total}
|
||||
</span>
|
||||
<span className="ranking-col-avg avg-score">
|
||||
{result.average.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect } from 'react';
|
||||
import { TeamResult } from '../../shared/types';
|
||||
|
||||
interface TeamDetailModalProps {
|
||||
team: TeamResult;
|
||||
scaleMax: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal overlay showing per-category bar chart for a selected team.
|
||||
*/
|
||||
export default function TeamDetailModal({ team, scaleMax, onClose }: TeamDetailModalProps) {
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
const stats = team.criteriaStats ?? [];
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={`Ergebnisse für ${team.teamName}`}>
|
||||
<div className="modal-content glass-panel" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="modal-header">
|
||||
<h2 className="modal-title">{team.teamName}</h2>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Schließen">✕</button>
|
||||
</header>
|
||||
|
||||
<div className="modal-summary">
|
||||
<span className="modal-stat">
|
||||
<span className="modal-stat-label">Gesamt</span>
|
||||
<span className="modal-stat-value">{team.total}</span>
|
||||
</span>
|
||||
<span className="modal-stat">
|
||||
<span className="modal-stat-label">Ø</span>
|
||||
<span className="modal-stat-value">{team.average.toFixed(1)}</span>
|
||||
</span>
|
||||
<span className="modal-stat">
|
||||
<span className="modal-stat-label">σ</span>
|
||||
<span className="modal-stat-value">±{team.stddev.toFixed(1)}</span>
|
||||
</span>
|
||||
<span className="modal-stat">
|
||||
<span className="modal-stat-label">Juroren</span>
|
||||
<span className="modal-stat-value">{team.jurorCount}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{stats.length > 0 ? (
|
||||
<div className="modal-chart">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.criterionId} className="chart-row">
|
||||
<span className="chart-label">{stat.criterionName}</span>
|
||||
<div className="chart-bar-container">
|
||||
<div
|
||||
className="chart-bar"
|
||||
style={{ width: `${(stat.mean / scaleMax) * 100}%` }}
|
||||
>
|
||||
<span className="chart-bar-value">{stat.mean.toFixed(1)}</span>
|
||||
</div>
|
||||
{stat.stddev > 0 && (
|
||||
<span className="chart-stddev">±{stat.stddev.toFixed(1)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="modal-empty">Noch keine Bewertungen für dieses Team.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import type { PendingScore } from '../../shared/types'
|
||||
|
||||
const STORAGE_KEY = 'pendingScores'
|
||||
const MAX_RETRIES = 3
|
||||
const BASE_DELAY_MS = 1000
|
||||
|
||||
interface SubmitScoreParams {
|
||||
jurorId: string
|
||||
teamId: string
|
||||
criterionId: string
|
||||
value: number
|
||||
}
|
||||
|
||||
interface UseOfflineQueueReturn {
|
||||
submitScore: (params: SubmitScoreParams) => void
|
||||
pendingCount: number
|
||||
isOnline: boolean
|
||||
}
|
||||
|
||||
function loadPendingScores(): PendingScore[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (!stored) return []
|
||||
return JSON.parse(stored) as PendingScore[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function savePendingScores(scores: PendingScore[]): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(scores))
|
||||
}
|
||||
|
||||
export function useOfflineQueue(): UseOfflineQueueReturn {
|
||||
const [pending, setPending] = useState<PendingScore[]>(loadPendingScores)
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||
const retryCountsRef = useRef<Map<string, number>>(new Map())
|
||||
const processingRef = useRef(false)
|
||||
|
||||
// Track online/offline status
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true)
|
||||
const handleOffline = () => setIsOnline(false)
|
||||
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Process queue when online or when pending changes
|
||||
useEffect(() => {
|
||||
if (isOnline && pending.some(s => !s.synced)) {
|
||||
processQueue()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOnline, pending])
|
||||
|
||||
const getScoreKey = (s: PendingScore) =>
|
||||
`${s.jurorId}:${s.teamId}:${s.criterionId}:${s.timestamp}`
|
||||
|
||||
const processQueue = useCallback(async () => {
|
||||
if (processingRef.current) return
|
||||
processingRef.current = true
|
||||
|
||||
const unsynced = pending.filter(s => !s.synced)
|
||||
|
||||
for (const score of unsynced) {
|
||||
const key = getScoreKey(score)
|
||||
const retries = retryCountsRef.current.get(key) || 0
|
||||
|
||||
if (retries >= MAX_RETRIES) continue
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/scores', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jurorId: score.jurorId,
|
||||
teamId: score.teamId,
|
||||
criterionId: score.criterionId,
|
||||
value: score.value,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok || res.status === 409) {
|
||||
// Success or conflict (server has newer) — remove from queue
|
||||
setPending(prev => {
|
||||
const updated = prev.filter(s => getScoreKey(s) !== key)
|
||||
savePendingScores(updated)
|
||||
return updated
|
||||
})
|
||||
retryCountsRef.current.delete(key)
|
||||
} else {
|
||||
throw new Error(`Server returned ${res.status}`)
|
||||
}
|
||||
} catch {
|
||||
const newRetries = retries + 1
|
||||
retryCountsRef.current.set(key, newRetries)
|
||||
|
||||
if (newRetries < MAX_RETRIES) {
|
||||
// Exponential backoff
|
||||
const delay = BASE_DELAY_MS * Math.pow(2, newRetries - 1)
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processingRef.current = false
|
||||
}, [pending])
|
||||
|
||||
const submitScore = useCallback((params: SubmitScoreParams) => {
|
||||
const newScore: PendingScore = {
|
||||
jurorId: params.jurorId,
|
||||
teamId: params.teamId,
|
||||
criterionId: params.criterionId,
|
||||
value: params.value,
|
||||
timestamp: new Date().toISOString(),
|
||||
synced: false,
|
||||
}
|
||||
|
||||
setPending(prev => {
|
||||
// Replace any existing pending score for same juror/team/criterion
|
||||
const filtered = prev.filter(
|
||||
s =>
|
||||
!(
|
||||
s.jurorId === params.jurorId &&
|
||||
s.teamId === params.teamId &&
|
||||
s.criterionId === params.criterionId
|
||||
)
|
||||
)
|
||||
const updated = [...filtered, newScore]
|
||||
savePendingScores(updated)
|
||||
return updated
|
||||
})
|
||||
|
||||
// Reset retry count for this score
|
||||
const key = getScoreKey(newScore)
|
||||
retryCountsRef.current.delete(key)
|
||||
}, [])
|
||||
|
||||
const pendingCount = pending.filter(s => !s.synced).length
|
||||
|
||||
return { submitScore, pendingCount, isOnline }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { TeamResult } from '../../shared/types';
|
||||
|
||||
interface UseSSEReturn {
|
||||
results: TeamResult[];
|
||||
connected: boolean;
|
||||
lastUpdate: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom React hook that connects to the SSE endpoint for live score updates.
|
||||
* Fetches initial results immediately, then uses EventSource for live updates.
|
||||
*/
|
||||
export function useSSE(): UseSSEReturn {
|
||||
const [results, setResults] = useState<TeamResult[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState<string | null>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch initial results immediately
|
||||
fetch('/api/results')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setResults(data);
|
||||
setLastUpdate(new Date().toISOString());
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Then connect to SSE for live updates
|
||||
const eventSource = new EventSource('/api/results/stream');
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
eventSource.onopen = () => {
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
eventSource.addEventListener('scores_updated', (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
setResults(data.results);
|
||||
setLastUpdate(data.timestamp);
|
||||
} catch {
|
||||
// Ignore malformed events
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
setConnected(false);
|
||||
// EventSource will automatically attempt to reconnect
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
setConnected(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { results, connected, lastUpdate };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Session } from '../../shared/types';
|
||||
import ProgressMatrix from '../components/ProgressMatrix';
|
||||
|
||||
/**
|
||||
* Admin panel for session management, progress monitoring, and export.
|
||||
*/
|
||||
export default function AdminPanel() {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showEndConfirm, setShowEndConfirm] = useState(false);
|
||||
const [incompleteWarning, setIncompleteWarning] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSession();
|
||||
}, []);
|
||||
|
||||
function fetchSession() {
|
||||
fetch('/api/session')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setSession(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
const res = await fetch('/api/session/start', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSession(data);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEndClick() {
|
||||
// Check progress before ending
|
||||
try {
|
||||
const res = await fetch('/api/progress');
|
||||
const progress: Record<string, { completed: number; total: number }> = await res.json();
|
||||
const entries = Object.entries(progress);
|
||||
const incomplete = entries.filter(([, p]) => p.completed < p.total);
|
||||
|
||||
if (incomplete.length > 0) {
|
||||
const warnings = incomplete.map(
|
||||
([id, p]) => `${id.charAt(0).toUpperCase() + id.slice(1)}: ${p.completed}/${p.total}`
|
||||
);
|
||||
setIncompleteWarning(`Unvollständige Bewertungen:\n${warnings.join(', ')}`);
|
||||
} else {
|
||||
setIncompleteWarning(null);
|
||||
}
|
||||
} catch {
|
||||
setIncompleteWarning(null);
|
||||
}
|
||||
|
||||
setShowEndConfirm(true);
|
||||
}
|
||||
|
||||
async function handleEndConfirm() {
|
||||
const res = await fetch('/api/session/end', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSession(data);
|
||||
}
|
||||
setShowEndConfirm(false);
|
||||
setIncompleteWarning(null);
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
const a = document.createElement('a');
|
||||
a.href = '/api/export/csv';
|
||||
a.download = 'jury-results.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: string | null): string {
|
||||
if (!ts) return '—';
|
||||
return new Date(ts).toLocaleString('de-DE', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Laden...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="admin-header-row">
|
||||
<h1>Admin Panel</h1>
|
||||
</div>
|
||||
|
||||
{/* Session Status */}
|
||||
<section className="glass-panel admin-section">
|
||||
<h3 className="admin-section-title">Session</h3>
|
||||
<div className="admin-session-info">
|
||||
<div className="session-status-row">
|
||||
<span className="session-label">Status:</span>
|
||||
<span className={`session-badge status-${session?.status || 'setup'}`}>
|
||||
{session?.status === 'active' ? 'Aktiv' :
|
||||
session?.status === 'ended' ? 'Beendet' : 'Vorbereitung'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="session-status-row">
|
||||
<span className="session-label">Gestartet:</span>
|
||||
<span className="session-value">{formatTimestamp(session?.startedAt ?? null)}</span>
|
||||
</div>
|
||||
<div className="session-status-row">
|
||||
<span className="session-label">Beendet:</span>
|
||||
<span className="session-value">{formatTimestamp(session?.endedAt ?? null)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Controls */}
|
||||
<div className="admin-controls">
|
||||
{session?.status === 'setup' && (
|
||||
<button className="primary-button" onClick={handleStart}>
|
||||
Abstimmung starten
|
||||
</button>
|
||||
)}
|
||||
{session?.status === 'active' && (
|
||||
<button className="admin-end-btn" onClick={handleEndClick}>
|
||||
Abstimmung beenden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* End Confirmation Dialog */}
|
||||
{showEndConfirm && (
|
||||
<div className="admin-dialog-overlay" onClick={() => setShowEndConfirm(false)}>
|
||||
<div className="glass-panel admin-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Abstimmung beenden?</h3>
|
||||
{incompleteWarning && (
|
||||
<p className="admin-warning">{incompleteWarning}</p>
|
||||
)}
|
||||
<p className="admin-dialog-text">
|
||||
Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
</p>
|
||||
<div className="admin-dialog-actions">
|
||||
<button className="admin-cancel-btn" onClick={() => setShowEndConfirm(false)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button className="admin-confirm-btn" onClick={handleEndConfirm}>
|
||||
Beenden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Matrix */}
|
||||
<section className="glass-panel admin-section">
|
||||
<ProgressMatrix />
|
||||
</section>
|
||||
|
||||
{/* Export & Links */}
|
||||
<section className="glass-panel admin-section">
|
||||
<h3 className="admin-section-title">Export & Ansichten</h3>
|
||||
<div className="admin-actions-row">
|
||||
<button className="primary-button" onClick={handleExport}>
|
||||
CSV Export
|
||||
</button>
|
||||
<a href="/results" target="_blank" rel="noopener noreferrer" className="admin-link-btn">
|
||||
Ergebnis-Ansicht öffnen ↗
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import type { Juror } from '../../shared/types'
|
||||
|
||||
const STORAGE_KEY = 'jury-voting-juror'
|
||||
|
||||
export default function JurorSelect() {
|
||||
const navigate = useNavigate()
|
||||
const [jurors, setJurors] = useState<Juror[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/jurors')
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('Failed to load jurors')
|
||||
return res.json()
|
||||
})
|
||||
.then((data: Juror[]) => {
|
||||
setJurors(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(err => {
|
||||
setError(err.message)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSelect = (juror: Juror) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: juror.id, name: juror.name }))
|
||||
navigate('/vote')
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page-container" style={styles.center}>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Laden...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="page-container" style={styles.center}>
|
||||
<p style={{ color: '#ff6b6b' }}>{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container" style={styles.page}>
|
||||
<header style={styles.header}>
|
||||
<p style={styles.eventTitle}>15. UNIKAT Ideenwettbewerb</p>
|
||||
<h1 style={styles.title}>
|
||||
<span className="text-gradient">Jury Voting</span>
|
||||
</h1>
|
||||
<p style={styles.subtitle}>Wählen Sie Ihren Namen</p>
|
||||
</header>
|
||||
|
||||
<div style={styles.grid}>
|
||||
{jurors.map(juror => (
|
||||
<button
|
||||
key={juror.id}
|
||||
onClick={() => handleSelect(juror)}
|
||||
style={styles.card}
|
||||
className="glass-panel"
|
||||
aria-label={`Als ${juror.name} anmelden`}
|
||||
>
|
||||
<span style={styles.cardName}>{juror.name}</span>
|
||||
{juror.active && (
|
||||
<span style={styles.activeBadge}>aktiv</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
page: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
gap: '40px',
|
||||
},
|
||||
center: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
},
|
||||
header: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
eventTitle: {
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 400,
|
||||
marginBottom: '8px',
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
title: {
|
||||
fontSize: 'clamp(2rem, 8vw, 3rem)',
|
||||
marginBottom: '12px',
|
||||
},
|
||||
subtitle: {
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 300,
|
||||
},
|
||||
grid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '12px',
|
||||
width: '100%',
|
||||
maxWidth: '360px',
|
||||
},
|
||||
card: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '24px 16px',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid var(--glass-border)',
|
||||
background: 'var(--glass-bg)',
|
||||
borderRadius: '16px',
|
||||
transition: 'all 0.2s ease',
|
||||
minHeight: '80px',
|
||||
position: 'relative',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '1rem',
|
||||
fontFamily: 'inherit',
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
},
|
||||
cardName: {
|
||||
fontSize: '1.2rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
activeBadge: {
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
right: '8px',
|
||||
fontSize: '0.65rem',
|
||||
color: 'var(--accent-color)',
|
||||
border: '1px solid rgba(0, 255, 0, 0.3)',
|
||||
borderRadius: '50px',
|
||||
padding: '2px 8px',
|
||||
fontWeight: 400,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useSSE } from '../hooks/useSSE';
|
||||
import { Session, CriterionStats } from '../../shared/types';
|
||||
|
||||
/**
|
||||
* Detail view showing per-category scores (mean + stddev) for all teams.
|
||||
* Scrollable table with sticky shrinking header.
|
||||
* Teams expand to show histograms (5 categories side by side).
|
||||
*/
|
||||
export default function ResultsDetailView() {
|
||||
const { results, connected } = useSSE();
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [expandedTeams, setExpandedTeams] = useState<Set<string>>(new Set());
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/session')
|
||||
.then((res) => res.json())
|
||||
.then(setSession)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = mainRef.current;
|
||||
if (!el) return;
|
||||
const handleScroll = () => {
|
||||
setScrolled(el.scrollTop > 40);
|
||||
};
|
||||
el.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => el.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const criteria = session?.config.criteria ?? [];
|
||||
const isEnded = session?.status === 'ended';
|
||||
const scaleMax = session?.config.scaleMax ?? 10;
|
||||
|
||||
const toggleTeam = (teamId: string) => {
|
||||
setExpandedTeams((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(teamId)) {
|
||||
next.delete(teamId);
|
||||
} else {
|
||||
next.add(teamId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="results-view results-view--scrollable">
|
||||
<div className="results-bg-mesh" />
|
||||
|
||||
{/* Connection indicator */}
|
||||
<div className="results-connection">
|
||||
<span className={`connection-dot ${connected ? 'connected' : 'disconnected'}`} />
|
||||
</div>
|
||||
|
||||
{/* Sticky Header */}
|
||||
<header className={`results-header results-header--sticky ${scrolled ? 'results-header--compact' : ''}`}>
|
||||
<h1 className="results-title">15. UNIKAT Ideenwettbewerb</h1>
|
||||
<div className="results-header-extras">
|
||||
<p className={`results-status ${!isEnded ? 'voting-active' : ''}`}>
|
||||
{isEnded ? 'Endergebnis' : 'Abstimmung läuft...'}
|
||||
</p>
|
||||
<nav className="results-nav">
|
||||
<a href="/results" className="results-nav-link">Ranking</a>
|
||||
<a href="/results/detail" className="results-nav-link active">Details</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Detail Table */}
|
||||
<main className="results-main results-main--scrollable" ref={mainRef}>
|
||||
{results.length === 0 ? (
|
||||
<div className="ranking-empty">
|
||||
<p>Noch keine Bewertungen eingegangen.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="detail-table-wrapper">
|
||||
<table className="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="detail-th detail-th-rank">#</th>
|
||||
<th className="detail-th detail-th-team">Team</th>
|
||||
{criteria.map((c) => (
|
||||
<th key={c.id} className="detail-th detail-th-criterion">
|
||||
{c.nameDE}
|
||||
</th>
|
||||
))}
|
||||
<th className="detail-th detail-th-total">Gesamt</th>
|
||||
<th className="detail-th detail-th-avg">Ø</th>
|
||||
<th className="detail-th detail-th-stddev">StAbw</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((result) => {
|
||||
const isExpanded = expandedTeams.has(result.teamId);
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
key={result.teamId}
|
||||
className={`detail-row ${getRankStyle(result.rank)} detail-row--clickable`}
|
||||
onClick={() => toggleTeam(result.teamId)}
|
||||
>
|
||||
<td className="detail-td detail-td-rank">
|
||||
{getRankLabel(result.rank)}
|
||||
</td>
|
||||
<td className="detail-td detail-td-team">
|
||||
{result.teamName}
|
||||
<span className="expand-indicator-sm">{isExpanded ? ' ▲' : ' ▼'}</span>
|
||||
</td>
|
||||
{criteria.map((c) => {
|
||||
const stat = result.criteriaStats?.find(
|
||||
(s) => s.criterionId === c.id
|
||||
);
|
||||
return (
|
||||
<td key={c.id} className="detail-td detail-td-criterion">
|
||||
{stat && stat.mean > 0 ? (
|
||||
<div className="detail-cell-content">
|
||||
<span className="detail-mean">{stat.mean.toFixed(1)}</span>
|
||||
{stat.stddev > 0 && (
|
||||
<span className="detail-stddev">±{stat.stddev.toFixed(1)}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="detail-empty">–</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="detail-td detail-td-total">{result.total}</td>
|
||||
<td className="detail-td detail-td-avg">{result.average.toFixed(1)}</td>
|
||||
<td className="detail-td detail-td-stddev">±{result.stddev.toFixed(1)}</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr key={`${result.teamId}-expand`} className="detail-expand-row">
|
||||
<td colSpan={criteria.length + 5} className="detail-expand-cell">
|
||||
<div className="histograms">
|
||||
{(result.criteriaStats ?? []).map((stat) => (
|
||||
<Histogram key={stat.criterionId} stat={stat} scaleMax={scaleMax} />
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Single histogram for one criterion — vertical bars for each juror score */
|
||||
function Histogram({ stat, scaleMax }: { stat: CriterionStats; scaleMax: number }) {
|
||||
return (
|
||||
<div className="histogram">
|
||||
<div className="histogram-bars">
|
||||
{stat.scores.map((score, i) => (
|
||||
<div key={i} className="histogram-bar-wrapper">
|
||||
<div
|
||||
className="histogram-bar"
|
||||
style={{ height: `${(score / scaleMax) * 100}%` }}
|
||||
>
|
||||
<span className="histogram-bar-label">{score}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="histogram-footer">
|
||||
<span className="histogram-name">{stat.criterionName}</span>
|
||||
<span className="histogram-mean">Ø {stat.mean.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getRankStyle(rank: number): string {
|
||||
if (rank === 1) return 'rank-gold';
|
||||
if (rank === 2) return 'rank-silver';
|
||||
if (rank === 3) return 'rank-bronze';
|
||||
return '';
|
||||
}
|
||||
|
||||
function getRankLabel(rank: number): string {
|
||||
if (rank === 1) return '🥇';
|
||||
if (rank === 2) return '🥈';
|
||||
if (rank === 3) return '🥉';
|
||||
return `${rank}`;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useSSE } from '../hooks/useSSE';
|
||||
import { Session, CriterionStats } from '../../shared/types';
|
||||
|
||||
/**
|
||||
* Full-screen beamer view for live-updating rankings.
|
||||
* Scrollable with sticky shrinking header.
|
||||
* Teams expand to show histograms (5 categories side by side, vertical bars per juror score).
|
||||
*/
|
||||
export default function ResultsView() {
|
||||
const { results, connected } = useSSE();
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [expandedTeams, setExpandedTeams] = useState<Set<string>>(new Set());
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/session')
|
||||
.then((res) => res.json())
|
||||
.then(setSession)
|
||||
.catch(() => {});
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetch('/api/session')
|
||||
.then((res) => res.json())
|
||||
.then(setSession)
|
||||
.catch(() => {});
|
||||
}, 10000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = mainRef.current;
|
||||
if (!el) return;
|
||||
const handleScroll = () => {
|
||||
setScrolled(el.scrollTop > 40);
|
||||
};
|
||||
el.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => el.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const isEnded = session?.status === 'ended';
|
||||
const scaleMax = session?.config.scaleMax ?? 10;
|
||||
|
||||
const toggleTeam = (teamId: string) => {
|
||||
setExpandedTeams((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(teamId)) {
|
||||
next.delete(teamId);
|
||||
} else {
|
||||
next.add(teamId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="results-view results-view--scrollable">
|
||||
<div className="results-bg-mesh" />
|
||||
|
||||
{/* Connection indicator */}
|
||||
<div className="results-connection">
|
||||
<span className={`connection-dot ${connected ? 'connected' : 'disconnected'}`} />
|
||||
</div>
|
||||
|
||||
{/* Sticky Header — shrinks on scroll */}
|
||||
<header className={`results-header results-header--sticky ${scrolled ? 'results-header--compact' : ''}`}>
|
||||
<h1 className="results-title">15. UNIKAT Ideenwettbewerb</h1>
|
||||
<div className="results-header-extras">
|
||||
<p className={`results-status ${!isEnded ? 'voting-active' : ''}`}>
|
||||
{isEnded ? 'Endergebnis' : 'Abstimmung läuft...'}
|
||||
</p>
|
||||
<nav className="results-nav">
|
||||
<a href="/results" className="results-nav-link active">Ranking</a>
|
||||
<a href="/results/detail" className="results-nav-link">Details</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Ranking Table */}
|
||||
<main className="results-main results-main--scrollable" ref={mainRef}>
|
||||
{results.length === 0 ? (
|
||||
<div className="ranking-empty">
|
||||
<p>Noch keine Bewertungen eingegangen.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ranking-table">
|
||||
<div className="ranking-header ranking-header--with-stddev">
|
||||
<span className="ranking-col-rank">Platz</span>
|
||||
<span className="ranking-col-team">Team</span>
|
||||
<span className="ranking-col-total">Gesamt</span>
|
||||
<span className="ranking-col-avg">Ø</span>
|
||||
<span className="ranking-col-stddev">StAbw</span>
|
||||
</div>
|
||||
<div className="ranking-rows">
|
||||
{results.map((result) => {
|
||||
const isExpanded = expandedTeams.has(result.teamId);
|
||||
return (
|
||||
<div key={result.teamId} className="ranking-item">
|
||||
<div
|
||||
className={`ranking-row glass-panel ${getRankStyle(result.rank)} ranking-row--clickable ${isExpanded ? 'ranking-row--expanded' : ''}`}
|
||||
onClick={() => toggleTeam(result.teamId)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') toggleTeam(result.teamId);
|
||||
}}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`Details für ${result.teamName} ${isExpanded ? 'einklappen' : 'ausklappen'}`}
|
||||
>
|
||||
<span className="ranking-col-rank rank-number">
|
||||
{getRankLabel(result.rank)}
|
||||
</span>
|
||||
<span className="ranking-col-team team-name">
|
||||
{result.teamName}
|
||||
<span className="expand-indicator">{isExpanded ? '▲' : '▼'}</span>
|
||||
</span>
|
||||
<span className="ranking-col-total total-score">
|
||||
{result.total}
|
||||
</span>
|
||||
<span className="ranking-col-avg avg-score">
|
||||
{result.average.toFixed(1)}
|
||||
</span>
|
||||
<span className="ranking-col-stddev stddev-score">
|
||||
±{result.stddev.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Expandable: Histograms per category, side by side */}
|
||||
{isExpanded && (
|
||||
<div className="ranking-expand glass-panel">
|
||||
{result.criteriaStats && result.criteriaStats.length > 0 ? (
|
||||
<div className="histograms">
|
||||
{result.criteriaStats.map((stat) => (
|
||||
<Histogram key={stat.criterionId} stat={stat} scaleMax={scaleMax} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="expand-empty">Noch keine Einzelbewertungen vorhanden.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Single histogram for one criterion — vertical bars for each juror score */
|
||||
function Histogram({ stat, scaleMax }: { stat: CriterionStats; scaleMax: number }) {
|
||||
return (
|
||||
<div className="histogram">
|
||||
<div className="histogram-bars">
|
||||
{stat.scores.map((score, i) => (
|
||||
<div key={i} className="histogram-bar-wrapper">
|
||||
<div
|
||||
className="histogram-bar"
|
||||
style={{ height: `${(score / scaleMax) * 100}%` }}
|
||||
>
|
||||
<span className="histogram-bar-label">{score}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="histogram-footer">
|
||||
<span className="histogram-name">{stat.criterionName}</span>
|
||||
<span className="histogram-mean">Ø {stat.mean.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getRankStyle(rank: number): string {
|
||||
if (rank === 1) return 'rank-gold';
|
||||
if (rank === 2) return 'rank-silver';
|
||||
if (rank === 3) return 'rank-bronze';
|
||||
return '';
|
||||
}
|
||||
|
||||
function getRankLabel(rank: number): string {
|
||||
if (rank === 1) return '🥇';
|
||||
if (rank === 2) return '🥈';
|
||||
if (rank === 3) return '🥉';
|
||||
return `${rank}`;
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import type { Team, Score, Criterion } from '../../shared/types'
|
||||
import { useOfflineQueue } from '../hooks/useOfflineQueue'
|
||||
|
||||
const STORAGE_KEY = 'jury-voting-juror'
|
||||
const DEBOUNCE_MS = 300
|
||||
|
||||
interface JurorInfo {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export default function TeamScoring() {
|
||||
const { teamId } = useParams<{ teamId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [juror, setJuror] = useState<JurorInfo | null>(null)
|
||||
const [team, setTeam] = useState<Team | null>(null)
|
||||
const [teams, setTeams] = useState<Team[]>([])
|
||||
const [criteria, setCriteria] = useState<Criterion[]>([])
|
||||
const [scores, setScores] = useState<Record<string, number>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [savedCriterion, setSavedCriterion] = useState<string | null>(null)
|
||||
const debounceTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
|
||||
const { submitScore, pendingCount, isOnline } = useOfflineQueue()
|
||||
|
||||
// Load juror from localStorage
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (!stored) {
|
||||
navigate('/', { replace: true })
|
||||
return
|
||||
}
|
||||
try {
|
||||
setJuror(JSON.parse(stored) as JurorInfo)
|
||||
} catch {
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
// Fetch data
|
||||
useEffect(() => {
|
||||
if (!juror || !teamId) return
|
||||
|
||||
Promise.all([
|
||||
fetch('/api/teams').then(r => r.json()),
|
||||
fetch(`/api/scores/${juror.id}`).then(r => r.json()),
|
||||
fetch('/api/session').then(r => r.json()),
|
||||
])
|
||||
.then(([teamsData, scoresData, sessionData]) => {
|
||||
const allTeams = teamsData as Team[]
|
||||
setTeams(allTeams)
|
||||
setTeam(allTeams.find(t => t.id === teamId) || null)
|
||||
|
||||
const allScores = scoresData as Score[]
|
||||
const teamScores: Record<string, number> = {}
|
||||
allScores
|
||||
.filter(s => s.teamId === teamId)
|
||||
.forEach(s => {
|
||||
teamScores[s.criterionId] = s.value
|
||||
})
|
||||
setScores(teamScores)
|
||||
|
||||
setCriteria(sessionData.config?.criteria || [])
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => setLoading(false))
|
||||
}, [juror, teamId])
|
||||
|
||||
// Show green flash on save
|
||||
const flashSaved = useCallback((criterionId: string) => {
|
||||
setSavedCriterion(criterionId)
|
||||
setTimeout(() => setSavedCriterion(null), 800)
|
||||
}, [])
|
||||
|
||||
// Handle score change with debounce
|
||||
const handleScoreChange = useCallback(
|
||||
(criterionId: string, value: number) => {
|
||||
if (!juror || !teamId) return
|
||||
|
||||
setScores(prev => ({ ...prev, [criterionId]: value }))
|
||||
|
||||
// Clear existing debounce timer for this criterion
|
||||
const existing = debounceTimers.current.get(criterionId)
|
||||
if (existing) clearTimeout(existing)
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
submitScore({
|
||||
jurorId: juror.id,
|
||||
teamId,
|
||||
criterionId,
|
||||
value,
|
||||
})
|
||||
flashSaved(criterionId)
|
||||
}, DEBOUNCE_MS)
|
||||
|
||||
debounceTimers.current.set(criterionId, timer)
|
||||
},
|
||||
[juror, teamId, submitScore, flashSaved]
|
||||
)
|
||||
|
||||
// Navigation
|
||||
const currentIndex = teams.findIndex(t => t.id === teamId)
|
||||
const prevTeam = currentIndex > 0 ? teams[currentIndex - 1] : null
|
||||
const nextTeam = currentIndex < teams.length - 1 ? teams[currentIndex + 1] : null
|
||||
|
||||
if (!juror || loading) {
|
||||
return (
|
||||
<div className="page-container" style={styles.center}>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Laden...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
return (
|
||||
<div className="page-container" style={styles.center}>
|
||||
<p style={{ color: '#ff6b6b' }}>Team nicht gefunden</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container" style={styles.page}>
|
||||
{/* Offline indicator */}
|
||||
{(!isOnline || pendingCount > 0) && (
|
||||
<div style={styles.offlineBanner}>
|
||||
{!isOnline ? '⚡ Offline' : `↑ ${pendingCount} ausstehend`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<header style={styles.header}>
|
||||
<button onClick={() => navigate('/vote')} style={styles.backBtn}>
|
||||
← Zurück
|
||||
</button>
|
||||
<h1 style={styles.teamName}>{team.name}</h1>
|
||||
<p style={styles.teamDesc}>{team.description}</p>
|
||||
</header>
|
||||
|
||||
{/* Score inputs */}
|
||||
<div style={styles.criteriaList}>
|
||||
{criteria.map(criterion => {
|
||||
const value = scores[criterion.id] || 0
|
||||
const isSaved = savedCriterion === criterion.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={criterion.id}
|
||||
style={{
|
||||
...styles.criterionCard,
|
||||
borderColor: isSaved
|
||||
? 'rgba(0, 255, 0, 0.4)'
|
||||
: 'var(--glass-border)',
|
||||
boxShadow: isSaved
|
||||
? '0 0 12px rgba(0, 255, 0, 0.15)'
|
||||
: 'none',
|
||||
}}
|
||||
className="glass-panel"
|
||||
>
|
||||
<div style={styles.criterionHeader}>
|
||||
<span style={styles.criterionName}>{criterion.nameDE}</span>
|
||||
<span
|
||||
style={{
|
||||
...styles.criterionValue,
|
||||
color: value > 0 ? 'var(--accent-color)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{value > 0 ? value : '–'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.stepperRow}>
|
||||
<button
|
||||
style={styles.stepperBtn}
|
||||
onClick={() => {
|
||||
if (value > 1) handleScoreChange(criterion.id, value - 1)
|
||||
}}
|
||||
disabled={value <= 1}
|
||||
aria-label={`${criterion.nameDE} verringern`}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={10}
|
||||
value={value || 1}
|
||||
onChange={e =>
|
||||
handleScoreChange(criterion.id, parseInt(e.target.value, 10))
|
||||
}
|
||||
style={styles.slider}
|
||||
aria-label={`${criterion.nameDE} Bewertung`}
|
||||
/>
|
||||
<button
|
||||
style={styles.stepperBtn}
|
||||
onClick={() => {
|
||||
if (value < 10) handleScoreChange(criterion.id, (value || 0) + 1)
|
||||
else if (value === 0) handleScoreChange(criterion.id, 1)
|
||||
}}
|
||||
disabled={value >= 10}
|
||||
aria-label={`${criterion.nameDE} erhöhen`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div style={styles.scaleLabels}>
|
||||
<span>1</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div style={styles.navRow}>
|
||||
<button
|
||||
onClick={() => prevTeam && navigate(`/vote/${prevTeam.id}`)}
|
||||
disabled={!prevTeam}
|
||||
style={{
|
||||
...styles.navBtn,
|
||||
opacity: prevTeam ? 1 : 0.3,
|
||||
}}
|
||||
>
|
||||
← Vorheriges
|
||||
</button>
|
||||
<button
|
||||
onClick={() => nextTeam && navigate(`/vote/${nextTeam.id}`)}
|
||||
disabled={!nextTeam}
|
||||
style={{
|
||||
...styles.navBtn,
|
||||
opacity: nextTeam ? 1 : 0.3,
|
||||
}}
|
||||
>
|
||||
Nächstes →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
page: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
paddingBottom: '40px',
|
||||
},
|
||||
center: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
},
|
||||
offlineBanner: {
|
||||
position: 'fixed',
|
||||
top: '12px',
|
||||
right: '12px',
|
||||
background: 'rgba(255, 170, 0, 0.15)',
|
||||
border: '1px solid rgba(255, 170, 0, 0.3)',
|
||||
color: '#ffaa00',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '50px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
zIndex: 100,
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
},
|
||||
backBtn: {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '0.85rem',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
padding: 0,
|
||||
alignSelf: 'flex-start',
|
||||
marginBottom: '4px',
|
||||
},
|
||||
teamName: {
|
||||
fontSize: '1.6rem',
|
||||
fontWeight: 800,
|
||||
},
|
||||
teamDesc: {
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '0.9rem',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
criteriaList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '14px',
|
||||
},
|
||||
criterionCard: {
|
||||
padding: '18px',
|
||||
borderRadius: '14px',
|
||||
transition: 'border-color 0.3s ease, box-shadow 0.3s ease',
|
||||
},
|
||||
criterionHeader: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '12px',
|
||||
},
|
||||
criterionName: {
|
||||
fontSize: '0.95rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
criterionValue: {
|
||||
fontSize: '1.4rem',
|
||||
fontWeight: 800,
|
||||
minWidth: '28px',
|
||||
textAlign: 'right' as const,
|
||||
},
|
||||
stepperRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
},
|
||||
stepperBtn: {
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '50%',
|
||||
border: '1px solid var(--glass-border)',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '1.2rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: 'inherit',
|
||||
transition: 'all 0.2s ease',
|
||||
flexShrink: 0,
|
||||
},
|
||||
slider: {
|
||||
flex: 1,
|
||||
height: '6px',
|
||||
appearance: 'none' as const,
|
||||
WebkitAppearance: 'none' as const,
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '3px',
|
||||
outline: 'none',
|
||||
cursor: 'pointer',
|
||||
accentColor: 'var(--accent-color)',
|
||||
},
|
||||
scaleLabels: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
paddingLeft: '46px',
|
||||
paddingRight: '46px',
|
||||
marginTop: '4px',
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--text-secondary)',
|
||||
},
|
||||
navRow: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: '12px',
|
||||
marginTop: '12px',
|
||||
},
|
||||
navBtn: {
|
||||
flex: 1,
|
||||
padding: '14px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid var(--glass-border)',
|
||||
background: 'var(--glass-bg)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
transition: 'all 0.2s ease',
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import type { Team, Score } from '../../shared/types'
|
||||
|
||||
const STORAGE_KEY = 'jury-voting-juror'
|
||||
const CRITERIA_COUNT = 5
|
||||
|
||||
interface JurorInfo {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type CompletionStatus = 'complete' | 'partial' | 'pending'
|
||||
|
||||
function getCompletionStatus(teamId: string, scores: Score[]): CompletionStatus {
|
||||
const teamScores = scores.filter(s => s.teamId === teamId)
|
||||
if (teamScores.length === 0) return 'pending'
|
||||
if (teamScores.length >= CRITERIA_COUNT) return 'complete'
|
||||
return 'partial'
|
||||
}
|
||||
|
||||
function getStatusIcon(status: CompletionStatus): string {
|
||||
switch (status) {
|
||||
case 'complete': return '✓'
|
||||
case 'partial': return '◐'
|
||||
case 'pending': return '○'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status: CompletionStatus): string {
|
||||
switch (status) {
|
||||
case 'complete': return 'var(--accent-color)'
|
||||
case 'partial': return '#ffaa00'
|
||||
case 'pending': return 'var(--text-secondary)'
|
||||
}
|
||||
}
|
||||
|
||||
export default function VotingDashboard() {
|
||||
const navigate = useNavigate()
|
||||
const [juror, setJuror] = useState<JurorInfo | null>(null)
|
||||
const [teams, setTeams] = useState<Team[]>([])
|
||||
const [scores, setScores] = useState<Score[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (!stored) {
|
||||
navigate('/', { replace: true })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as JurorInfo
|
||||
setJuror(parsed)
|
||||
} catch {
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
useEffect(() => {
|
||||
if (!juror) return
|
||||
|
||||
Promise.all([
|
||||
fetch('/api/teams').then(r => r.json()),
|
||||
fetch(`/api/scores/${juror.id}`).then(r => r.json()),
|
||||
])
|
||||
.then(([teamsData, scoresData]) => {
|
||||
setTeams(teamsData as Team[])
|
||||
setScores(scoresData as Score[])
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => setLoading(false))
|
||||
}, [juror])
|
||||
|
||||
if (!juror || loading) {
|
||||
return (
|
||||
<div className="page-container" style={styles.center}>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Laden...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const completedCount = teams.filter(
|
||||
t => getCompletionStatus(t.id, scores) === 'complete'
|
||||
).length
|
||||
|
||||
return (
|
||||
<div className="page-container" style={styles.page}>
|
||||
<header style={styles.header}>
|
||||
<div style={styles.headerTop}>
|
||||
<p style={styles.jurorName}>{juror.name}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
navigate('/')
|
||||
}}
|
||||
style={styles.logoutBtn}
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
<div style={styles.progress}>
|
||||
<span style={styles.progressText}>
|
||||
{completedCount}/{teams.length} Teams bewertet
|
||||
</span>
|
||||
<div style={styles.progressBar}>
|
||||
<div
|
||||
style={{
|
||||
...styles.progressFill,
|
||||
width: `${(completedCount / teams.length) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={styles.teamList}>
|
||||
{teams.map(team => {
|
||||
const status = getCompletionStatus(team.id, scores)
|
||||
return (
|
||||
<button
|
||||
key={team.id}
|
||||
onClick={() => navigate(`/vote/${team.id}`)}
|
||||
style={styles.teamCard}
|
||||
className="glass-panel"
|
||||
aria-label={`Team ${team.name} bewerten`}
|
||||
>
|
||||
<div style={styles.teamInfo}>
|
||||
<span style={styles.teamName}>{team.name}</span>
|
||||
<span style={styles.teamDesc}>
|
||||
{team.description.length > 50
|
||||
? team.description.slice(0, 50) + '…'
|
||||
: team.description}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
...styles.statusIcon,
|
||||
color: getStatusColor(status),
|
||||
}}
|
||||
>
|
||||
{getStatusIcon(status)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
page: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
paddingBottom: '40px',
|
||||
},
|
||||
center: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
},
|
||||
headerTop: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
jurorName: {
|
||||
fontSize: '1.4rem',
|
||||
fontWeight: 800,
|
||||
},
|
||||
logoutBtn: {
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
color: 'var(--text-secondary)',
|
||||
padding: '6px 14px',
|
||||
borderRadius: '50px',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
progress: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
},
|
||||
progressText: {
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text-secondary)',
|
||||
},
|
||||
progressBar: {
|
||||
height: '4px',
|
||||
background: 'rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
background: 'var(--accent-color)',
|
||||
borderRadius: '2px',
|
||||
transition: 'width 0.3s ease',
|
||||
},
|
||||
teamList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
},
|
||||
teamCard: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px 18px',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid var(--glass-border)',
|
||||
background: 'var(--glass-bg)',
|
||||
borderRadius: '14px',
|
||||
transition: 'all 0.2s ease',
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
color: 'var(--text-primary)',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '1rem',
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
},
|
||||
teamInfo: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
teamName: {
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
},
|
||||
teamDesc: {
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--text-secondary)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
statusIcon: {
|
||||
fontSize: '1.3rem',
|
||||
marginLeft: '12px',
|
||||
flexShrink: 0,
|
||||
},
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user