feat(docs, ui): restructure frontend documentation & introduce Vision directories for modularity

- Moved existing FIGMA-related files into `Vison_01` and `Vision_02` folders to better support versioning and collaboration.
- Added PostCSS configuration for extending plugins in Tailwind CSS.
- Introduced new style guidelines, theme configurations, and modular imports for `Vision_02`.
- Documented detailed ÖTO tournament structures and parameters for CSN/CDN inclusions.
- Enhanced routing and UI files for future scalability, including new `theme.tsx` and `routes.tsx`.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-03-24 09:59:59 +01:00
parent e1514cfbce
commit 5a545182f2
155 changed files with 13832 additions and 0 deletions
@@ -0,0 +1,16 @@
import {ThemeProvider} from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import {RouterProvider} from 'react-router';
import {theme} from './theme';
import {router} from './routes';
function App() {
return (
<ThemeProvider theme={theme}>
<CssBaseline/>
<RouterProvider router={router}/>
</ThemeProvider>
);
}
export default App;
@@ -0,0 +1,139 @@
import {useState} from 'react';
import Box from '@mui/material/Box';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button';
import RefreshIcon from '@mui/icons-material/Refresh';
import FilterListIcon from '@mui/icons-material/FilterList';
// Mock-Daten für Bewerbe
const mockBewerbe = [
{tag: 'So', platz: 1, nr: '1', beginn: '08:00', nenn: 0, name: 'Dressurreiterprüfung Ratepass', klasse: 'A'},
{tag: 'So', platz: 1, nr: '2', beginn: '08:20', nenn: 0, name: 'Dressurreiterprüfung Katecnadel', klasse: 'L'},
{tag: 'So', platz: 1, nr: '3', beginn: '08:40', nenn: 0, name: 'Dressurreiterprüfung Idf. (Idf.)', klasse: 'M'},
{tag: 'So', platz: 1, nr: '4', beginn: '09:00', nenn: 0, name: 'Dressurprüfung Idf. (Idf.)', klasse: 'L'},
{tag: 'So', platz: 1, nr: '5', beginn: '09:20', nenn: 0, name: 'Führzügelklasse', klasse: 'E'},
{tag: 'So', platz: 1, nr: '6', beginn: '09:40', nenn: 0, name: 'First Ridden', klasse: 'E'},
{tag: 'So', platz: 1, nr: '7', beginn: '10:00', nenn: 0, name: 'Pony Dressurprüfung Kl. A', klasse: 'A'},
{tag: 'So', platz: 1, nr: '8', beginn: '10:20', nenn: 0, name: 'Dressurreiterprüfung Kl. A', klasse: 'A'},
{tag: 'So', platz: 1, nr: '9', beginn: '10:40', nenn: 0, name: 'Dressurprüfung Kl. A', klasse: 'A'},
{tag: 'So', platz: 1, nr: '10', beginn: '11:00', nenn: 0, name: 'Pony Dressurprüfung Kl. A', klasse: 'A'},
{tag: 'So', platz: 1, nr: '11', beginn: '11:20', nenn: 0, name: 'Dressurreiterprüfung Kl. L', klasse: 'L'},
{tag: 'So', platz: 1, nr: '12', beginn: '11:40', nenn: 0, name: 'Dressurprüfung Kl. L', klasse: 'L'},
];
interface Props {
selectedPferd: any;
selectedReiter: any;
onNennung: (bewerb: any) => void;
}
export function Bewerbsliste({selectedPferd, selectedReiter, onNennung}: Props) {
const [selectedBewerb, setSelectedBewerb] = useState<string | null>(null);
const handleBewerbDoppelklick = (bewerb: any) => {
if (selectedPferd && selectedReiter) {
onNennung(bewerb);
setSelectedBewerb(bewerb.nr);
}
};
const canNennen = selectedPferd && selectedReiter;
return (
<Box sx={{display: 'flex', flexDirection: 'column', height: '100%', p: 1.5}}>
<Typography variant="caption" sx={{mb: 1, fontWeight: 600, fontSize: '11px'}}>
Bewerbsübersicht
</Typography>
<Toolbar variant="dense" sx={{
minHeight: 28,
px: 1,
gap: 1,
bgcolor: 'background.paper',
borderRadius: 1,
mb: 1,
border: 1,
borderColor: 'divider'
}}>
<IconButton size="small" sx={{width: 24, height: 24}}>
<RefreshIcon sx={{fontSize: 16}}/>
</IconButton>
<Typography variant="caption" sx={{fontSize: '10px'}}>
Aktualisieren
</Typography>
<Typography variant="caption" sx={{flex: 1, textAlign: 'center', fontWeight: 600, fontSize: '10px'}}>
{mockBewerbe.length} Bewerbe
</Typography>
<Button size="small" variant="text" startIcon={<FilterListIcon sx={{fontSize: 14}}/>}
sx={{fontSize: '10px', py: 0.25}}>
Filtern
</Button>
<Typography variant="caption" color="text.secondary" sx={{fontSize: '10px'}}>
0 gefiltert
</Typography>
</Toolbar>
<TableContainer sx={{flex: 1, border: 1, borderColor: 'divider', borderRadius: 1}}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5}}>Tag</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5}}>Pl.</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5}}>Bewerb</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5}}>Beginn</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5}} align="center">Nenn.</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5}}>Bewerbsname</TableCell>
</TableRow>
</TableHead>
<TableBody>
{mockBewerbe.map((bewerb, idx) => {
const isSelected = selectedBewerb === bewerb.nr;
const isClickable = canNennen;
return (
<TableRow
key={idx}
hover={isClickable}
selected={isSelected}
onDoubleClick={() => handleBewerbDoppelklick(bewerb)}
sx={{
cursor: isClickable ? 'pointer' : 'default',
'&:nth-of-type(odd)': {bgcolor: isSelected ? 'primary.100' : 'action.hover'},
'&.Mui-selected': {
bgcolor: 'primary.100',
'&:hover': {
bgcolor: 'primary.200',
},
},
opacity: isClickable ? 1 : 0.5,
}}
>
<TableCell sx={{fontSize: '10px', py: 0.5}}>{bewerb.tag}</TableCell>
<TableCell sx={{fontSize: '10px', py: 0.5}}>{bewerb.platz}</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5}}>{bewerb.nr}</TableCell>
<TableCell sx={{fontSize: '10px', py: 0.5}}>{bewerb.beginn}</TableCell>
<TableCell sx={{fontSize: '10px', py: 0.5}} align="center">{bewerb.nenn}</TableCell>
<TableCell sx={{fontSize: '10px', py: 0.5}}>{bewerb.name}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
{!canNennen && (
<Typography variant="caption" color="text.secondary" sx={{mt: 1, textAlign: 'center', fontSize: '10px'}}>
Bitte wählen Sie zuerst ein Pferd und einen Reiter aus
</Typography>
)}
</Box>
);
}
@@ -0,0 +1,445 @@
import {useState} from 'react';
import {useNavigate} from 'react-router';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import Grid from '@mui/material/Grid';
import Paper from '@mui/material/Paper';
import SearchIcon from '@mui/icons-material/Search';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
import LocationOnIcon from '@mui/icons-material/LocationOn';
import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import WarningIcon from '@mui/icons-material/Warning';
import PlayCircleIcon from '@mui/icons-material/PlayCircle';
// Mock-Daten für Veranstaltungen
export const veranstaltungenData = [
{
id: 1,
name: 'Union Reit- und Fahrverein Neumarkt Frühjahrsturnier 2026',
ort: 'Reitanlage Stroblmair, Neumarkt/M., OÖ',
datum: '25.-26. April 2026',
datumVon: new Date('2026-04-25'),
datumBis: new Date('2026-04-26'),
status: 'vorbereitung' as const,
turniere: [
{
nr: '26128',
name: 'CSN-C NEU CSNP-C NEU',
datum: '25.04.2026',
kategorie: 'C',
disziplin: 'Springen',
bewerbeAnzahl: 14,
znsStatus: 'geladen'
},
{
nr: '26129',
name: 'CDN-C NEU CDNP-C NEU',
datum: '26.04.2026',
kategorie: 'C',
disziplin: 'Dressur',
bewerbeAnzahl: 12,
znsStatus: 'geladen'
}
],
nennungen: 87,
letzteAktivitaet: '22.03.2026 14:30'
},
{
id: 2,
name: 'AWÖ-Cup Stadl-Paura 2025',
ort: 'Bundesgestüt Piber, Stadl-Paura',
datum: '15.-17. Mai 2025',
datumVon: new Date('2025-05-15'),
datumBis: new Date('2025-05-17'),
status: 'abgeschlossen' as const,
turniere: [
{
nr: '25001',
name: 'CSN-A',
datum: '15.05.2025',
kategorie: 'A',
disziplin: 'Springen',
bewerbeAnzahl: 18,
znsStatus: 'geladen'
},
{
nr: '25002',
name: 'CDN-A',
datum: '16.05.2025',
kategorie: 'A',
disziplin: 'Dressur',
bewerbeAnzahl: 15,
znsStatus: 'geladen'
}
],
nennungen: 142,
letzteAktivitaet: '17.05.2025 18:45'
},
{
id: 3,
name: 'Linzer Pferdetage 2026',
ort: 'Reitsportzentrum Linz-Ebelsberg',
datum: '12.-14. Juni 2026',
datumVon: new Date('2026-06-12'),
datumBis: new Date('2026-06-14'),
status: 'vorbereitung' as const,
turniere: [
{
nr: '26201',
name: 'CSN-B',
datum: '12.06.2026',
kategorie: 'B',
disziplin: 'Springen',
bewerbeAnzahl: 16,
znsStatus: 'ausstehend'
},
{
nr: '26202',
name: 'CDN-B',
datum: '13.06.2026',
kategorie: 'B',
disziplin: 'Dressur',
bewerbeAnzahl: 14,
znsStatus: 'ausstehend'
}
],
nennungen: 23,
letzteAktivitaet: '20.03.2026 09:15'
}
];
export function AdminVerwaltung() {
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'alle' | 'vorbereitung' | 'live' | 'abgeschlossen'>('alle');
// Statistiken berechnen
const stats = {
gesamt: veranstaltungenData.length,
vorbereitung: veranstaltungenData.filter(v => v.status === 'vorbereitung').length,
live: veranstaltungenData.filter(v => v.status === 'live').length,
abgeschlossen: veranstaltungenData.filter(v => v.status === 'abgeschlossen').length,
};
// Filter
const filteredVeranstaltungen = veranstaltungenData.filter(v => {
const matchesSearch = v.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
v.ort.toLowerCase().includes(searchTerm.toLowerCase()) ||
v.turniere.some(t => t.nr.includes(searchTerm));
const matchesStatus = statusFilter === 'alle' || v.status === statusFilter;
return matchesSearch && matchesStatus;
});
const getStatusColor = (status: string) => {
switch (status) {
case 'vorbereitung':
return 'info';
case 'live':
return 'success';
case 'abgeschlossen':
return 'default';
default:
return 'default';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'vorbereitung':
return <CalendarTodayIcon sx={{fontSize: 14}}/>;
case 'live':
return <PlayCircleIcon sx={{fontSize: 14}}/>;
case 'abgeschlossen':
return <CheckCircleIcon sx={{fontSize: 14}}/>;
default:
return null;
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case 'vorbereitung':
return 'Vorbereitung';
case 'live':
return 'Live';
case 'abgeschlossen':
return 'Abgeschlossen';
default:
return status;
}
};
const handleVeranstaltungOeffnen = (id: number) => {
navigate(`/veranstaltung/${id}`);
};
const handleTurnierOeffnen = (veranstaltungId: number, turnierNr: string) => {
navigate(`/veranstaltung/${veranstaltungId}/turnier/${turnierNr}`);
};
const handleNeueVeranstaltung = () => {
navigate('/veranstaltung/neu');
};
return (
<Box sx={{height: '100vh', bgcolor: 'background.default', display: 'flex', flexDirection: 'column'}}>
{/* Header */}
<Box sx={{bgcolor: 'primary.main', color: 'white', py: 2, px: 3}}>
<Typography variant="h5" sx={{fontSize: '16px', fontWeight: 600}}>
Admin - Verwaltung
</Typography>
</Box>
{/* Content */}
<Box sx={{flex: 1, overflow: 'auto', p: 3}}>
{/* Statistik-Cards - dezent und auf gesamte Breite */}
<Grid container spacing={1.5} sx={{mb: 3}}>
<Grid size={{xs: 12, sm: 3}}>
<Paper sx={{p: 1.5, bgcolor: 'success.50', borderLeft: 3, borderColor: 'success.main'}}>
<Typography variant="body2"
sx={{fontSize: '9px', color: 'text.secondary', textTransform: 'uppercase', mb: 0.5}}>
Live / Aktiv
</Typography>
<Typography variant="h5" sx={{fontSize: '20px', fontWeight: 600, color: 'success.main'}}>
{stats.live}
</Typography>
</Paper>
</Grid>
<Grid size={{xs: 12, sm: 3}}>
<Paper sx={{p: 1.5, bgcolor: 'info.50', borderLeft: 3, borderColor: 'info.main'}}>
<Typography variant="body2"
sx={{fontSize: '9px', color: 'text.secondary', textTransform: 'uppercase', mb: 0.5}}>
In Vorbereitung
</Typography>
<Typography variant="h5" sx={{fontSize: '20px', fontWeight: 600, color: 'info.main'}}>
{stats.vorbereitung}
</Typography>
</Paper>
</Grid>
<Grid size={{xs: 12, sm: 3}}>
<Paper sx={{p: 1.5, bgcolor: 'grey.50', borderLeft: 3, borderColor: 'primary.main'}}>
<Typography variant="body2"
sx={{fontSize: '9px', color: 'text.secondary', textTransform: 'uppercase', mb: 0.5}}>
Gesamt
</Typography>
<Typography variant="h5" sx={{fontSize: '20px', fontWeight: 600, color: 'primary.main'}}>
{stats.gesamt}
</Typography>
</Paper>
</Grid>
<Grid size={{xs: 12, sm: 3}}>
<Paper sx={{p: 1.5, bgcolor: 'grey.50', borderLeft: 3, borderColor: 'grey.500'}}>
<Typography variant="body2"
sx={{fontSize: '9px', color: 'text.secondary', textTransform: 'uppercase', mb: 0.5}}>
Archiv
</Typography>
<Typography variant="h5" sx={{fontSize: '20px', fontWeight: 600, color: 'grey.600'}}>
{stats.abgeschlossen}
</Typography>
</Paper>
</Grid>
</Grid>
{/* Toolbar - neue Reihenfolge */}
<Box sx={{display: 'flex', gap: 2, mb: 3, alignItems: 'center'}}>
<Button
variant="contained"
startIcon={<AddIcon/>}
onClick={handleNeueVeranstaltung}
sx={{fontSize: '11px'}}
>
Neue Veranstaltung
</Button>
<TextField
size="small"
placeholder="Suche nach Name, Ort oder Turnier-Nr..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
sx={{
flex: 1,
maxWidth: 400,
'& .MuiInputBase-input': {fontSize: '11px'}
}}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon sx={{fontSize: 18}}/>
</InputAdornment>
),
}}
/>
<Box sx={{display: 'flex', gap: 1}}>
<Chip
label="Alle"
onClick={() => setStatusFilter('alle')}
color={statusFilter === 'alle' ? 'primary' : 'default'}
size="small"
sx={{fontSize: '10px'}}
/>
<Chip
label="Vorbereitung"
onClick={() => setStatusFilter('vorbereitung')}
color={statusFilter === 'vorbereitung' ? 'primary' : 'default'}
size="small"
sx={{fontSize: '10px'}}
/>
<Chip
label="Live"
onClick={() => setStatusFilter('live')}
color={statusFilter === 'live' ? 'primary' : 'default'}
size="small"
sx={{fontSize: '10px'}}
/>
<Chip
label="Abgeschlossen"
onClick={() => setStatusFilter('abgeschlossen')}
color={statusFilter === 'abgeschlossen' ? 'primary' : 'default'}
size="small"
sx={{fontSize: '10px'}}
/>
</Box>
</Box>
{/* Veranstaltungs-Liste - volle Breite */}
<Box sx={{display: 'flex', flexDirection: 'column', gap: 2}}>
{filteredVeranstaltungen.map((v) => (
<Card key={v.id} sx={{position: 'relative'}}>
<CardContent>
{/* Header mit Status */}
<Box sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1.5}}>
<Typography variant="h6" sx={{fontSize: '13px', fontWeight: 600, flex: 1}}>
{v.name}
</Typography>
<Chip
icon={getStatusIcon(v.status)}
label={getStatusLabel(v.status)}
color={getStatusColor(v.status)}
size="small"
sx={{fontSize: '9px'}}
/>
</Box>
{/* Ort und Datum */}
<Box sx={{display: 'flex', gap: 2, mb: 2}}>
<Box sx={{display: 'flex', alignItems: 'center', gap: 0.5}}>
<LocationOnIcon sx={{fontSize: 14, color: 'text.secondary'}}/>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary'}}>
{v.ort}
</Typography>
</Box>
<Box sx={{display: 'flex', alignItems: 'center', gap: 0.5}}>
<CalendarTodayIcon sx={{fontSize: 14, color: 'text.secondary'}}/>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary'}}>
{v.datum}
</Typography>
</Box>
</Box>
{/* Turniere */}
<Box sx={{mb: 2}}>
<Typography variant="body2" sx={{fontSize: '10px', fontWeight: 600, mb: 1}}>
<EmojiEventsIcon sx={{fontSize: 12, verticalAlign: 'middle', mr: 0.5}}/>
Turniere ({v.turniere.length}):
</Typography>
<Box sx={{display: 'flex', flexDirection: 'column', gap: 1}}>
{v.turniere.map((t) => (
<Box
key={t.nr}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
p: 1,
bgcolor: 'background.default',
borderRadius: 1
}}
>
<Chip
label={t.nr}
size="small"
sx={{fontSize: '9px', fontWeight: 600, minWidth: 60}}
/>
<Typography variant="body2" sx={{fontSize: '10px', flex: 1}}>
{t.name} ({t.bewerbeAnzahl} Bewerbe)
</Typography>
<Chip
label={t.disziplin}
size="small"
variant="outlined"
sx={{fontSize: '8px'}}
/>
{t.kategorie === 'B' || t.kategorie === 'A' ? (
t.znsStatus === 'geladen' ? (
<CheckCircleIcon sx={{fontSize: 14, color: 'success.main'}}
titleAccess="ZNS N2-Daten geladen"/>
) : (
<WarningIcon sx={{fontSize: 14, color: 'warning.main'}}
titleAccess="ZNS N2-Daten ausstehend"/>
)
) : null}
<Button
variant="outlined"
size="small"
onClick={() => handleTurnierOeffnen(v.id, t.nr)}
sx={{fontSize: '9px', py: 0.5, px: 1.5, minWidth: 0}}
>
Öffnen
</Button>
</Box>
))}
</Box>
</Box>
{/* Statistik */}
<Box sx={{display: 'flex', gap: 2, mb: 2}}>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary'}}>
Nennungen: <strong>{v.nennungen}</strong>
</Typography>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary'}}>
Letzte Aktivität: {v.letzteAktivitaet}
</Typography>
</Box>
{/* Actions */}
<Box sx={{display: 'flex', gap: 1, justifyContent: 'flex-end'}}>
<Button
variant="contained"
size="small"
onClick={() => handleVeranstaltungOeffnen(v.id)}
sx={{fontSize: '10px'}}
>
Öffnen
</Button>
<IconButton size="small" sx={{fontSize: '10px', color: 'error.main'}}>
<DeleteIcon sx={{fontSize: 16}}/>
</IconButton>
</Box>
</CardContent>
</Card>
))}
</Box>
{filteredVeranstaltungen.length === 0 && (
<Paper sx={{p: 4, textAlign: 'center'}}>
<Typography variant="body2" sx={{fontSize: '11px', color: 'text.secondary'}}>
Keine Veranstaltungen gefunden
</Typography>
</Paper>
)}
</Box>
</Box>
);
}
@@ -0,0 +1,223 @@
import {useState, useEffect} from 'react';
import {useNavigate} from 'react-router';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import Alert from '@mui/material/Alert';
import CircularProgress from '@mui/material/CircularProgress';
import Visibility from '@mui/icons-material/Visibility';
import VisibilityOff from '@mui/icons-material/VisibilityOff';
import WifiIcon from '@mui/icons-material/Wifi';
import WifiOffIcon from '@mui/icons-material/WifiOff';
export function Login() {
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [isOnline, setIsOnline] = useState(navigator.onLine);
// Internet-Verbindung überwachen
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);
};
}, []);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
// Simulated login delay
await new Promise(resolve => setTimeout(resolve, 800));
// Hardcoded credentials für Phase 1
if (username === 'admin' && password === 'Admin#1234') {
// Login erfolgreich
localStorage.setItem('isAuthenticated', 'true');
localStorage.setItem('userRole', 'admin');
localStorage.setItem('username', username);
navigate('/admin');
} else {
setError('Ungültige Anmeldedaten. Bitte überprüfen Sie Benutzername und Passwort.');
setLoading(false);
}
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'grey.100',
backgroundImage: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
position: 'relative'
}}
>
{/* Internet-Status Anzeige */}
<Box
sx={{
position: 'absolute',
top: 16,
right: 16,
display: 'flex',
alignItems: 'center',
gap: 1,
bgcolor: isOnline ? 'success.main' : 'error.main',
color: 'white',
px: 2,
py: 1,
borderRadius: 2,
fontSize: '11px',
fontWeight: 600
}}
>
{isOnline ? (
<>
<WifiIcon sx={{fontSize: 18}}/>
Online
</>
) : (
<>
<WifiOffIcon sx={{fontSize: 18}}/>
Offline
</>
)}
</Box>
<Paper
elevation={8}
sx={{
width: '100%',
maxWidth: 420,
p: 4,
mx: 2,
borderRadius: 3
}}
>
{/* Logo & Titel */}
<Box sx={{textAlign: 'center', mb: 4}}>
<Typography
variant="h4"
sx={{
fontWeight: 700,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
mb: 1
}}
>
Turnierverwaltung
</Typography>
<Typography variant="body2" sx={{fontSize: '11px', color: 'text.secondary'}}>
Österreichischer Pferdesportverband
</Typography>
</Box>
{/* Fehler-Anzeige */}
{error && (
<Alert severity="error" sx={{mb: 3, fontSize: '11px'}}>
{error}
</Alert>
)}
{/* Login-Formular */}
<form onSubmit={handleLogin}>
<Box sx={{display: 'flex', flexDirection: 'column', gap: 2.5}}>
<TextField
label="Benutzername"
value={username}
onChange={(e) => setUsername(e.target.value)}
fullWidth
autoFocus
disabled={loading}
sx={{'& .MuiInputBase-input': {fontSize: '12px'}}}
/>
<TextField
label="Passwort"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
fullWidth
disabled={loading}
sx={{'& .MuiInputBase-input': {fontSize: '12px'}}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
size="small"
>
{showPassword ? <VisibilityOff/> : <Visibility/>}
</IconButton>
</InputAdornment>
),
}}
/>
<Button
type="submit"
variant="contained"
fullWidth
disabled={loading || !username || !password}
sx={{
py: 1.5,
fontSize: '12px',
fontWeight: 600,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
'&:hover': {
background: 'linear-gradient(135deg, #5568d3 0%, #6a4193 100%)',
}
}}
>
{loading ? (
<CircularProgress size={20} sx={{color: 'white'}}/>
) : (
'Anmelden'
)}
</Button>
</Box>
</form>
{/* Hinweis */}
<Box
sx={{
mt: 4,
p: 2,
bgcolor: 'grey.50',
borderRadius: 2,
border: 1,
borderColor: 'grey.200'
}}
>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary', mb: 1}}>
<strong>Demo-Zugang (Phase 1):</strong>
</Typography>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary', fontFamily: 'monospace'}}>
Benutzer: <strong>admin</strong><br/>
Passwort: <strong>Admin#1234</strong>
</Typography>
</Box>
</Paper>
</Box>
);
}
@@ -0,0 +1,129 @@
import {useState} from 'react';
import Box from '@mui/material/Box';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import RefreshIcon from '@mui/icons-material/Refresh';
interface Props {
nennungen: any[];
selectedPferd: any;
selectedReiter: any;
}
export function NennungenTabelle({nennungen, selectedPferd, selectedReiter}: Props) {
const [tabValue, setTabValue] = useState(0);
// Filter basierend auf Tab
const getFilteredNennungen = () => {
if (!selectedPferd && !selectedReiter) return [];
switch (tabValue) {
case 0: // Reiter
return selectedReiter
? nennungen.filter(n => n.reiter === selectedReiter.vorname + ' ' + selectedReiter.nachname)
: [];
case 1: // Pferd
return selectedPferd
? nennungen.filter(n => n.pferd === selectedPferd.name)
: [];
case 2: // Bewerbe
return (selectedPferd && selectedReiter)
? nennungen.filter(n =>
n.pferd === selectedPferd.name &&
n.reiter === selectedReiter.vorname + ' ' + selectedReiter.nachname
)
: [];
default:
return [];
}
};
const filteredNennungen = getFilteredNennungen();
return (
<Box sx={{display: 'flex', flexDirection: 'column', height: '100%'}}>
<Tabs value={tabValue} onChange={(_, v) => setTabValue(v)}
sx={{borderBottom: 1, borderColor: 'divider', minHeight: 32}}>
<Tab label="Reiter" sx={{minHeight: 32, fontSize: '11px', py: 0.5}}/>
<Tab label="Pferd" sx={{minHeight: 32, fontSize: '11px', py: 0.5}}/>
<Tab label="Bewerbe" sx={{minHeight: 32, fontSize: '11px', py: 0.5}}/>
</Tabs>
<Toolbar variant="dense" sx={{
minHeight: 28,
px: 1,
gap: 1,
bgcolor: 'background.paper',
borderBottom: 1,
borderColor: 'divider'
}}>
<IconButton size="small" sx={{width: 24, height: 24}}>
<RefreshIcon sx={{fontSize: 16}}/>
</IconButton>
<Typography variant="caption" sx={{fontSize: '10px'}}>
Aktualisieren
</Typography>
<Typography variant="caption" sx={{flex: 1, textAlign: 'center', fontWeight: 600, fontSize: '10px'}}>
{filteredNennungen.length} Nennungen
</Typography>
<Button size="small" variant="text" sx={{fontSize: '10px', py: 0.25}}>
Positionieren
</Button>
<Button size="small" variant="text" color="error" sx={{fontSize: '10px', py: 0.25}}>
Stornieren
</Button>
</Toolbar>
<TableContainer sx={{flex: 1}}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5}}>Tag</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5}}>Pl.</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5}}>Bewerb</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5}}>Bewerbsname</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5}}>Bemerkung</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5}}>Pferd</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredNennungen.length === 0 ? (
<TableRow>
<TableCell colSpan={6} sx={{textAlign: 'center', color: 'text.secondary', fontSize: '10px', py: 2}}>
Keine Nennungen vorhanden
</TableCell>
</TableRow>
) : (
filteredNennungen.map((nennung, idx) => (
<TableRow
key={idx}
sx={{
'&:nth-of-type(odd)': {bgcolor: 'action.hover'},
bgcolor: nennung.startwunsch === 'Vorne' ? 'success.50' : nennung.startwunsch === 'Hinten' ? 'info.50' : undefined,
}}
>
<TableCell sx={{fontSize: '10px', py: 0.5}}>{nennung.tag}</TableCell>
<TableCell sx={{fontSize: '10px', py: 0.5}}>{nennung.platz}</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5}}>{nennung.bewerbNr}</TableCell>
<TableCell sx={{fontSize: '10px', py: 0.5}}>{nennung.bewerbName}</TableCell>
<TableCell sx={{fontSize: '10px', py: 0.5}}>{nennung.startwunsch || '-'}</TableCell>
<TableCell sx={{fontSize: '10px', py: 0.5}}>{nennung.pferd}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Box>
);
}
@@ -0,0 +1,115 @@
import {useState} from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import {PferdReiterEingabe} from './PferdReiterEingabe';
import {NennungenTabelle} from './NennungenTabelle';
import {VerkaufBuchungen} from './VerkaufBuchungen';
import {Bewerbsliste} from './Bewerbsliste';
import ListIcon from '@mui/icons-material/List';
import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
import ReceiptIcon from '@mui/icons-material/Receipt';
export function NennungsMaske() {
const [selectedPferd, setSelectedPferd] = useState<any>(null);
const [selectedReiter, setSelectedReiter] = useState<any>(null);
const [nennungen, setNennungen] = useState<any[]>([]);
const handleNennung = (bewerb: any) => {
if (selectedPferd && selectedReiter) {
const neueNennung = {
tag: bewerb.tag,
platz: bewerb.platz,
bewerbNr: bewerb.nr,
bewerbName: bewerb.name,
beginn: bewerb.beginn,
pferd: selectedPferd.name,
reiter: `${selectedReiter.vorname} ${selectedReiter.nachname}`,
startwunsch: null,
};
setNennungen([...nennungen, neueNennung]);
}
};
return (
<Box sx={{display: 'flex', flexDirection: 'column', height: '100vh', bgcolor: 'background.default'}}>
{/* Zeile 1 (50% Höhe): Pferd/Reiter Suche + Verkauf/Buchungen */}
<Box sx={{height: '50%', display: 'flex', borderBottom: 1, borderColor: 'divider'}}>
{/* Links: Pferd & Reiter Eingabe (60%) */}
<Box sx={{width: '60%', borderRight: 1, borderColor: 'divider'}}>
<PferdReiterEingabe
selectedPferd={selectedPferd}
setSelectedPferd={setSelectedPferd}
selectedReiter={selectedReiter}
setSelectedReiter={setSelectedReiter}
/>
</Box>
{/* Rechts: Verkauf/Buchungen (40%) */}
<Box sx={{width: '40%'}}>
<VerkaufBuchungen selectedReiter={selectedReiter}/>
</Box>
</Box>
{/* Zeile 2 (5% Höhe): Navigation Buttons */}
<Box
sx={{
height: '5%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
borderBottom: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
px: 2,
}}
>
<Button
variant="contained"
size="small"
startIcon={<ListIcon fontSize="small"/>}
sx={{minWidth: 130, fontSize: '11px', py: 0.5}}
>
Startliste
</Button>
<Button
variant="contained"
size="small"
startIcon={<EmojiEventsIcon fontSize="small"/>}
sx={{minWidth: 130, fontSize: '11px', py: 0.5}}
>
Ergebnisse
</Button>
<Button
variant="contained"
size="small"
startIcon={<ReceiptIcon fontSize="small"/>}
sx={{minWidth: 130, fontSize: '11px', py: 0.5}}
>
Abrechnung
</Button>
</Box>
{/* Zeile 3 (45% Höhe): Nennungsübersicht + Bewerbsübersicht */}
<Box sx={{flex: 1, display: 'flex', minHeight: 0}}>
{/* Links: Nennungsübersicht (60%) */}
<Box sx={{width: '60%', borderRight: 1, borderColor: 'divider'}}>
<NennungenTabelle
nennungen={nennungen}
selectedPferd={selectedPferd}
selectedReiter={selectedReiter}
/>
</Box>
{/* Rechts: Bewerbsübersicht (40%) */}
<Box sx={{width: '40%'}}>
<Bewerbsliste
selectedPferd={selectedPferd}
selectedReiter={selectedReiter}
onNennung={handleNennung}
/>
</Box>
</Box>
</Box>
);
}
@@ -0,0 +1,558 @@
import {useState, useEffect, useRef} from 'react';
import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import Chip from '@mui/material/Chip';
import Badge from '@mui/material/Badge';
// Mock-Daten für Pferde
const mockPferde = [
{
id: 1,
kopfnr: 'A123',
name: "Obora's Donna",
rasse: 'Hannoveraner',
farbe: 'Brauner',
besitzer: 'Franz Huber',
stall: 'Box 12'
},
{
id: 2,
kopfnr: 'H597',
name: 'Weltmeyer',
rasse: 'Trakehner',
farbe: 'Schimmel',
besitzer: 'Maria Gruber',
stall: 'Box 8'
},
{
id: 3,
kopfnr: '9939',
name: 'Rubinstein',
rasse: 'Westfale',
farbe: 'Fuchs',
besitzer: 'Johann Maier',
stall: 'Box 15'
},
{
id: 4,
kopfnr: 'D456',
name: "Obora's Danilo",
rasse: 'Oldenburger',
farbe: 'Rappe',
besitzer: 'Anna Schmidt',
stall: 'Box 3'
},
{
id: 5,
kopfnr: '4568',
name: 'Domino',
rasse: 'Holsteiner',
farbe: 'Brauner',
besitzer: 'Thomas Bauer',
stall: 'Box 5'
},
{
id: 6,
kopfnr: 'B789',
name: "Obora's Dream",
rasse: 'Hannoveraner',
farbe: 'Fuchs',
besitzer: 'Franz Huber',
stall: 'Box 14'
},
];
// Mock-Daten für Reiter
const mockReiter = [
{
id: 1,
kopfnr: '201',
vorname: 'Anna',
nachname: 'Schneider',
verein: 'RV Wien',
lizenz: 'LNR-2024-4587',
lizenzGueltig: true,
kontoSaldo: 0,
geburtsjahr: 1995
},
{
id: 2,
kopfnr: '202',
vorname: 'Thomas',
nachname: 'Bauer',
verein: 'RC Graz',
lizenz: 'LNR-2023-1234',
lizenzGueltig: false,
kontoSaldo: -125.50,
geburtsjahr: 1998
},
{
id: 3,
kopfnr: '203',
vorname: 'Sophie',
nachname: 'Wagner',
verein: 'RFV Salzburg',
lizenz: 'LNR-2024-9876',
lizenzGueltig: true,
kontoSaldo: 50.00,
geburtsjahr: 1992
},
{
id: 4,
kopfnr: '204',
vorname: 'Michael',
nachname: 'Müller',
verein: 'RC Innsbruck',
lizenz: 'LNR-2024-5555',
lizenzGueltig: true,
kontoSaldo: 0,
geburtsjahr: 2001
},
{
id: 5,
kopfnr: '205',
vorname: 'Franz',
nachname: 'Huber',
verein: 'RV Linz',
lizenz: 'LNR-2024-7777',
lizenzGueltig: true,
kontoSaldo: 0,
geburtsjahr: 2002
},
{
id: 6,
kopfnr: '206',
vorname: 'Franz',
nachname: 'Huber',
verein: 'RC Wien',
lizenz: 'LNR-2024-8888',
lizenzGueltig: true,
kontoSaldo: 0,
geburtsjahr: 1998
},
];
// Mock-Daten für bereits getätigte Nennungen (IMS = Im System)
const turnieNennungen = [
{reiterId: 2, pferdId: 5, bewerbNr: 3}, // Thomas Bauer mit Domino in Bewerb 3
{reiterId: 1, pferdId: 1, bewerbNr: 2}, // Anna Schneider mit Obora's Donna in Bewerb 2
{reiterId: 1, pferdId: 2, bewerbNr: 5}, // Anna Schneider mit Weltmeyer in Bewerb 5
];
interface Props {
selectedPferd: any;
setSelectedPferd: (pferd: any) => void;
selectedReiter: any;
setSelectedReiter: (reiter: any) => void;
}
export function PferdReiterEingabe({selectedPferd, setSelectedPferd, selectedReiter, setSelectedReiter}: Props) {
const [pferdSuche, setPferdSuche] = useState('');
const [reiterSuche, setReiterSuche] = useState('');
const [pferdErgebnisse, setPferdErgebnisse] = useState<any[]>([]);
const [reiterErgebnisse, setReiterErgebnisse] = useState<any[]>([]);
const [selectedPferdIndex, setSelectedPferdIndex] = useState(0);
const [selectedReiterIndex, setSelectedReiterIndex] = useState(0);
const pferdInputRef = useRef<HTMLInputElement>(null);
const reiterInputRef = useRef<HTMLInputElement>(null);
// Autofokus auf Pferd-Suchfeld beim Laden
useEffect(() => {
pferdInputRef.current?.focus();
}, []);
// Pferd-Suche
useEffect(() => {
if (pferdSuche.length > 0) {
// Normale Suche nach Eingabe
const results = mockPferde.filter(p =>
p.kopfnr.toLowerCase().includes(pferdSuche.toLowerCase()) ||
p.name.toLowerCase().includes(pferdSuche.toLowerCase())
);
setPferdErgebnisse(results);
setSelectedPferdIndex(0);
} else if (selectedReiter && !pferdSuche) {
// Cross-Reference: Zeige Pferde des ausgewählten Reiters
const reiterPferde = turnieNennungen
.filter(n => n.reiterId === selectedReiter.id)
.map(n => mockPferde.find(p => p.id === n.pferdId))
.filter(Boolean);
setPferdErgebnisse(reiterPferde);
} else {
setPferdErgebnisse([]);
}
}, [pferdSuche, selectedReiter]);
// Reiter-Suche
useEffect(() => {
if (reiterSuche.length > 0) {
// Normale Suche nach Eingabe
const results = mockReiter.filter(r =>
r.vorname.toLowerCase().includes(reiterSuche.toLowerCase()) ||
r.nachname.toLowerCase().includes(reiterSuche.toLowerCase()) ||
`${r.vorname} ${r.nachname}`.toLowerCase().includes(reiterSuche.toLowerCase())
);
setReiterErgebnisse(results);
setSelectedReiterIndex(0);
} else if (selectedPferd && !reiterSuche) {
// Cross-Reference: Zeige Reiter des ausgewählten Pferdes
const pferdReiter = turnieNennungen
.filter(n => n.pferdId === selectedPferd.id)
.map(n => mockReiter.find(r => r.id === n.reiterId))
.filter(Boolean);
setReiterErgebnisse(pferdReiter);
} else {
setReiterErgebnisse([]);
}
}, [reiterSuche, selectedPferd]);
// Hilfsfunktion: Prüft ob Pferd im System ist (IMS)
const isPferdIMS = (pferdId: number) => {
return turnieNennungen.some(n => n.pferdId === pferdId);
};
// Hilfsfunktion: Prüft ob Reiter im System ist (IMS)
const isReiterIMS = (reiterId: number) => {
return turnieNennungen.some(n => n.reiterId === reiterId);
};
// Pferd auswählen
const handlePferdAuswahl = (pferd: any) => {
setSelectedPferd(pferd);
// Cross-Reference: Zeige Reiter dieses Pferdes
const pferdReiter = turnieNennungen
.filter(n => n.pferdId === pferd.id)
.map(n => mockReiter.find(r => r.id === n.reiterId))
.filter(Boolean);
if (pferdReiter.length > 0) {
setReiterErgebnisse(pferdReiter);
}
reiterInputRef.current?.focus();
};
// Reiter auswählen
const handleReiterAuswahl = (reiter: any) => {
setSelectedReiter(reiter);
// Cross-Reference: Zeige Pferde dieses Reiters
const reiterPferde = turnieNennungen
.filter(n => n.reiterId === reiter.id)
.map(n => mockPferde.find(p => p.id === n.pferdId))
.filter(Boolean);
if (reiterPferde.length > 0) {
setPferdErgebnisse(reiterPferde);
}
};
// Keyboard Navigation für Pferd
const handlePferdKeyDown = (e: React.KeyboardEvent) => {
if (pferdErgebnisse.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedPferdIndex(prev => Math.min(prev + 1, pferdErgebnisse.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedPferdIndex(prev => Math.max(prev - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
if (pferdErgebnisse[selectedPferdIndex]) {
handlePferdAuswahl(pferdErgebnisse[selectedPferdIndex]);
}
}
};
// Keyboard Navigation für Reiter
const handleReiterKeyDown = (e: React.KeyboardEvent) => {
if (reiterErgebnisse.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedReiterIndex(prev => Math.min(prev + 1, reiterErgebnisse.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedReiterIndex(prev => Math.max(prev - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
if (reiterErgebnisse[selectedReiterIndex]) {
handleReiterAuswahl(reiterErgebnisse[selectedReiterIndex]);
}
}
};
const handlePferdLeeren = () => {
setPferdSuche('');
setSelectedPferd(null);
setPferdErgebnisse([]);
pferdInputRef.current?.focus();
};
const handleReiterLeeren = () => {
setReiterSuche('');
setSelectedReiter(null);
setReiterErgebnisse([]);
reiterInputRef.current?.focus();
};
return (
<Box sx={{display: 'flex', height: '100%'}}>
{/* Linke Hälfte: Pferd */}
<Box sx={{
flex: 1,
borderRight: 1,
borderColor: 'divider',
p: 1.5,
display: 'flex',
flexDirection: 'column',
gap: 1
}}>
{/* Eingabefeld */}
<Box sx={{display: 'flex', gap: 1, alignItems: 'center'}}>
<Typography variant="caption" sx={{fontWeight: 600, minWidth: 50, fontSize: '11px'}}>
Pferd:
</Typography>
<TextField
inputRef={pferdInputRef}
fullWidth
size="small"
placeholder="Kopfnummer oder Name"
value={pferdSuche}
onChange={(e) => setPferdSuche(e.target.value)}
onKeyDown={handlePferdKeyDown}
sx={{
flex: 1,
'& .MuiInputBase-input': {fontSize: '11px', py: 0.75},
}}
/>
<Button variant="outlined" size="small" sx={{minWidth: 30, px: 0.5, fontSize: '10px'}}>
...
</Button>
<Button variant="outlined" size="small" onClick={handlePferdLeeren} sx={{fontSize: '10px', px: 1}}>
Leeren
</Button>
</Box>
{/* Suchergebnisse - bleiben immer sichtbar */}
<Paper
variant="outlined"
sx={{
height: selectedPferd ? '25%' : '50%',
overflow: 'auto',
transition: 'height 0.2s ease',
}}
>
<List dense disablePadding>
{pferdErgebnisse.length > 0 ? (
(pferdSuche ? pferdErgebnisse : pferdErgebnisse.slice(0, 4)).map((pferd, idx) => {
const istIMS = isPferdIMS(pferd.id);
return (
<ListItem key={pferd.id} disablePadding>
<ListItemButton
selected={idx === selectedPferdIndex}
onDoubleClick={() => handlePferdAuswahl(pferd)}
sx={{py: 0.25, display: 'flex', gap: 1}}
>
<ListItemText
primary={`${pferd.kopfnr} - ${pferd.name}`}
primaryTypographyProps={{fontSize: '11px'}}
/>
{istIMS && (
<Chip
label="IMS"
size="small"
color="primary"
sx={{height: 16, fontSize: '8px', fontWeight: 600}}
/>
)}
</ListItemButton>
</ListItem>
);
})
) : (
<ListItem>
<ListItemText
primary="Keine Ergebnisse"
primaryTypographyProps={{fontSize: '11px', color: 'text.secondary', textAlign: 'center'}}
/>
</ListItem>
)}
</List>
</Paper>
{/* Pferd Details - erscheint nach Auswahl */}
{selectedPferd && (
<Paper variant="outlined" sx={{p: 1.5, bgcolor: 'primary.50', flex: 1}}>
<Typography variant="caption" sx={{fontWeight: 600, mb: 0.5, display: 'block', fontSize: '10px'}}>
Pferd Details
</Typography>
<Typography variant="caption" sx={{fontSize: '10px', mb: 0.25, display: 'block'}}>
<strong>Kopfnummer:</strong> {selectedPferd.kopfnr}
</Typography>
<Typography variant="caption" sx={{fontSize: '10px', mb: 0.25, display: 'block'}}>
<strong>Name:</strong> {selectedPferd.name}
</Typography>
<Typography variant="caption" sx={{fontSize: '10px', mb: 0.25, display: 'block'}}>
<strong>Rasse:</strong> {selectedPferd.rasse}
</Typography>
<Typography variant="caption" sx={{fontSize: '10px', mb: 0.25, display: 'block'}}>
<strong>Farbe:</strong> {selectedPferd.farbe}
</Typography>
<Typography variant="caption" sx={{fontSize: '10px', mb: 0.25, display: 'block'}}>
<strong>Besitzer:</strong> {selectedPferd.besitzer}
</Typography>
<Typography variant="caption" sx={{fontSize: '10px', display: 'block'}}>
<strong>Stall:</strong> {selectedPferd.stall}
</Typography>
</Paper>
)}
{/* Buttons */}
<Box sx={{display: 'flex', gap: 0.5}}>
<Button variant="outlined" size="small" fullWidth sx={{fontSize: '10px', py: 0.5}}>
Neu
</Button>
<Button variant="outlined" size="small" fullWidth disabled={!selectedPferd} sx={{fontSize: '10px', py: 0.5}}>
Bearbeiten
</Button>
</Box>
</Box>
{/* Rechte Hälfte: Reiter */}
<Box sx={{flex: 1, p: 1.5, display: 'flex', flexDirection: 'column', gap: 1}}>
{/* Eingabefeld */}
<Box sx={{display: 'flex', gap: 1, alignItems: 'center'}}>
<Typography variant="caption" sx={{fontWeight: 600, minWidth: 50, fontSize: '11px'}}>
Reiter:
</Typography>
<TextField
inputRef={reiterInputRef}
fullWidth
size="small"
placeholder="Vorname und/oder Nachname"
value={reiterSuche}
onChange={(e) => setReiterSuche(e.target.value)}
onKeyDown={handleReiterKeyDown}
sx={{
flex: 1,
'& .MuiInputBase-input': {fontSize: '11px', py: 0.75},
}}
/>
<Button variant="outlined" size="small" sx={{minWidth: 30, px: 0.5, fontSize: '10px'}}>
...
</Button>
<Button variant="outlined" size="small" onClick={handleReiterLeeren} sx={{fontSize: '10px', px: 1}}>
Leeren
</Button>
</Box>
{/* Suchergebnisse - bleiben immer sichtbar */}
<Paper
variant="outlined"
sx={{
height: selectedReiter ? '25%' : '50%',
overflow: 'auto',
transition: 'height 0.2s ease',
}}
>
<List dense disablePadding>
{reiterErgebnisse.length > 0 ? (
(reiterSuche ? reiterErgebnisse : reiterErgebnisse.slice(0, 4)).map((reiter, idx) => {
const istIMS = isReiterIMS(reiter.id);
return (
<ListItem key={reiter.id} disablePadding>
<ListItemButton
selected={idx === selectedReiterIndex}
onDoubleClick={() => handleReiterAuswahl(reiter)}
sx={{py: 0.25, display: 'flex', gap: 1}}
>
<ListItemText
primary={`${reiter.vorname} ${reiter.nachname}`}
secondary={reiter.geburtsjahr ? `*${reiter.geburtsjahr}` : undefined}
primaryTypographyProps={{fontSize: '11px'}}
secondaryTypographyProps={{fontSize: '9px'}}
/>
{istIMS && (
<Chip
label="IMS"
size="small"
color="primary"
sx={{height: 16, fontSize: '8px', fontWeight: 600}}
/>
)}
</ListItemButton>
</ListItem>
);
})
) : (
<ListItem>
<ListItemText
primary="Keine Ergebnisse"
primaryTypographyProps={{fontSize: '11px', color: 'text.secondary', textAlign: 'center'}}
/>
</ListItem>
)}
</List>
</Paper>
{/* Reiter Details - erscheint nach Auswahl */}
{selectedReiter && (
<Paper variant="outlined" sx={{p: 1.5, bgcolor: 'primary.50', flex: 1}}>
<Typography variant="caption" sx={{fontWeight: 600, mb: 0.5, display: 'block', fontSize: '10px'}}>
Reiter Details
</Typography>
<Typography variant="caption" sx={{fontSize: '10px', mb: 0.25, display: 'block'}}>
<strong>Name:</strong> {selectedReiter.vorname} {selectedReiter.nachname}
</Typography>
<Typography variant="caption" sx={{fontSize: '10px', mb: 0.25, display: 'block'}}>
<strong>Verein:</strong> {selectedReiter.verein}
</Typography>
<Box sx={{display: 'flex', alignItems: 'center', gap: 1, mb: 0.25}}>
<Typography variant="caption" sx={{fontSize: '10px'}}>
<strong>Lizenz:</strong> {selectedReiter.lizenz}
</Typography>
<Chip
label={selectedReiter.lizenzGueltig ? 'Gültig' : 'Abgelaufen'}
size="small"
color={selectedReiter.lizenzGueltig ? 'success' : 'error'}
sx={{height: 16, fontSize: '9px'}}
/>
</Box>
<Typography
variant="caption"
sx={{
fontSize: '10px',
color: selectedReiter.kontoSaldo < 0 ? 'error.main' : 'text.primary',
fontWeight: selectedReiter.kontoSaldo < 0 ? 600 : 400,
display: 'block',
}}
>
<strong>Konto-Saldo:</strong> €{selectedReiter.kontoSaldo.toFixed(2)}
</Typography>
</Paper>
)}
{/* Buttons */}
<Box sx={{display: 'flex', gap: 0.5}}>
<Button variant="outlined" size="small" fullWidth sx={{fontSize: '10px', py: 0.5}}>
Neu
</Button>
<Button variant="outlined" size="small" fullWidth disabled={!selectedReiter} sx={{fontSize: '10px', py: 0.5}}>
Bearbeiten
</Button>
</Box>
</Box>
</Box>
);
}
@@ -0,0 +1,130 @@
import {useState} from 'react';
import {useParams, useNavigate} from 'react-router';
import Box from '@mui/material/Box';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Breadcrumbs from '@mui/material/Breadcrumbs';
import Link from '@mui/material/Link';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import HomeIcon from '@mui/icons-material/Home';
import {StammdatenTab} from './turnier/StammdatenTab';
import {OrganisationTab} from './turnier/OrganisationTab';
import {BewerbeTab} from './turnier/BewerbeTab';
import {PreislisteTab} from './turnier/PreislisteTab';
import {veranstaltungenData} from './Dashboard';
export function TurnierAnsicht() {
const params = useParams();
const navigate = useNavigate();
const veranstaltungId = params.veranstaltungId;
const turnierNr = params.nr;
// Bei neu: Direkt zu Stammdaten (Tab 0), sonst Stammdaten (Tab 0)
const [activeTab, setActiveTab] = useState(0);
// Veranstaltung laden
const veranstaltung = veranstaltungId !== 'neu'
? veranstaltungenData.find(v => v.id === parseInt(veranstaltungId || '0'))
: null;
// Turnier laden (wenn nicht neu)
const turnier = turnierNr !== 'neu' && veranstaltung
? veranstaltung.turniere.find(t => t.nr === turnierNr)
: null;
const handleZurueck = () => {
navigate(`/veranstaltung/${veranstaltungId}`);
};
const handleToAdmin = () => {
navigate('/admin');
};
return (
<Box sx={{display: 'flex', flexDirection: 'column', height: '100vh', bgcolor: 'background.default'}}>
{/* Header mit Navigation */}
<AppBar position="static" elevation={1}>
<Toolbar variant="dense" sx={{gap: 2}}>
<IconButton
edge="start"
color="inherit"
onClick={handleZurueck}
sx={{mr: 1}}
>
<ArrowBackIcon/>
</IconButton>
<Breadcrumbs
aria-label="breadcrumb"
sx={{
color: 'white',
'& .MuiBreadcrumbs-separator': {color: 'rgba(255,255,255,0.7)'}
}}
>
<Link
underline="hover"
sx={{
display: 'flex',
alignItems: 'center',
color: 'rgba(255,255,255,0.9)',
cursor: 'pointer',
fontSize: '11px'
}}
onClick={handleToAdmin}
>
<HomeIcon sx={{mr: 0.5, fontSize: 16}}/>
Admin - Verwaltung
</Link>
<Link
underline="hover"
sx={{
color: 'rgba(255,255,255,0.9)',
cursor: 'pointer',
fontSize: '11px'
}}
onClick={handleZurueck}
>
{veranstaltung?.name || 'Veranstaltung'}
</Link>
<Typography sx={{color: 'white', fontSize: '11px', fontWeight: 600}}>
{turnier ? `Turnier ${turnier.nr}` : 'Neues Turnier'}
</Typography>
</Breadcrumbs>
</Toolbar>
</AppBar>
{/* Tab Navigation */}
<Tabs
value={activeTab}
onChange={(_, v) => setActiveTab(v)}
sx={{
borderBottom: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
'& .MuiTab-root': {
fontSize: '11px',
minHeight: 36,
py: 1,
}
}}
>
<Tab label="Stammdaten"/>
<Tab label="Organisation"/>
<Tab label="Bewerbe"/>
<Tab label="Preisliste"/>
</Tabs>
{/* Tab Content */}
<Box sx={{flex: 1, overflow: 'auto'}}>
{activeTab === 0 && <StammdatenTab turnierId={turnierNr}/>}
{activeTab === 1 && <OrganisationTab/>}
{activeTab === 2 && <BewerbeTab/>}
{activeTab === 3 && <PreislisteTab/>}
</Box>
</Box>
);
}
@@ -0,0 +1,145 @@
import {useState} from 'react';
import {useParams, useNavigate} from 'react-router';
import Box from '@mui/material/Box';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Breadcrumbs from '@mui/material/Breadcrumbs';
import Link from '@mui/material/Link';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import HomeIcon from '@mui/icons-material/Home';
import {VeranstaltungUebersicht} from './turnier/VeranstaltungUebersicht';
import {veranstaltungenData} from './Dashboard';
import {StammdatenTab} from './turnier/StammdatenTab';
import {OrganisationTab} from './turnier/OrganisationTab';
import {BewerbeTab} from './turnier/BewerbeTab';
import {PreislisteTab} from './turnier/PreislisteTab';
export function TurnierErstellen() {
const params = useParams();
const navigate = useNavigate();
const id = params.id;
// Bei neu: Direkt zu Stammdaten (Tab 1), sonst Veranstaltung - Übersicht (Tab 0)
const [activeTab, setActiveTab] = useState(id === 'neu' ? 1 : 0);
// Veranstaltung laden
const veranstaltung = id !== 'neu'
? veranstaltungenData.find(v => v.id === parseInt(id || '0'))
: null;
const handleZurueck = () => {
navigate('/admin');
};
// Für bestehende Veranstaltungen: Nur "Veranstaltung - Übersicht" Tab
// Für neue Veranstaltungen: Alle Tabs anzeigen
const istNeueVeranstaltung = id === 'neu';
const istBestehendeVeranstaltung = !istNeueVeranstaltung && veranstaltung;
return (
<Box sx={{display: 'flex', flexDirection: 'column', height: '100vh', bgcolor: 'background.default'}}>
{/* Header mit Navigation */}
<AppBar position="static" elevation={1}>
<Toolbar variant="dense" sx={{gap: 2}}>
<IconButton
edge="start"
color="inherit"
onClick={handleZurueck}
sx={{mr: 1}}
>
<ArrowBackIcon/>
</IconButton>
<Breadcrumbs
aria-label="breadcrumb"
sx={{
color: 'white',
'& .MuiBreadcrumbs-separator': {color: 'rgba(255,255,255,0.7)'}
}}
>
<Link
underline="hover"
sx={{
display: 'flex',
alignItems: 'center',
color: 'rgba(255,255,255,0.9)',
cursor: 'pointer',
fontSize: '11px'
}}
onClick={handleZurueck}
>
<HomeIcon sx={{mr: 0.5, fontSize: 16}}/>
Admin - Verwaltung
</Link>
<Typography sx={{color: 'white', fontSize: '11px', fontWeight: 600}}>
{veranstaltung?.name || 'Neue Veranstaltung'}
</Typography>
</Breadcrumbs>
</Toolbar>
</AppBar>
{/* Tab Navigation */}
{istBestehendeVeranstaltung ? (
// Nur "Veranstaltung - Übersicht" für bestehende Veranstaltungen
<Tabs
value={0}
sx={{
borderBottom: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
'& .MuiTab-root': {
fontSize: '11px',
minHeight: 36,
py: 1,
}
}}
>
<Tab label="Veranstaltung - Übersicht"/>
</Tabs>
) : (
// Alle Tabs für neue Veranstaltungen
<Tabs
value={activeTab}
onChange={(_, v) => setActiveTab(v)}
sx={{
borderBottom: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
'& .MuiTab-root': {
fontSize: '11px',
minHeight: 36,
py: 1,
}
}}
>
<Tab label="Veranstaltung - Übersicht"/>
<Tab label="Stammdaten"/>
<Tab label="Organisation"/>
<Tab label="Bewerbe"/>
<Tab label="Preisliste"/>
</Tabs>
)}
{/* Tab Content */}
<Box sx={{flex: 1, overflow: 'auto'}}>
{istBestehendeVeranstaltung ? (
// Nur Veranstaltung - Übersicht für bestehende Veranstaltungen
<VeranstaltungUebersicht/>
) : (
// Alle Tabs für neue Veranstaltungen
<>
{activeTab === 0 && <VeranstaltungUebersicht/>}
{activeTab === 1 && <StammdatenTab turnierId={id}/>}
{activeTab === 2 && <OrganisationTab/>}
{activeTab === 3 && <BewerbeTab/>}
{activeTab === 4 && <PreislisteTab/>}
</>
)}
</Box>
</Box>
);
}
@@ -0,0 +1,213 @@
import {useState} from 'react';
import Box from '@mui/material/Box';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import RefreshIcon from '@mui/icons-material/Refresh';
import AddIcon from '@mui/icons-material/Add';
import RemoveIcon from '@mui/icons-material/Remove';
// Mock-Daten für Verkauf
const mockVerkaufArtikel = [
{knr: '', text: 'Belastung', einzelpreis: 0, menge: 0, gebucht: '0.00'},
{knr: '', text: 'Gutschrift', einzelpreis: 0, menge: 0, gebucht: '0.00'},
{knr: '', text: 'Boxenpauschale', einzelpreis: 115.00, menge: 0, gebucht: '0.00'},
{knr: '', text: 'Ansage', einzelpreis: 2.00, menge: 0, gebucht: '0.00'},
{knr: '', text: 'Füttern', einzelpreis: 3.00, menge: 0, gebucht: '0.00'},
{knr: '', text: 'Heu', einzelpreis: 13.00, menge: 0, gebucht: '0.00'},
{knr: '', text: 'Späne', einzelpreis: 15.00, menge: 0, gebucht: '0.00'},
{knr: '', text: 'Stroh', einzelpreis: 5.00, menge: 0, gebucht: '0.00'},
{knr: '', text: 'Strom', einzelpreis: 50.00, menge: 0, gebucht: '0.00'},
{knr: '', text: 'Y-Nummer', einzelpreis: 35.00, menge: 0, gebucht: '0.00'},
{knr: '', text: 'Z-Nummer', einzelpreis: 10.00, menge: 0, gebucht: '0.00'},
];
interface Props {
selectedReiter: any;
}
export function VerkaufBuchungen({selectedReiter}: Props) {
const [tabValue, setTabValue] = useState(0);
const [verkaufMengen, setVerkaufMengen] = useState<{ [key: string]: number }>({});
const handleMengeChange = (text: string, delta: number) => {
setVerkaufMengen(prev => ({
...prev,
[text]: Math.max(0, (prev[text] || 0) + delta),
}));
};
return (
<Box sx={{display: 'flex', flexDirection: 'column', height: '100%'}}>
<Tabs value={tabValue} onChange={(_, v) => setTabValue(v)}
sx={{borderBottom: 1, borderColor: 'divider', minHeight: 32}}>
<Tab label="Verkauf" sx={{minHeight: 32, fontSize: '11px', py: 0.5}}/>
<Tab label="Buchungen" sx={{minHeight: 32, fontSize: '11px', py: 0.5}}/>
</Tabs>
{tabValue === 0 && (
<>
<Toolbar variant="dense" sx={{
minHeight: 28,
px: 1,
gap: 1,
bgcolor: 'background.paper',
borderBottom: 1,
borderColor: 'divider'
}}>
<IconButton size="small" sx={{width: 24, height: 24}}>
<RefreshIcon sx={{fontSize: 16}}/>
</IconButton>
<Typography variant="caption" sx={{fontSize: '10px'}}>
Aktualisieren
</Typography>
<Typography variant="caption" sx={{flex: 1, textAlign: 'center', fontWeight: 600, fontSize: '10px'}}>
{mockVerkaufArtikel.length} Artikel
</Typography>
<Button size="small" variant="text" sx={{fontSize: '10px', py: 0.25}}>
Rückgängig
</Button>
<Button size="small" variant="text" sx={{fontSize: '10px', py: 0.25}}>
Speichern
</Button>
</Toolbar>
<TableContainer sx={{flex: 1}}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5, width: 40}}>KNr</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5, width: 40}} align="center">+</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5, width: 60}}
align="center">Menge</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5, width: 40}} align="center">-</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5}}>Buchungstext</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5, width: 70}}
align="right">Betrag</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5, width: 70}}
align="right">Gebucht</TableCell>
</TableRow>
</TableHead>
<TableBody>
{mockVerkaufArtikel.map((artikel, idx) => {
const menge = verkaufMengen[artikel.text] || 0;
const betrag = menge * artikel.einzelpreis;
return (
<TableRow
key={idx}
sx={{
'&:nth-of-type(odd)': {bgcolor: 'action.hover'},
bgcolor: idx === 0 || idx === 1 ? '#FFFFCC' : undefined,
}}
>
<TableCell sx={{fontSize: '10px', py: 0.5}}>{artikel.knr}</TableCell>
<TableCell sx={{fontSize: '10px', px: 0.25, py: 0.5}} align="center">
<IconButton
size="small"
onClick={() => handleMengeChange(artikel.text, 1)}
sx={{width: 20, height: 20}}
>
<AddIcon sx={{fontSize: 14}}/>
</IconButton>
</TableCell>
<TableCell sx={{fontSize: '10px', px: 0.5, py: 0.5}} align="center">
<TextField
size="small"
value={menge}
onChange={(e) => setVerkaufMengen(prev => ({
...prev,
[artikel.text]: Math.max(0, parseInt(e.target.value) || 0),
}))}
sx={{
width: 50,
'& .MuiInputBase-input': {
textAlign: 'center',
fontSize: '10px',
py: 0.25,
px: 0.5,
},
}}
/>
</TableCell>
<TableCell sx={{fontSize: '10px', px: 0.25, py: 0.5}} align="center">
<IconButton
size="small"
onClick={() => handleMengeChange(artikel.text, -1)}
sx={{width: 20, height: 20}}
>
<RemoveIcon sx={{fontSize: 14}}/>
</IconButton>
</TableCell>
<TableCell sx={{fontSize: '10px', py: 0.5}}>{artikel.text}</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: betrag > 0 ? 600 : 400, py: 0.5}} align="right">
{betrag.toFixed(2)}
</TableCell>
<TableCell sx={{fontSize: '10px', py: 0.5}} align="right">{artikel.gebucht}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</>
)}
{tabValue === 1 && (
<>
<Toolbar variant="dense" sx={{
minHeight: 28,
px: 1,
gap: 1,
bgcolor: 'background.paper',
borderBottom: 1,
borderColor: 'divider'
}}>
<IconButton size="small" sx={{width: 24, height: 24}}>
<RefreshIcon sx={{fontSize: 16}}/>
</IconButton>
<Typography variant="caption" sx={{fontSize: '10px'}}>
Aktualisieren
</Typography>
<Typography variant="caption" sx={{flex: 1, textAlign: 'center', fontWeight: 600, fontSize: '10px'}}>
0 Buchungen
</Typography>
<Button size="small" variant="text" color="error" sx={{fontSize: '10px', py: 0.25}}>
Stornieren
</Button>
</Toolbar>
<TableContainer sx={{flex: 1}}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5}}>Kopfnr</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5}}>Menge</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5}}>Buchungstext</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5}} align="right">Soll</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, py: 0.5}} align="right">Haben</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell colSpan={5} sx={{textAlign: 'center', color: 'text.secondary', fontSize: '10px', py: 2}}>
Keine Buchungen vorhanden
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</>
)}
</Box>
);
}
@@ -0,0 +1,27 @@
import React, {useState} from 'react'
const ERROR_IMG_SRC =
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODgiIGhlaWdodD0iODgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvcGFjaXR5PSIuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIzLjciPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iNiIvPjxwYXRoIGQ9Im0xNiA1OCAxNi0xOCAzMiAzMiIvPjxjaXJjbGUgY3g9IjUzIiBjeT0iMzUiIHI9IjciLz48L3N2Zz4KCg=='
export function ImageWithFallback(props: React.ImgHTMLAttributes<HTMLImageElement>) {
const [didError, setDidError] = useState(false)
const handleError = () => {
setDidError(true)
}
const {src, alt, style, className, ...rest} = props
return didError ? (
<div
className={`inline-block bg-gray-100 text-center align-middle ${className ?? ''}`}
style={style}
>
<div className="flex items-center justify-center w-full h-full">
<img src={ERROR_IMG_SRC} alt="Error loading image" {...rest} data-original-url={src}/>
</div>
</div>
) : (
<img src={src} alt={alt} className={className} style={style} {...rest} onError={handleError}/>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,398 @@
import {useState} from 'react';
import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Paper from '@mui/material/Paper';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import IconButton from '@mui/material/IconButton';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import SearchIcon from '@mui/icons-material/Search';
interface Richter {
id: number;
name: string;
qualifikation: string;
funktion: string;
}
// Mock-Qualifikationen basierend auf OEPS-System
const qualifikationen = [
'D-E', 'D-A', 'D-L', 'D-M', 'D-S', 'D-GP', // Dressur
'S-E', 'S-A', 'S-L', 'S-M', 'S-S', // Springen
'V-E', 'V-A', 'V-L', 'V-M', 'V-S', // Vielseitigkeit
'FEI Level 1', 'FEI Level 2', 'FEI Level 3' // International
];
const richterfunktionen = [
'Hauptrichter',
'Beisitzer',
'Richter bei C',
'Richter bei H',
'Richter bei M',
'Richter bei B',
'Richter bei E'
];
export function FunktionaereTab() {
// Einzelne Funktionäre
const [turnierleiter, setTurnierleiter] = useState('');
const [turnierbeauftragter, setTurnierbeauftragter] = useState('');
const [technischerDelegierter, setTechnischerDelegierter] = useState('');
const [parcourschef, setParcourschef] = useState('');
const [tierarzt, setTierarzt] = useState('');
const [schmied, setSchmied] = useState('');
const [steward, setSteward] = useState('');
// Richterkollegium (dynamische Liste)
const [richter, setRichter] = useState<Richter[]>([
{id: 1, name: 'Alexandra Schuster', qualifikation: 'D-GP', funktion: 'Hauptrichter'},
{id: 2, name: 'Ulrike Knasmüller-Prinz', qualifikation: 'D-M', funktion: 'Beisitzer'},
]);
const handleRichterHinzufuegen = () => {
const newId = Math.max(0, ...richter.map(r => r.id)) + 1;
setRichter([
...richter,
{id: newId, name: '', qualifikation: 'D-E', funktion: 'Beisitzer'}
]);
};
const handleRichterLoeschen = (id: number) => {
setRichter(richter.filter(r => r.id !== id));
};
const handleRichterAendern = (id: number, field: keyof Richter, value: string) => {
setRichter(richter.map(r =>
r.id === id ? {...r, [field]: value} : r
));
};
const handleSpeichern = () => {
console.log('Funktionäre speichern:', {
turnierleiter,
turnierbeauftragter,
technischerDelegierter,
parcourschef,
tierarzt,
schmied,
steward,
richter,
});
// TODO: Backend Integration (C-Satz)
};
return (
<Box sx={{p: 3, bgcolor: 'background.default', height: '100%', overflowY: 'auto'}}>
<Box sx={{maxWidth: 1000}}>
<Typography variant="h6" sx={{fontSize: '13px', fontWeight: 600, mb: 3}}>
Funktionäre & Offizielle (C-Satz)
</Typography>
{/* Turnier-Organisation */}
<Paper variant="outlined" sx={{p: 2.5, mb: 3}}>
<Typography variant="body2" sx={{fontSize: '11px', fontWeight: 600, mb: 2}}>
Turnier-Organisation
</Typography>
<Box sx={{display: 'flex', flexDirection: 'column', gap: 2}}>
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 200, fontSize: '11px'}}>
Turnierleiter:
</Typography>
<TextField
size="small"
fullWidth
value={turnierleiter}
onChange={(e) => setTurnierleiter(e.target.value)}
placeholder="z.B. Ursula Stroblmair"
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.75}}}
InputProps={{
endAdornment: (
<IconButton size="small" sx={{mr: -1}}>
<SearchIcon sx={{fontSize: 16}}/>
</IconButton>
)
}}
/>
</Box>
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 200, fontSize: '11px'}}>
Turnierbeauftragte/r:
</Typography>
<TextField
size="small"
fullWidth
value={turnierbeauftragter}
onChange={(e) => setTurnierbeauftragter(e.target.value)}
placeholder="z.B. Rudi Kreupl"
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.75}}}
InputProps={{
endAdornment: (
<IconButton size="small" sx={{mr: -1}}>
<SearchIcon sx={{fontSize: 16}}/>
</IconButton>
)
}}
/>
</Box>
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 200, fontSize: '11px'}}>
Technischer Delegierter (TD):
</Typography>
<TextField
size="small"
fullWidth
value={technischerDelegierter}
onChange={(e) => setTechnischerDelegierter(e.target.value)}
placeholder="Optional (hauptsächlich Vielseitigkeit)"
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.75}}}
InputProps={{
endAdornment: (
<IconButton size="small" sx={{mr: -1}}>
<SearchIcon sx={{fontSize: 16}}/>
</IconButton>
)
}}
/>
</Box>
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 200, fontSize: '11px'}}>
Steward:
</Typography>
<TextField
size="small"
fullWidth
value={steward}
onChange={(e) => setSteward(e.target.value)}
placeholder="z.B. Barbara Hruschka"
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.75}}}
InputProps={{
endAdornment: (
<IconButton size="small" sx={{mr: -1}}>
<SearchIcon sx={{fontSize: 16}}/>
</IconButton>
)
}}
/>
</Box>
</Box>
</Paper>
{/* Parcours & Technik */}
<Paper variant="outlined" sx={{p: 2.5, mb: 3}}>
<Typography variant="body2" sx={{fontSize: '11px', fontWeight: 600, mb: 2}}>
Parcours & Technik
</Typography>
<Box sx={{display: 'flex', flexDirection: 'column', gap: 2}}>
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 200, fontSize: '11px'}}>
Parcourschef:
</Typography>
<TextField
size="small"
fullWidth
value={parcourschef}
onChange={(e) => setParcourschef(e.target.value)}
placeholder="z.B. Kurt Reitetschläger"
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.75}}}
InputProps={{
endAdornment: (
<IconButton size="small" sx={{mr: -1}}>
<SearchIcon sx={{fontSize: 16}}/>
</IconButton>
)
}}
/>
</Box>
</Box>
</Paper>
{/* Medizinische Versorgung */}
<Paper variant="outlined" sx={{p: 2.5, mb: 3}}>
<Typography variant="body2" sx={{fontSize: '11px', fontWeight: 600, mb: 2}}>
Medizinische Versorgung
</Typography>
<Box sx={{display: 'flex', flexDirection: 'column', gap: 2}}>
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 200, fontSize: '11px'}}>
Turniertierarzt:
</Typography>
<TextField
size="small"
fullWidth
value={tierarzt}
onChange={(e) => setTierarzt(e.target.value)}
placeholder="z.B. Dr. Sabine Ötschmaier"
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.75}}}
InputProps={{
endAdornment: (
<IconButton size="small" sx={{mr: -1}}>
<SearchIcon sx={{fontSize: 16}}/>
</IconButton>
)
}}
/>
</Box>
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 200, fontSize: '11px'}}>
Schmied:
</Typography>
<TextField
size="small"
fullWidth
value={schmied}
onChange={(e) => setSchmied(e.target.value)}
placeholder="Name des Turnierschmieds"
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.75}}}
InputProps={{
endAdornment: (
<IconButton size="small" sx={{mr: -1}}>
<SearchIcon sx={{fontSize: 16}}/>
</IconButton>
)
}}
/>
</Box>
</Box>
</Paper>
{/* Richterkollegium */}
<Paper variant="outlined" sx={{p: 2.5, mb: 3}}>
<Box sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2}}>
<Typography variant="body2" sx={{fontSize: '11px', fontWeight: 600}}>
Richterkollegium
</Typography>
<Button
size="small"
startIcon={<AddIcon/>}
onClick={handleRichterHinzufuegen}
sx={{fontSize: '10px'}}
>
Richter hinzufügen
</Button>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{fontSize: '10px', fontWeight: 600}}>Name</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, width: 180}}>Qualifikation</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, width: 180}}>Funktion</TableCell>
<TableCell sx={{width: 50}}></TableCell>
</TableRow>
</TableHead>
<TableBody>
{richter.map((r) => (
<TableRow key={r.id}>
<TableCell sx={{py: 1}}>
<TextField
size="small"
fullWidth
value={r.name}
onChange={(e) => handleRichterAendern(r.id, 'name', e.target.value)}
placeholder="Name des Richters"
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.5}}}
InputProps={{
endAdornment: (
<IconButton size="small" sx={{mr: -1}}>
<SearchIcon sx={{fontSize: 14}}/>
</IconButton>
)
}}
/>
</TableCell>
<TableCell sx={{py: 1}}>
<Select
size="small"
fullWidth
value={r.qualifikation}
onChange={(e) => handleRichterAendern(r.id, 'qualifikation', e.target.value)}
sx={{fontSize: '11px'}}
>
{qualifikationen.map((q) => (
<MenuItem key={q} value={q} sx={{fontSize: '11px'}}>
{q}
</MenuItem>
))}
</Select>
</TableCell>
<TableCell sx={{py: 1}}>
<Select
size="small"
fullWidth
value={r.funktion}
onChange={(e) => handleRichterAendern(r.id, 'funktion', e.target.value)}
sx={{fontSize: '11px'}}
>
{richterfunktionen.map((f) => (
<MenuItem key={f} value={f} sx={{fontSize: '11px'}}>
{f}
</MenuItem>
))}
</Select>
</TableCell>
<TableCell sx={{py: 1}}>
<IconButton
size="small"
onClick={() => handleRichterLoeschen(r.id)}
>
<DeleteIcon sx={{fontSize: 16}}/>
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{richter.length === 0 && (
<Box sx={{textAlign: 'center', py: 3}}>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary'}}>
Keine Richter definiert
</Typography>
</Box>
)}
</Paper>
{/* Hinweis */}
<Paper variant="outlined" sx={{p: 2, mb: 3, bgcolor: '#f5f5f5'}}>
<Typography variant="body2" sx={{fontSize: '10px', mb: 1, fontWeight: 600}}>
ℹ️ Hinweis zu Funktionären
</Typography>
<Typography variant="body2" sx={{fontSize: '10px', lineHeight: 1.6}}>
Die Funktionäre werden im <strong>C-Satz</strong> der ZNS-Schnittstelle übermittelt.
Richter müssen entsprechende Qualifikationen für die jeweiligen Klassen besitzen (z.B. D-GP für Grand Prix
Dressur).
Bei internationalen Turnieren sind FEI-Lizenzen erforderlich.
</Typography>
</Paper>
{/* Action Buttons */}
<Box sx={{display: 'flex', gap: 2, justifyContent: 'flex-end'}}>
<Button variant="outlined" size="small" sx={{fontSize: '11px', px: 3}}>
Zurücksetzen
</Button>
<Button variant="contained" size="small" onClick={handleSpeichern} sx={{fontSize: '11px', px: 3}}>
Speichern
</Button>
</Box>
</Box>
</Box>
);
}
@@ -0,0 +1,411 @@
import {useState} from 'react';
import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Paper from '@mui/material/Paper';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import IconButton from '@mui/material/IconButton';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import Divider from '@mui/material/Divider';
interface Richter {
id: number;
name: string;
qualifikation: string;
funktion: string;
}
interface Platz {
id: number;
sparte: string;
groesse: string;
bezeichnung: string;
}
// Mock-Qualifikationen basierend auf OEPS-System
const qualifikationen = [
'D-E', 'D-A', 'D-L', 'D-M', 'D-S', 'D-GP', // Dressur
'S-E', 'S-A', 'S-L', 'S-M', 'S-S', // Springen
'V-E', 'V-A', 'V-L', 'V-M', 'V-S', // Vielseitigkeit
'FEI Level 1', 'FEI Level 2', 'FEI Level 3' // International
];
const richterfunktionen = [
'Hauptrichter',
'Beisitzer',
'Richter bei C',
'Richter bei H',
'Richter bei M',
'Richter bei B',
'Richter bei E'
];
const sparten = ['Dressur', 'Springen', 'Vielseitigkeit'];
const platzgroessen = [
'20 x 40 m',
'20 x 60 m',
'25 x 60 m',
'30 x 60 m',
'Springplatz'
];
export function OrganisationTab() {
// Einzelne Funktionäre
const [turnierleiter, setTurnierleiter] = useState('');
const [turnierbeauftragter, setTurnierbeauftragter] = useState('');
const [technischerDelegierter, setTechnischerDelegierter] = useState('');
const [parcourschef, setParcourschef] = useState('');
const [tierarzt, setTierarzt] = useState('');
const [schmied, setSchmied] = useState('');
const [steward, setSteward] = useState('');
// Richterkollegium (dynamische Liste)
const [richter, setRichter] = useState<Richter[]>([
{id: 1, name: 'Alexandra Schuster', qualifikation: 'D-GP', funktion: 'Hauptrichter'},
{id: 2, name: 'Ulrike Knasmüller-Prinz', qualifikation: 'D-M', funktion: 'Beisitzer'},
]);
// Plätze (dynamische Liste)
const [plaetze, setPlaetze] = useState<Platz[]>([
{id: 1, sparte: 'Dressur', groesse: '20 x 60 m', bezeichnung: 'Hauptplatz'},
{id: 2, sparte: 'Dressur', groesse: '20 x 40 m', bezeichnung: 'Abreiteplatz 1'},
]);
const handleRichterHinzufuegen = () => {
const newId = Math.max(0, ...richter.map(r => r.id)) + 1;
setRichter([
...richter,
{id: newId, name: '', qualifikation: 'D-E', funktion: 'Beisitzer'}
]);
};
const handleRichterLoeschen = (id: number) => {
setRichter(richter.filter(r => r.id !== id));
};
const handleRichterAendern = (id: number, field: keyof Richter, value: string) => {
setRichter(richter.map(r =>
r.id === id ? {...r, [field]: value} : r
));
};
const handlePlatzHinzufuegen = () => {
const newId = Math.max(0, ...plaetze.map(p => p.id)) + 1;
setPlaetze([
...plaetze,
{id: newId, sparte: 'Dressur', groesse: '20 x 60 m', bezeichnung: ''}
]);
};
const handlePlatzLoeschen = (id: number) => {
setPlaetze(plaetze.filter(p => p.id !== id));
};
const handlePlatzAendern = (id: number, field: keyof Platz, value: string) => {
setPlaetze(plaetze.map(p =>
p.id === id ? {...p, [field]: value} : p
));
};
const handleSpeichern = () => {
console.log('Organisation speichern:', {
turnierleiter,
turnierbeauftragter,
technischerDelegierter,
parcourschef,
tierarzt,
schmied,
steward,
richter,
plaetze,
});
// TODO: Backend Integration (C-Satz)
};
return (
<Box sx={{p: 3, bgcolor: 'background.default', height: '100%', overflowY: 'auto'}}>
<Box sx={{maxWidth: 1200}}>
{/* === FUNKTIONÄRE === */}
<Typography variant="h6" sx={{fontSize: '13px', fontWeight: 600, mb: 3}}>
Funktionäre & Offizielle (C-Satz)
</Typography>
{/* Turnier-Organisation */}
<Paper variant="outlined" sx={{p: 2.5, mb: 3}}>
<Typography variant="subtitle2" sx={{fontSize: '11px', fontWeight: 600, mb: 2}}>
Turnier-Organisation
</Typography>
<Box sx={{display: 'grid', gridTemplateColumns: '180px 1fr', gap: 2, alignItems: 'center'}}>
<Typography variant="body2" sx={{fontSize: '11px'}}>Turnierleiter:</Typography>
<TextField
size="small"
value={turnierleiter}
onChange={(e) => setTurnierleiter(e.target.value)}
placeholder="Name suchen..."
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.75}}}
/>
<Typography variant="body2" sx={{fontSize: '11px'}}>Turnierbeauftragter:</Typography>
<TextField
size="small"
value={turnierbeauftragter}
onChange={(e) => setTurnierbeauftragter(e.target.value)}
placeholder="Name suchen..."
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.75}}}
/>
<Typography variant="body2" sx={{fontSize: '11px'}}>Technischer Delegierter:</Typography>
<TextField
size="small"
value={technischerDelegierter}
onChange={(e) => setTechnischerDelegierter(e.target.value)}
placeholder="Name suchen..."
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.75}}}
/>
<Typography variant="body2" sx={{fontSize: '11px'}}>Parcourschef:</Typography>
<TextField
size="small"
value={parcourschef}
onChange={(e) => setParcourschef(e.target.value)}
placeholder="Name suchen..."
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.75}}}
/>
</Box>
</Paper>
{/* Support-Team */}
<Paper variant="outlined" sx={{p: 2.5, mb: 3}}>
<Typography variant="subtitle2" sx={{fontSize: '11px', fontWeight: 600, mb: 2}}>
Support-Team
</Typography>
<Box sx={{display: 'grid', gridTemplateColumns: '180px 1fr', gap: 2, alignItems: 'center'}}>
<Typography variant="body2" sx={{fontSize: '11px'}}>Tierarzt:</Typography>
<TextField
size="small"
value={tierarzt}
onChange={(e) => setTierarzt(e.target.value)}
placeholder="Name suchen..."
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.75}}}
/>
<Typography variant="body2" sx={{fontSize: '11px'}}>Schmied:</Typography>
<TextField
size="small"
value={schmied}
onChange={(e) => setSchmied(e.target.value)}
placeholder="Name suchen..."
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.75}}}
/>
<Typography variant="body2" sx={{fontSize: '11px'}}>Steward:</Typography>
<TextField
size="small"
value={steward}
onChange={(e) => setSteward(e.target.value)}
placeholder="Name suchen..."
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.75}}}
/>
</Box>
</Paper>
{/* Richterkollegium */}
<Paper variant="outlined" sx={{p: 2.5, mb: 4}}>
<Box sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2}}>
<Typography variant="subtitle2" sx={{fontSize: '11px', fontWeight: 600}}>
Richterkollegium
</Typography>
<Button
variant="outlined"
size="small"
startIcon={<AddIcon/>}
onClick={handleRichterHinzufuegen}
sx={{fontSize: '10px', px: 2}}
>
Richter hinzufügen
</Button>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{fontSize: '10px', fontWeight: 600, bgcolor: 'grey.50'}}>Name</TableCell>
<TableCell
sx={{fontSize: '10px', fontWeight: 600, bgcolor: 'grey.50', width: 180}}>Qualifikation</TableCell>
<TableCell
sx={{fontSize: '10px', fontWeight: 600, bgcolor: 'grey.50', width: 180}}>Funktion</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, bgcolor: 'grey.50', width: 60}}
align="center">Aktion</TableCell>
</TableRow>
</TableHead>
<TableBody>
{richter.map((r) => (
<TableRow key={r.id} hover>
<TableCell sx={{py: 1}}>
<TextField
size="small"
fullWidth
value={r.name}
onChange={(e) => handleRichterAendern(r.id, 'name', e.target.value)}
placeholder="Name suchen..."
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.5}}}
/>
</TableCell>
<TableCell sx={{py: 1}}>
<Select
size="small"
fullWidth
value={r.qualifikation}
onChange={(e) => handleRichterAendern(r.id, 'qualifikation', e.target.value)}
sx={{fontSize: '11px'}}
>
{qualifikationen.map((q) => (
<MenuItem key={q} value={q} sx={{fontSize: '11px'}}>{q}</MenuItem>
))}
</Select>
</TableCell>
<TableCell sx={{py: 1}}>
<Select
size="small"
fullWidth
value={r.funktion}
onChange={(e) => handleRichterAendern(r.id, 'funktion', e.target.value)}
sx={{fontSize: '11px'}}
>
{richterfunktionen.map((f) => (
<MenuItem key={f} value={f} sx={{fontSize: '11px'}}>{f}</MenuItem>
))}
</Select>
</TableCell>
<TableCell align="center" sx={{py: 1}}>
<IconButton
size="small"
onClick={() => handleRichterLoeschen(r.id)}
sx={{color: 'error.main'}}
>
<DeleteIcon sx={{fontSize: 16}}/>
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
<Divider sx={{my: 4}}/>
{/* === PLÄTZE === */}
<Typography variant="h6" sx={{fontSize: '13px', fontWeight: 600, mb: 3}}>
Austragungsplätze
</Typography>
<Paper variant="outlined" sx={{p: 2.5, mb: 3}}>
<Box sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2}}>
<Typography variant="subtitle2" sx={{fontSize: '11px', fontWeight: 600}}>
Plätze & Anlagen
</Typography>
<Button
variant="outlined"
size="small"
startIcon={<AddIcon/>}
onClick={handlePlatzHinzufuegen}
sx={{fontSize: '10px', px: 2}}
>
Platz hinzufügen
</Button>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{fontSize: '10px', fontWeight: 600, bgcolor: 'grey.50', width: 150}}>Sparte</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, bgcolor: 'grey.50', width: 150}}>Größe</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, bgcolor: 'grey.50'}}>Bezeichnung</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, bgcolor: 'grey.50', width: 60}}
align="center">Aktion</TableCell>
</TableRow>
</TableHead>
<TableBody>
{plaetze.map((p) => (
<TableRow key={p.id} hover>
<TableCell sx={{py: 1}}>
<Select
size="small"
fullWidth
value={p.sparte}
onChange={(e) => handlePlatzAendern(p.id, 'sparte', e.target.value)}
sx={{fontSize: '11px'}}
>
{sparten.map((s) => (
<MenuItem key={s} value={s} sx={{fontSize: '11px'}}>{s}</MenuItem>
))}
</Select>
</TableCell>
<TableCell sx={{py: 1}}>
<Select
size="small"
fullWidth
value={p.groesse}
onChange={(e) => handlePlatzAendern(p.id, 'groesse', e.target.value)}
sx={{fontSize: '11px'}}
>
{platzgroessen.map((g) => (
<MenuItem key={g} value={g} sx={{fontSize: '11px'}}>{g}</MenuItem>
))}
</Select>
</TableCell>
<TableCell sx={{py: 1}}>
<TextField
size="small"
fullWidth
value={p.bezeichnung}
onChange={(e) => handlePlatzAendern(p.id, 'bezeichnung', e.target.value)}
placeholder="z.B. Hauptplatz, Abreiteplatz 1..."
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.5}}}
/>
</TableCell>
<TableCell align="center" sx={{py: 1}}>
<IconButton
size="small"
onClick={() => handlePlatzLoeschen(p.id)}
sx={{color: 'error.main'}}
>
<DeleteIcon sx={{fontSize: 16}}/>
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
{/* Speichern Button */}
<Box sx={{display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 4}}>
<Button
variant="contained"
onClick={handleSpeichern}
sx={{fontSize: '11px', px: 4, py: 0.75}}
>
Speichern
</Button>
</Box>
</Box>
</Box>
);
}
@@ -0,0 +1,345 @@
import {useState} from 'react';
import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Paper from '@mui/material/Paper';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import IconButton from '@mui/material/IconButton';
import Checkbox from '@mui/material/Checkbox';
import FormControlLabel from '@mui/material/FormControlLabel';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import Divider from '@mui/material/Divider';
interface Gebuehr {
id: number;
bezeichnung: string;
betrag: string;
pflicht: boolean;
}
export function PreislisteTab() {
// Nennungs- und Startgebühren
const [nenngebuehrProPferd, setNenngebuehrProPferd] = useState('0.00');
const [startgebuehrProBewerb, setStartgebuehrProBewerb] = useState('15.00');
const [sporteuro, setSporteuro] = useState('0.00');
const [nachnennungsgebuehr, setNachnennungsgebuehr] = useState('0.00');
const [nennungstauschgebuehr, setNennungstauschgebuehr] = useState('0.00');
// Stallungen & Boxen
const [boxenProTag, setBoxenProTag] = useState('0.00');
const [einstreuErst, setEinstreuErst] = useState('0.00');
const [einstreuNach, setEinstreuNach] = useState('0.00');
const [paddockProTag, setPaddockProTag] = useState('0.00');
// Zusatzgebühren (dynamisch)
const [zusatzgebuehren, setZusatzgebuehren] = useState<Gebuehr[]>([
{id: 1, bezeichnung: 'Stromanschluss pro Tag', betrag: '5.00', pflicht: false},
{id: 2, bezeichnung: 'Camping pro Nacht', betrag: '10.00', pflicht: false},
]);
const handleZusatzgebuehrHinzufuegen = () => {
const newId = Math.max(0, ...zusatzgebuehren.map(g => g.id)) + 1;
setZusatzgebuehren([
...zusatzgebuehren,
{id: newId, bezeichnung: '', betrag: '0.00', pflicht: false}
]);
};
const handleZusatzgebuehrLoeschen = (id: number) => {
setZusatzgebuehren(zusatzgebuehren.filter(g => g.id !== id));
};
const handleZusatzgebuehrAendern = (id: number, field: keyof Gebuehr, value: string | boolean) => {
setZusatzgebuehren(zusatzgebuehren.map(g =>
g.id === id ? {...g, [field]: value} : g
));
};
const handleSpeichern = () => {
console.log('Preisliste speichern:', {
nenngebuehrProPferd,
startgebuehrProBewerb,
sporteuro,
nachnennungsgebuehr,
nennungstauschgebuehr,
boxenProTag,
einstreuErst,
einstreuNach,
paddockProTag,
zusatzgebuehren,
});
// TODO: Backend Integration
};
return (
<Box sx={{p: 3, bgcolor: 'background.default', height: '100%', overflowY: 'auto'}}>
<Box sx={{maxWidth: 900}}>
<Typography variant="h6" sx={{fontSize: '13px', fontWeight: 600, mb: 3}}>
Nennungen & Gebühren
</Typography>
{/* Nennungs- und Startgebühren */}
<Paper variant="outlined" sx={{p: 2.5, mb: 3}}>
<Typography variant="body2" sx={{fontSize: '11px', fontWeight: 600, mb: 2}}>
Nennungs- und Startgebühren
</Typography>
<Box sx={{display: 'flex', flexDirection: 'column', gap: 2}}>
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 240, fontSize: '11px'}}>
Nenngebühr pro Pferd/Reiter:
</Typography>
<TextField
size="small"
value={nenngebuehrProPferd}
onChange={(e) => setNenngebuehrProPferd(e.target.value)}
sx={{width: 120, '& .MuiInputBase-input': {fontSize: '11px', py: 0.75, textAlign: 'right'}}}
InputProps={{endAdornment: '€'}}
/>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary', fontStyle: 'italic'}}>
(Grundgebühr unabhängig von Anzahl Bewerben)
</Typography>
</Box>
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 240, fontSize: '11px'}}>
Startgebühr pro Bewerb:
</Typography>
<TextField
size="small"
value={startgebuehrProBewerb}
onChange={(e) => setStartgebuehrProBewerb(e.target.value)}
sx={{width: 120, '& .MuiInputBase-input': {fontSize: '11px', py: 0.75, textAlign: 'right'}}}
InputProps={{endAdornment: '€'}}
/>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary', fontStyle: 'italic'}}>
(Pro einzelner Prüfung)
</Typography>
</Box>
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 240, fontSize: '11px'}}>
Sporteuro (Beitrag OEPS):
</Typography>
<TextField
size="small"
value={sporteuro}
onChange={(e) => setSporteuro(e.target.value)}
sx={{width: 120, '& .MuiInputBase-input': {fontSize: '11px', py: 0.75, textAlign: 'right'}}}
InputProps={{endAdornment: '€'}}
/>
</Box>
<Divider sx={{my: 1}}/>
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 240, fontSize: '11px'}}>
Nachnennungsgebühr:
</Typography>
<TextField
size="small"
value={nachnennungsgebuehr}
onChange={(e) => setNachnennungsgebuehr(e.target.value)}
sx={{width: 120, '& .MuiInputBase-input': {fontSize: '11px', py: 0.75, textAlign: 'right'}}}
InputProps={{endAdornment: '€'}}
/>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary', fontStyle: 'italic'}}>
(Nach Nennschluss)
</Typography>
</Box>
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 240, fontSize: '11px'}}>
Nennungstausch-Gebühr:
</Typography>
<TextField
size="small"
value={nennungstauschgebuehr}
onChange={(e) => setNennungstauschgebuehr(e.target.value)}
sx={{width: 120, '& .MuiInputBase-input': {fontSize: '11px', py: 0.75, textAlign: 'right'}}}
InputProps={{endAdornment: '€'}}
/>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary', fontStyle: 'italic'}}>
(Pferd- oder Reiter-Wechsel)
</Typography>
</Box>
</Box>
</Paper>
{/* Stallungen & Boxen */}
<Paper variant="outlined" sx={{p: 2.5, mb: 3}}>
<Typography variant="body2" sx={{fontSize: '11px', fontWeight: 600, mb: 2}}>
Stallungen & Boxen
</Typography>
<Box sx={{display: 'flex', flexDirection: 'column', gap: 2}}>
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 240, fontSize: '11px'}}>
Box pro Tag:
</Typography>
<TextField
size="small"
value={boxenProTag}
onChange={(e) => setBoxenProTag(e.target.value)}
sx={{width: 120, '& .MuiInputBase-input': {fontSize: '11px', py: 0.75, textAlign: 'right'}}}
InputProps={{endAdornment: '€'}}
/>
</Box>
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 240, fontSize: '11px'}}>
Einstreu (Erst-Einstreu):
</Typography>
<TextField
size="small"
value={einstreuErst}
onChange={(e) => setEinstreuErst(e.target.value)}
sx={{width: 120, '& .MuiInputBase-input': {fontSize: '11px', py: 0.75, textAlign: 'right'}}}
InputProps={{endAdornment: '€'}}
/>
</Box>
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 240, fontSize: '11px'}}>
Einstreu (Nachlegen):
</Typography>
<TextField
size="small"
value={einstreuNach}
onChange={(e) => setEinstreuNach(e.target.value)}
sx={{width: 120, '& .MuiInputBase-input': {fontSize: '11px', py: 0.75, textAlign: 'right'}}}
InputProps={{endAdornment: '€'}}
/>
</Box>
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 240, fontSize: '11px'}}>
Paddock pro Tag:
</Typography>
<TextField
size="small"
value={paddockProTag}
onChange={(e) => setPaddockProTag(e.target.value)}
sx={{width: 120, '& .MuiInputBase-input': {fontSize: '11px', py: 0.75, textAlign: 'right'}}}
InputProps={{endAdornment: '€'}}
/>
</Box>
</Box>
</Paper>
{/* Zusatzgebühren */}
<Paper variant="outlined" sx={{p: 2.5, mb: 3}}>
<Box sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2}}>
<Typography variant="body2" sx={{fontSize: '11px', fontWeight: 600}}>
Zusatzgebühren
</Typography>
<Button
size="small"
startIcon={<AddIcon/>}
onClick={handleZusatzgebuehrHinzufuegen}
sx={{fontSize: '10px'}}
>
Hinzufügen
</Button>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{fontSize: '10px', fontWeight: 600}}>Bezeichnung</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, width: 140}}>Betrag</TableCell>
<TableCell sx={{fontSize: '10px', fontWeight: 600, width: 100}}>Pflicht</TableCell>
<TableCell sx={{width: 50}}></TableCell>
</TableRow>
</TableHead>
<TableBody>
{zusatzgebuehren.map((gebuehr) => (
<TableRow key={gebuehr.id}>
<TableCell sx={{py: 1}}>
<TextField
size="small"
fullWidth
value={gebuehr.bezeichnung}
onChange={(e) => handleZusatzgebuehrAendern(gebuehr.id, 'bezeichnung', e.target.value)}
placeholder="z.B. Stromanschluss"
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.5}}}
/>
</TableCell>
<TableCell sx={{py: 1}}>
<TextField
size="small"
value={gebuehr.betrag}
onChange={(e) => handleZusatzgebuehrAendern(gebuehr.id, 'betrag', e.target.value)}
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.5, textAlign: 'right'}}}
InputProps={{endAdornment: '€'}}
/>
</TableCell>
<TableCell sx={{py: 1}}>
<FormControlLabel
control={
<Checkbox
size="small"
checked={gebuehr.pflicht}
onChange={(e) => handleZusatzgebuehrAendern(gebuehr.id, 'pflicht', e.target.checked)}
/>
}
label={<Typography sx={{fontSize: '10px'}}>Pflicht</Typography>}
/>
</TableCell>
<TableCell sx={{py: 1}}>
<IconButton
size="small"
onClick={() => handleZusatzgebuehrLoeschen(gebuehr.id)}
>
<DeleteIcon sx={{fontSize: 16}}/>
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{zusatzgebuehren.length === 0 && (
<Box sx={{textAlign: 'center', py: 3}}>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary'}}>
Keine Zusatzgebühren definiert
</Typography>
</Box>
)}
</Paper>
{/* Hinweis */}
<Paper variant="outlined" sx={{p: 2, mb: 3, bgcolor: '#f5f5f5'}}>
<Typography variant="body2" sx={{fontSize: '10px', mb: 1, fontWeight: 600}}>
ℹ️ Hinweis zur Preisliste
</Typography>
<Typography variant="body2" sx={{fontSize: '10px', lineHeight: 1.6}}>
Die Gebührenstruktur wird in der offiziellen Ausschreibung veröffentlicht und ist für alle Teilnehmer
verbindlich. Bei nationalen Turnieren der Kategorie C-Neu sind oft reduzierte Gebühren oder
Gebührenbefreiungen
üblich (z.B. kein Nenngeld, kein Sporteuro).
</Typography>
</Paper>
{/* Action Buttons */}
<Box sx={{display: 'flex', gap: 2, justifyContent: 'flex-end'}}>
<Button variant="outlined" size="small" sx={{fontSize: '11px', px: 3}}>
Zurücksetzen
</Button>
<Button variant="contained" size="small" onClick={handleSpeichern} sx={{fontSize: '11px', px: 3}}>
Speichern
</Button>
</Box>
</Box>
</Box>
);
}
@@ -0,0 +1,831 @@
import {useState} from 'react';
import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import Radio from '@mui/material/Radio';
import RadioGroup from '@mui/material/RadioGroup';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormControl from '@mui/material/FormControl';
import Checkbox from '@mui/material/Checkbox';
import FormGroup from '@mui/material/FormGroup';
import Typography from '@mui/material/Typography';
import Paper from '@mui/material/Paper';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import Autocomplete from '@mui/material/Autocomplete';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import {DatePicker} from '@mui/x-date-pickers/DatePicker';
import {LocalizationProvider} from '@mui/x-date-pickers/LocalizationProvider';
import {AdapterDateFns} from '@mui/x-date-pickers/AdapterDateFns';
import {de} from 'date-fns/locale';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
// Kategorien basierend auf Screenshot
const kategorienDressur = [
'CDN-A', 'CDN-A*', 'CDN-B', 'CDN-B*', 'CDN-C', 'CDN-C-Neu', 'CDNP-B', 'CDNP-C', 'CDNP-C-Neu'
];
const kategorienSpringen = [
'CSN-A', 'CSN-A*', 'CSN-B', 'CSN-B*', 'CSN-C', 'CSN-C-Neu', 'CSNP-A', 'CSNP-B', 'CSNP-C', 'CSNP-C-Neu'
];
// Mock-Daten für Vereine (später vom Backend)
const mockVereine = [
'RFV Neumarkt/Hausruck',
'Reitclub Wien',
'Reitverein Salzburg',
'Pferdesportverband OÖ',
'RC Linz',
];
interface StammdatenTabProps {
turnierId?: string;
}
export function StammdatenTab({turnierId}: StammdatenTabProps) {
const [turniernummer, setTurniernummer] = useState('');
const [turniernummerError, setTurniernummerError] = useState('');
const [turnierTitel, setTurnierTitel] = useState('');
const [kommentar, setKommentar] = useState('');
const [typ, setTyp] = useState('national');
const [sprache, setSprache] = useState('deutsch');
// Sparten (kombinierbar)
const [sparteDressur, setSparteDressur] = useState(false);
const [sparteSpringen, setSparteSpringen] = useState(false);
// Klassen (kombinierbar!)
const [klasseC, setKlasseC] = useState(false);
const [klasseB, setKlasseB] = useState(false);
const [klasseA, setKlasseA] = useState(false);
// Kategorien (Mehrfachauswahl!)
const [selectedKategorien, setSelectedKategorien] = useState<string[]>([]);
const [datumVon, setDatumVon] = useState<Date | null>(null);
const [datumBis, setDatumBis] = useState<Date | null>(null);
const [verein, setVerein] = useState<string | null>(null);
const [logo, setLogo] = useState('');
// Vorschau nach Speichern
const [showVorschau, setShowVorschau] = useState(false);
// Bestätigungsdialog für Initialisierung
const [showInitDialog, setShowInitDialog] = useState(false);
// Initialisierungs-Status: Turnier ist initialisiert, wenn eine Turnier-Nr. vorhanden ist
const istNeu = turnierId === 'neu';
const [turniernummerBestaetigt, setTurniernummerBestaetigt] = useState(false);
const istInitialisiert = !istNeu || turniernummerBestaetigt;
// Turniernummer validieren
const validateTurniernummer = (value: string) => {
if (value.length === 0) {
setTurniernummerError('');
return;
}
if (!/^\d+$/.test(value)) {
setTurniernummerError('Nur Zahlen erlaubt');
return;
}
if (value.length !== 5) {
setTurniernummerError('Muss 5-stellig sein');
return;
}
setTurniernummerError('');
};
// Verfügbare Kategorien basierend auf Sparte UND Klasse
const verfuegbareKategorien = (() => {
const kategorien: string[] = [];
// Sparte bestimmt die Basis-Kategorien
const basisKategorien: string[] = [];
if (sparteDressur) basisKategorien.push(...kategorienDressur);
if (sparteSpringen) basisKategorien.push(...kategorienSpringen);
// Filter nach Klassen (C, B, A)
const selectedKlassen: string[] = [];
if (klasseC) selectedKlassen.push('C', 'C-Neu');
if (klasseB) selectedKlassen.push('B', 'B*');
if (klasseA) selectedKlassen.push('A', 'A*');
if (selectedKlassen.length > 0 && basisKategorien.length > 0) {
return basisKategorien.filter(kat => {
// Extrahiere die Klasse aus der Kategorie (z.B. "CSN-C-Neu" -> "C-Neu")
const match = kat.match(/-(C-Neu|C|B\*|B|A\*|A)$/i);
if (match) {
const katKlasse = match[1].toUpperCase();
return selectedKlassen.some(k => k.toUpperCase() === katKlasse);
}
return false;
});
}
return [];
})();
const handleKategorieToggle = (kategorie: string) => {
if (!istInitialisiert) return;
setSelectedKategorien(prev =>
prev.includes(kategorie)
? prev.filter(k => k !== kategorie)
: [...prev, kategorie]
);
};
const handleInitialisieren = () => {
if (turniernummer.trim().length !== 5 || turniernummerError) return;
console.log('Turnier initialisieren mit Nr.:', turniernummer);
// TODO: Backend-Call zur Datenbank-Initialisierung
setTurniernummerBestaetigt(true);
};
const handleZuruecksetzen = () => {
setTurniernummer('');
setTurniernummerError('');
setTurnierTitel('');
setKommentar('');
setTyp('national');
setSprache('deutsch');
setSparteDressur(false);
setSparteSpringen(false);
setKlasseC(false);
setKlasseB(false);
setKlasseA(false);
setSelectedKategorien([]);
setDatumVon(null);
setDatumBis(null);
setVerein(null);
setLogo('');
setShowVorschau(false);
};
const handleSpeichern = () => {
console.log('Turnier speichern:', {
turniernummer,
turnierTitel,
kommentar,
typ,
sprache,
sparteDressur,
sparteSpringen,
klasseC,
klasseB,
klasseA,
selectedKategorien,
datumVon,
datumBis,
verein,
logo,
});
// TODO: Backend Integration
setShowVorschau(true);
};
return (
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={de}>
<Box sx={{height: '100%', bgcolor: 'background.default', p: 3, overflowY: 'auto'}}>
{/* Vorschau nach Speichern (oben zentral) */}
{showVorschau && (
<Paper elevation={3} sx={{p: 3, mb: 3, maxWidth: 800, mx: 'auto', borderRadius: 2}}>
<Box sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2}}>
<Typography variant="h6" sx={{fontSize: '13px', fontWeight: 600}}>
✅ Turnier gespeichert
</Typography>
<Button
size="small"
onClick={() => setShowVorschau(false)}
sx={{fontSize: '10px', minWidth: 'auto', px: 1}}
>
Schließen
</Button>
</Box>
<Paper elevation={2} sx={{p: 2.5, borderRadius: 2, bgcolor: 'grey.50'}}>
{/* Turniernummer Badge */}
<Box sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2}}>
<Box
sx={{
bgcolor: 'primary.main',
color: 'white',
px: 1.5,
py: 0.5,
borderRadius: 1,
fontSize: '11px',
fontWeight: 600
}}
>
{turniernummer}
</Box>
<Box sx={{display: 'flex', gap: 0.5, flexWrap: 'wrap', justifyContent: 'flex-end'}}>
{selectedKategorien.slice(0, 5).map((kat, idx) => (
<Box
key={idx}
sx={{
bgcolor: 'grey.200',
px: 1,
py: 0.25,
borderRadius: 0.5,
fontSize: '9px'
}}
>
{kat}
</Box>
))}
{selectedKategorien.length > 5 && (
<Box
sx={{
bgcolor: 'grey.300',
px: 1,
py: 0.25,
borderRadius: 0.5,
fontSize: '9px'
}}
>
+{selectedKategorien.length - 5}
</Box>
)}
</Box>
</Box>
{/* Turnier-Titel */}
<Typography variant="h6" sx={{fontSize: '14px', fontWeight: 600, mb: 1.5}}>
{turnierTitel || 'Frühjahrs-Turnier 2026'}
</Typography>
{/* Sparten & Klassen */}
<Box sx={{display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2}}>
{sparteDressur && (
<Box
sx={{bgcolor: 'info.100', color: 'info.dark', px: 1.5, py: 0.5, borderRadius: 1, fontSize: '10px'}}>
🏇 Dressur
</Box>
)}
{sparteSpringen && (
<Box sx={{
bgcolor: 'success.100',
color: 'success.dark',
px: 1.5,
py: 0.5,
borderRadius: 1,
fontSize: '10px'
}}>
🐴 Springen
</Box>
)}
{klasseC && (
<Box sx={{
bgcolor: 'warning.100',
color: 'warning.dark',
px: 1.5,
py: 0.5,
borderRadius: 1,
fontSize: '10px'
}}>
Klasse C
</Box>
)}
{klasseB && (
<Box sx={{
bgcolor: 'warning.100',
color: 'warning.dark',
px: 1.5,
py: 0.5,
borderRadius: 1,
fontSize: '10px'
}}>
Klasse B
</Box>
)}
{klasseA && (
<Box sx={{
bgcolor: 'warning.100',
color: 'warning.dark',
px: 1.5,
py: 0.5,
borderRadius: 1,
fontSize: '10px'
}}>
Klasse A
</Box>
)}
</Box>
{/* Details */}
<Box sx={{display: 'flex', gap: 3, flexWrap: 'wrap'}}>
{(datumVon || datumBis) && (
<Typography variant="body2" sx={{fontSize: '11px', color: 'text.secondary'}}>
📅 {datumVon?.toLocaleDateString('de-DE') || '...'} - {datumBis?.toLocaleDateString('de-DE') || '...'}
</Typography>
)}
{verein && (
<Typography variant="body2" sx={{fontSize: '11px', color: 'text.secondary'}}>
🏛️ {verein}
</Typography>
)}
</Box>
{/* Kommentar */}
{kommentar && (
<Typography variant="body2" sx={{
fontSize: '10px',
color: 'text.secondary',
mt: 2,
pt: 2,
borderTop: 1,
borderColor: 'divider',
fontStyle: 'italic'
}}>
{kommentar}
</Typography>
)}
</Paper>
</Paper>
)}
{/* Formular (volle Breite) */}
<Box sx={{maxWidth: 800, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 2.5}}>
{/* Hinweis für neue Veranstaltung */}
{istNeu && !istInitialisiert && (
<Paper sx={{p: 2, mb: 2, bgcolor: 'info.50', borderLeft: 3, borderColor: 'info.main'}}>
<Typography variant="body2" sx={{fontSize: '11px', fontWeight: 600, mb: 0.5}}>
🔑 Turnier-Nummer erforderlich
</Typography>
<Typography variant="body2" sx={{fontSize: '10px', lineHeight: 1.6}}>
Bitte geben Sie zuerst eine <strong>5-stellige Turnier-Nummer</strong> ein und klicken Sie auf
"Initialisieren".
Diese eindeutige Nummer wird vom ÖPSS vergeben und dient als Schlüssel für die
Datenbank-Initialisierung.
</Typography>
</Paper>
)}
{/* Turniernummer mit Initialisieren-Button */}
<Box sx={{display: 'flex', alignItems: 'flex-start', gap: 2}}>
<Typography variant="body2" sx={{
minWidth: 180,
fontSize: '11px',
fontWeight: istNeu && !istInitialisiert ? 600 : 400,
mt: 1
}}>
Turnier-Nr.: {istNeu && !istInitialisiert && <span style={{color: 'red'}}>*</span>}
</Typography>
<Box sx={{display: 'flex', alignItems: 'center', gap: 2, flex: 1}}>
<TextField
size="small"
value={turniernummer}
onChange={(e) => {
const value = e.target.value;
// Nur Zahlen erlauben, maximal 5 Stellen
if (value === '' || (/^\d+$/.test(value) && value.length <= 5)) {
setTurniernummer(value);
validateTurniernummer(value);
}
}}
placeholder="z.B. 26128"
autoFocus={istNeu}
disabled={istInitialisiert && istNeu}
error={!!turniernummerError}
helperText={turniernummerError}
sx={{
width: 150,
'& .MuiInputBase-input': {
fontSize: '11px',
py: 0.75,
fontWeight: istNeu && !istInitialisiert ? 600 : 400,
},
'& .MuiOutlinedInput-root': {
bgcolor: istNeu && !istInitialisiert ? 'info.50' : 'background.paper'
}
}}
/>
{istNeu && !istInitialisiert && (
<Button
variant="contained"
size="small"
onClick={() => setShowInitDialog(true)}
disabled={turniernummer.trim().length !== 5 || !!turniernummerError}
sx={{fontSize: '11px', px: 2.5, py: 0.75}}
>
Initialisieren
</Button>
)}
</Box>
</Box>
{/* Typ */}
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 180, fontSize: '11px'}}>
Typ:
</Typography>
<FormControl disabled={!istInitialisiert}>
<RadioGroup
row
value={typ}
onChange={(e) => setTyp(e.target.value)}
>
<FormControlLabel
value="national"
control={<Radio size="small"/>}
label={<Typography sx={{fontSize: '11px'}}>National</Typography>}
/>
<FormControlLabel
value="international"
control={<Radio size="small"/>}
label={<Typography sx={{fontSize: '11px', color: 'text.disabled'}}>International</Typography>}
disabled
/>
</RadioGroup>
</FormControl>
<Typography variant="body2" sx={{fontSize: '9px', color: 'text.secondary', fontStyle: 'italic'}}>
(kommt später)
</Typography>
</Box>
{/* Sprache */}
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 180, fontSize: '11px'}}>
Sprache:
</Typography>
<FormControl disabled={!istInitialisiert}>
<RadioGroup
row
value={sprache}
onChange={(e) => setSprache(e.target.value)}
>
<FormControlLabel
value="deutsch"
control={<Radio size="small"/>}
label={<Typography sx={{fontSize: '11px'}}>Deutsch</Typography>}
/>
<FormControlLabel
value="english"
control={<Radio size="small"/>}
label={<Typography sx={{fontSize: '11px', color: 'text.disabled'}}>English</Typography>}
disabled
/>
</RadioGroup>
</FormControl>
<Typography variant="body2" sx={{fontSize: '9px', color: 'text.secondary', fontStyle: 'italic'}}>
(kommt später)
</Typography>
</Box>
{/* Sparten */}
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 180, fontSize: '11px'}}>
Sparten:
</Typography>
<Box sx={{display: 'flex', gap: 2}}>
<FormControlLabel
control={
<Checkbox
size="small"
checked={sparteDressur}
onChange={(e) => {
setSparteDressur(e.target.checked);
setSelectedKategorien([]);
}}
disabled={!istInitialisiert}
/>
}
label={<Typography sx={{fontSize: '11px'}}>Dressur</Typography>}
/>
<FormControlLabel
control={
<Checkbox
size="small"
checked={sparteSpringen}
onChange={(e) => {
setSparteSpringen(e.target.checked);
setSelectedKategorien([]);
}}
disabled={!istInitialisiert}
/>
}
label={<Typography sx={{fontSize: '11px'}}>Springen</Typography>}
/>
</Box>
</Box>
{/* Klassen */}
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 180, fontSize: '11px'}}>
Klassen:
</Typography>
<Box sx={{display: 'flex', gap: 2}}>
<FormControlLabel
control={
<Checkbox
size="small"
checked={klasseC}
onChange={(e) => {
setKlasseC(e.target.checked);
setSelectedKategorien([]);
}}
disabled={!istInitialisiert}
/>
}
label={<Typography sx={{fontSize: '11px'}}>C</Typography>}
/>
<FormControlLabel
control={
<Checkbox
size="small"
checked={klasseB}
onChange={(e) => {
setKlasseB(e.target.checked);
setSelectedKategorien([]);
}}
disabled={!istInitialisiert}
/>
}
label={<Typography sx={{fontSize: '11px'}}>B</Typography>}
/>
<FormControlLabel
control={
<Checkbox
size="small"
checked={klasseA}
onChange={(e) => {
setKlasseA(e.target.checked);
setSelectedKategorien([]);
}}
disabled={!istInitialisiert}
/>
}
label={<Typography sx={{fontSize: '11px'}}>A</Typography>}
/>
</Box>
</Box>
{/* Kategorien (Mehrfachauswahl) */}
<Box sx={{display: 'flex', alignItems: 'flex-start', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 180, fontSize: '11px', mt: 1}}>
Kategorien:
</Typography>
<Paper variant="outlined" sx={{
p: 1.5,
flex: 1,
maxHeight: 200,
overflow: 'auto',
bgcolor: !istInitialisiert || verfuegbareKategorien.length === 0 ? 'action.disabledBackground' : 'background.paper'
}}>
{verfuegbareKategorien.length > 0 ? (
<FormGroup>
{verfuegbareKategorien.map((kategorie) => (
<FormControlLabel
key={kategorie}
control={
<Checkbox
size="small"
checked={selectedKategorien.includes(kategorie)}
onChange={() => handleKategorieToggle(kategorie)}
disabled={!istInitialisiert}
/>
}
label={<Typography sx={{fontSize: '11px'}}>{kategorie}</Typography>}
sx={{mb: 0.25}}
/>
))}
</FormGroup>
) : (
<Typography variant="body2" sx={{
fontSize: '10px',
color: 'text.secondary',
fontStyle: 'italic',
textAlign: 'center',
py: 2
}}>
{!sparteDressur && !sparteSpringen
? 'Bitte Sparte(n) auswählen'
: !klasseC && !klasseB && !klasseA
? 'Bitte Klasse(n) auswählen'
: 'Keine Kategorien verfügbar'}
</Typography>
)}
</Paper>
</Box>
{/* Datum von und bis in einer Zeile */}
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 180, fontSize: '11px'}}>
Datum:
</Typography>
<Box sx={{display: 'flex', alignItems: 'center', gap: 1.5}}>
<DatePicker
value={datumVon}
onChange={(newValue) => setDatumVon(newValue)}
disabled={!istInitialisiert}
slotProps={{
textField: {
size: 'small',
placeholder: 'von',
sx: {width: 160, '& .MuiInputBase-input': {fontSize: '11px', py: 0.75}}
}
}}
/>
<Typography variant="body2" sx={{fontSize: '11px'}}>bis</Typography>
<DatePicker
value={datumBis}
onChange={(newValue) => setDatumBis(newValue)}
disabled={!istInitialisiert}
minDate={datumVon || undefined}
slotProps={{
textField: {
size: 'small',
placeholder: 'bis',
sx: {width: 160, '& .MuiInputBase-input': {fontSize: '11px', py: 0.75}}
}
}}
/>
</Box>
</Box>
{/* Verein */}
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 180, fontSize: '11px'}}>
Verein:
</Typography>
<Autocomplete
size="small"
fullWidth
value={verein}
onChange={(e, newValue) => setVerein(newValue)}
disabled={!istInitialisiert}
options={mockVereine}
renderInput={(params) => (
<TextField
{...params}
placeholder="Suche Verein..."
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.75}}}
/>
)}
/>
</Box>
{/* Logo */}
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 180, fontSize: '11px'}}>
Logo:
</Typography>
<TextField
size="small"
fullWidth
value={logo}
onChange={(e) => setLogo(e.target.value)}
disabled={!istInitialisiert}
placeholder="Logo-Datei auswählen..."
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.75}}}
/>
<Button
variant="outlined"
size="small"
disabled={!istInitialisiert}
sx={{minWidth: 40, px: 1, fontSize: '10px'}}
startIcon={<FolderOpenIcon sx={{fontSize: 14}}/>}
>
</Button>
</Box>
{/* Turnier-Titel */}
<Box sx={{display: 'flex', alignItems: 'center', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 180, fontSize: '11px'}}>
Turnier-Titel:
</Typography>
<TextField
size="small"
fullWidth
value={turnierTitel}
onChange={(e) => setTurnierTitel(e.target.value)}
disabled={!istInitialisiert}
placeholder="Frühjahrs-Turnier 2026"
sx={{'& .MuiInputBase-input': {fontSize: '11px', py: 0.75}}}
/>
</Box>
{/* Kommentar */}
<Box sx={{display: 'flex', alignItems: 'flex-start', gap: 2}}>
<Typography variant="body2" sx={{minWidth: 180, fontSize: '11px', mt: 1}}>
Kommentar:
</Typography>
<TextField
size="small"
fullWidth
multiline
rows={3}
value={kommentar}
onChange={(e) => setKommentar(e.target.value)}
disabled={!istInitialisiert}
placeholder="z.B. KIDS CUP • PONY EINSTEIGER CUP OÖ"
sx={{'& .MuiInputBase-input': {fontSize: '11px'}}}
/>
</Box>
{/* Action Buttons */}
<Box sx={{
display: 'flex',
gap: 2,
justifyContent: 'flex-end',
mt: 3,
pt: 3,
borderTop: 1,
borderColor: 'divider'
}}>
<Button
variant="outlined"
size="small"
onClick={handleZuruecksetzen}
sx={{fontSize: '11px', px: 3, py: 0.75}}
>
Zurücksetzen
</Button>
<Button
variant="contained"
size="small"
onClick={handleSpeichern}
disabled={!istInitialisiert}
sx={{fontSize: '11px', px: 3, py: 0.75}}
>
Speichern
</Button>
</Box>
</Box>
{/* Bestätigungsdialog für Initialisierung */}
<Dialog
open={showInitDialog}
onClose={() => setShowInitDialog(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle sx={{fontSize: '13px', fontWeight: 600}}>
Turnier-Nummer bestätigen
</DialogTitle>
<DialogContent>
<Paper sx={{p: 2, mb: 2, bgcolor: 'warning.50', borderLeft: 3, borderColor: 'warning.main'}}>
<Typography variant="body2" sx={{fontSize: '11px', fontWeight: 600, mb: 1}}>
⚠️ Wichtig
</Typography>
<Typography variant="body2" sx={{fontSize: '10px', lineHeight: 1.6, mb: 1}}>
Die Turnier-Nummer kann nach der Initialisierung <strong>nicht mehr geändert</strong> werden.
</Typography>
<Typography variant="body2" sx={{fontSize: '10px', lineHeight: 1.6}}>
Bitte überprüfen Sie die eingegebene Nummer sorgfältig.
</Typography>
</Paper>
<Box sx={{display: 'flex', alignItems: 'center', gap: 2, mt: 3}}>
<Typography variant="body2" sx={{fontSize: '11px', fontWeight: 600}}>
Turnier-Nr.:
</Typography>
<Box
sx={{
bgcolor: 'primary.main',
color: 'white',
px: 2,
py: 1,
borderRadius: 1,
fontSize: '16px',
fontWeight: 600,
letterSpacing: '0.1em'
}}
>
{turniernummer}
</Box>
</Box>
<Typography variant="body2" sx={{fontSize: '11px', mt: 3, color: 'text.secondary'}}>
Ist diese Turnier-Nummer korrekt?
</Typography>
</DialogContent>
<DialogActions sx={{p: 2, gap: 1}}>
<Button
onClick={() => setShowInitDialog(false)}
variant="outlined"
size="small"
sx={{fontSize: '11px', px: 3}}
>
Abbrechen
</Button>
<Button
onClick={() => {
handleInitialisieren();
setShowInitDialog(false);
}}
variant="contained"
size="small"
sx={{fontSize: '11px', px: 3}}
>
Ja, initialisieren
</Button>
</DialogActions>
</Dialog>
</Box>
</LocalizationProvider>
);
}
@@ -0,0 +1,325 @@
import {useState} from 'react';
import {useParams} from 'react-router';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import Paper from '@mui/material/Paper';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Divider from '@mui/material/Divider';
import SaveIcon from '@mui/icons-material/Save';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import AddIcon from '@mui/icons-material/Add';
import UploadIcon from '@mui/icons-material/Upload';
import DownloadIcon from '@mui/icons-material/Download';
import UsbIcon from '@mui/icons-material/Usb';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CloudDownloadIcon from '@mui/icons-material/CloudDownload';
import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import WarningIcon from '@mui/icons-material/Warning';
import {veranstaltungenData} from '../Dashboard';
export function TransferTab() {
const {id} = useParams();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [selectedTurnierId, setSelectedTurnierId] = useState<string | null>(null);
// Veranstaltung laden
const veranstaltung = id !== 'neu'
? veranstaltungenData.find(v => v.id === parseInt(id || '0'))
: null;
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, turnierId: string) => {
setAnchorEl(event.currentTarget);
setSelectedTurnierId(turnierId);
};
const handleMenuClose = () => {
setAnchorEl(null);
setSelectedTurnierId(null);
};
const handleNeuesTurnier = () => {
console.log('Neues Turnier erstellen für Veranstaltung:', id);
// TODO: Dialog öffnen
};
const handleImportZNS = (turnierId: string) => {
console.log('Import ZNS N2-Daten für Turnier:', turnierId);
handleMenuClose();
};
const handleExportZNS = (turnierId: string) => {
console.log('Export ZNS für Turnier:', turnierId);
handleMenuClose();
};
const handleImportUSB = (turnierId: string) => {
console.log('Import von USB für Turnier:', turnierId);
handleMenuClose();
};
const handleExportUSB = (turnierId: string) => {
console.log('Export auf USB für Turnier:', turnierId);
handleMenuClose();
};
const handleImportLokal = (turnierId: string) => {
console.log('Import von lokaler Datei für Turnier:', turnierId);
handleMenuClose();
};
const handleExportLokal = (turnierId: string) => {
console.log('Export als lokale Datei für Turnier:', turnierId);
handleMenuClose();
};
if (!veranstaltung) {
return (
<Box sx={{p: 3}}>
<Typography variant="body2" sx={{fontSize: '11px'}}>
Veranstaltung nicht gefunden
</Typography>
</Box>
);
}
return (
<Box sx={{p: 3, bgcolor: 'background.default', height: '100%', overflowY: 'auto'}}>
<Box sx={{maxWidth: 1200}}>
{/* Veranstaltungs-Info oben */}
<Paper variant="outlined" sx={{p: 2.5, mb: 3, bgcolor: 'primary.50'}}>
<Box sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start'}}>
<Box sx={{flex: 1}}>
<Typography variant="h6" sx={{fontSize: '13px', fontWeight: 600, mb: 1}}>
{veranstaltung.name}
</Typography>
<Box sx={{display: 'flex', gap: 2}}>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary'}}>
📍 {veranstaltung.ort}
</Typography>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary'}}>
📅 {veranstaltung.datum}
</Typography>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary'}}>
🏆 {veranstaltung.turniere.length} Turniere
</Typography>
</Box>
</Box>
<Chip
label={veranstaltung.status === 'vorbereitung' ? 'In Vorbereitung' : veranstaltung.status}
color="info"
size="small"
sx={{fontSize: '9px'}}
/>
</Box>
</Paper>
{/* Button: Neues Turnier */}
<Box sx={{mb: 3, display: 'flex', justifyContent: 'center'}}>
<Button
variant="contained"
size="large"
startIcon={<AddIcon/>}
onClick={handleNeuesTurnier}
sx={{fontSize: '11px', px: 4, py: 1.5}}
>
Neues Turnier
</Button>
</Box>
{/* Turniere dieser Veranstaltung */}
<Typography variant="h6" sx={{fontSize: '12px', fontWeight: 600, mb: 2}}>
Turniere dieser Veranstaltung
</Typography>
<Box sx={{display: 'flex', flexDirection: 'column', gap: 2}}>
{veranstaltung.turniere.map((turnier) => (
<Card key={turnier.nr}>
<CardContent>
<Box sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2}}>
<Box sx={{flex: 1}}>
<Box sx={{display: 'flex', alignItems: 'center', gap: 1.5, mb: 1}}>
<Chip
label={turnier.nr}
size="small"
sx={{fontSize: '10px', fontWeight: 600, bgcolor: 'primary.main', color: 'white'}}
/>
<Typography variant="h6" sx={{fontSize: '13px', fontWeight: 600}}>
{turnier.name}
</Typography>
</Box>
<Box sx={{display: 'flex', gap: 2, mb: 1.5}}>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary'}}>
<CalendarTodayIcon sx={{fontSize: 11, verticalAlign: 'middle', mr: 0.5}}/>
{turnier.datum}
</Typography>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary'}}>
<EmojiEventsIcon sx={{fontSize: 11, verticalAlign: 'middle', mr: 0.5}}/>
{turnier.disziplin}
</Typography>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary'}}>
{turnier.bewerbeAnzahl} Bewerbe
</Typography>
<Box sx={{display: 'flex', alignItems: 'center', gap: 0.5}}>
{(turnier.kategorie === 'B' || turnier.kategorie === 'A') && (
turnier.znsStatus === 'geladen' ? (
<>
<CheckCircleIcon sx={{fontSize: 11, color: 'success.main'}}/>
<Typography variant="body2" sx={{fontSize: '10px', color: 'success.main'}}>
ZNS N2-Daten geladen
</Typography>
</>
) : (
<>
<WarningIcon sx={{fontSize: 11, color: 'warning.main'}}/>
<Typography variant="body2" sx={{fontSize: '10px', color: 'warning.main'}}>
ZNS N2-Daten ausstehend
</Typography>
</>
)
)}
</Box>
</Box>
</Box>
<IconButton
size="small"
onClick={(e) => handleMenuOpen(e, turnier.nr)}
>
<MoreVertIcon sx={{fontSize: 18}}/>
</IconButton>
</Box>
{/* Actions für dieses Turnier */}
<Box sx={{display: 'flex', gap: 1, flexWrap: 'wrap'}}>
<Button
size="small"
variant="outlined"
startIcon={<FolderOpenIcon sx={{fontSize: 14}}/>}
sx={{fontSize: '10px'}}
>
Öffnen
</Button>
{(turnier.kategorie === 'B' || turnier.kategorie === 'A') && (
<>
<Button
size="small"
variant="outlined"
startIcon={<CloudDownloadIcon sx={{fontSize: 14}}/>}
onClick={() => handleImportZNS(turnier.nr)}
sx={{fontSize: '10px'}}
>
ZNS N2 Import
</Button>
<Button
size="small"
variant="outlined"
startIcon={<CloudUploadIcon sx={{fontSize: 14}}/>}
onClick={() => handleExportZNS(turnier.nr)}
sx={{fontSize: '10px'}}
>
ZNS Export
</Button>
</>
)}
<Button
size="small"
variant="outlined"
startIcon={<UploadIcon sx={{fontSize: 14}}/>}
onClick={() => handleImportLokal(turnier.nr)}
sx={{fontSize: '10px'}}
>
Import
</Button>
<Button
size="small"
variant="outlined"
startIcon={<DownloadIcon sx={{fontSize: 14}}/>}
onClick={() => handleExportLokal(turnier.nr)}
sx={{fontSize: '10px'}}
>
Export
</Button>
<Button
size="small"
variant="outlined"
startIcon={<UsbIcon sx={{fontSize: 14}}/>}
onClick={() => handleImportUSB(turnier.nr)}
sx={{fontSize: '10px'}}
>
USB
</Button>
</Box>
</CardContent>
</Card>
))}
</Box>
{veranstaltung.turniere.length === 0 && (
<Paper variant="outlined" sx={{p: 4, textAlign: 'center'}}>
<Typography variant="body2" sx={{fontSize: '11px', color: 'text.secondary', mb: 2}}>
Noch keine Turniere für diese Veranstaltung angelegt
</Typography>
<Button
variant="contained"
startIcon={<AddIcon/>}
onClick={handleNeuesTurnier}
sx={{fontSize: '10px'}}
>
Erstes Turnier erstellen
</Button>
</Paper>
)}
{/* Context Menu */}
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
>
<MenuItem onClick={() => selectedTurnierId && handleImportLokal(selectedTurnierId)} sx={{fontSize: '10px'}}>
<UploadIcon sx={{fontSize: 14, mr: 1}}/>
Import von lokaler Datei
</MenuItem>
<MenuItem onClick={() => selectedTurnierId && handleExportLokal(selectedTurnierId)} sx={{fontSize: '10px'}}>
<DownloadIcon sx={{fontSize: 14, mr: 1}}/>
Export als lokale Datei
</MenuItem>
<Divider/>
<MenuItem onClick={() => selectedTurnierId && handleImportUSB(selectedTurnierId)} sx={{fontSize: '10px'}}>
<UsbIcon sx={{fontSize: 14, mr: 1}}/>
Import von USB-Stick
</MenuItem>
<MenuItem onClick={() => selectedTurnierId && handleExportUSB(selectedTurnierId)} sx={{fontSize: '10px'}}>
<UsbIcon sx={{fontSize: 14, mr: 1}}/>
Export auf USB-Stick
</MenuItem>
{selectedTurnierId && veranstaltung.turniere.find(t => t.nr === selectedTurnierId)?.kategorie !== 'C' && (
<>
<Divider/>
<MenuItem onClick={() => selectedTurnierId && handleImportZNS(selectedTurnierId)} sx={{fontSize: '10px'}}>
<CloudDownloadIcon sx={{fontSize: 14, mr: 1}}/>
ZNS N2-Daten importieren
</MenuItem>
<MenuItem onClick={() => selectedTurnierId && handleExportZNS(selectedTurnierId)} sx={{fontSize: '10px'}}>
<CloudUploadIcon sx={{fontSize: 14, mr: 1}}/>
ZNS Ergebnisse exportieren
</MenuItem>
</>
)}
</Menu>
</Box>
</Box>
);
}
@@ -0,0 +1,347 @@
import {useState} from 'react';
import {useParams, useNavigate} from 'react-router';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import Paper from '@mui/material/Paper';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Divider from '@mui/material/Divider';
import SaveIcon from '@mui/icons-material/Save';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import AddIcon from '@mui/icons-material/Add';
import UploadIcon from '@mui/icons-material/Upload';
import DownloadIcon from '@mui/icons-material/Download';
import UsbIcon from '@mui/icons-material/Usb';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CloudDownloadIcon from '@mui/icons-material/CloudDownload';
import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import WarningIcon from '@mui/icons-material/Warning';
import {veranstaltungenData} from '../Dashboard';
export function VeranstaltungUebersicht() {
const params = useParams();
const id = params.id;
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [selectedTurnierId, setSelectedTurnierId] = useState<string | null>(null);
const navigate = useNavigate();
// Veranstaltung laden
const veranstaltung = id !== 'neu'
? veranstaltungenData.find(v => v.id === parseInt(id || '0'))
: null;
// Wenn neu, zeige eine leere Ansicht für neue Veranstaltung
if (id === 'neu') {
return (
<Box sx={{p: 3, bgcolor: 'background.default', height: '100%', overflowY: 'auto'}}>
<Box sx={{maxWidth: 1200}}>
<Paper variant="outlined" sx={{p: 2.5, mb: 3, bgcolor: 'info.50'}}>
<Typography variant="h6" sx={{fontSize: '13px', fontWeight: 600, mb: 1}}>
🆕 Neue Veranstaltung erstellen
</Typography>
<Typography variant="body2" sx={{fontSize: '11px', color: 'text.secondary'}}>
Bitte wechseln Sie zu den Tabs "Stammdaten", "Organisation", "Bewerbe" oder "Preisliste", um die
Veranstaltung zu konfigurieren.
</Typography>
</Paper>
</Box>
</Box>
);
}
if (!veranstaltung) {
return (
<Box sx={{p: 3}}>
<Typography variant="body2" sx={{fontSize: '11px'}}>
Veranstaltung nicht gefunden
</Typography>
</Box>
);
}
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, turnierId: string) => {
setAnchorEl(event.currentTarget);
setSelectedTurnierId(turnierId);
};
const handleMenuClose = () => {
setAnchorEl(null);
setSelectedTurnierId(null);
};
const handleNeuesTurnier = () => {
console.log('Neues Turnier erstellen für Veranstaltung:', id);
navigate(`/veranstaltung/${id}/turnier/neu`);
};
const handleImportZNS = (turnierId: string) => {
console.log('Import ZNS N2-Daten für Turnier:', turnierId);
handleMenuClose();
};
const handleExportZNS = (turnierId: string) => {
console.log('Export ZNS für Turnier:', turnierId);
handleMenuClose();
};
const handleImportUSB = (turnierId: string) => {
console.log('Import von USB für Turnier:', turnierId);
handleMenuClose();
};
const handleExportUSB = (turnierId: string) => {
console.log('Export auf USB für Turnier:', turnierId);
handleMenuClose();
};
const handleImportLokal = (turnierId: string) => {
console.log('Import von lokaler Datei für Turnier:', turnierId);
handleMenuClose();
};
const handleExportLokal = (turnierId: string) => {
console.log('Export als lokale Datei für Turnier:', turnierId);
handleMenuClose();
};
return (
<Box sx={{p: 3, bgcolor: 'background.default', height: '100%', overflowY: 'auto'}}>
<Box sx={{maxWidth: 1200}}>
{/* Veranstaltungs-Info oben */}
<Paper variant="outlined" sx={{p: 2.5, mb: 3, bgcolor: 'primary.50'}}>
<Box sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start'}}>
<Box sx={{flex: 1}}>
<Typography variant="h6" sx={{fontSize: '13px', fontWeight: 600, mb: 1}}>
{veranstaltung.name}
</Typography>
<Box sx={{display: 'flex', gap: 2}}>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary'}}>
📍 {veranstaltung.ort}
</Typography>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary'}}>
📅 {veranstaltung.datum}
</Typography>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary'}}>
🏆 {veranstaltung.turniere.length} Turniere
</Typography>
</Box>
</Box>
<Chip
label={veranstaltung.status === 'vorbereitung' ? 'In Vorbereitung' : veranstaltung.status}
color="info"
size="small"
sx={{fontSize: '9px'}}
/>
</Box>
</Paper>
{/* Button: Neues Turnier */}
<Box sx={{mb: 3, display: 'flex', justifyContent: 'center'}}>
<Button
variant="contained"
size="large"
startIcon={<AddIcon/>}
onClick={handleNeuesTurnier}
sx={{fontSize: '11px', px: 4, py: 1.5}}
>
Neues Turnier
</Button>
</Box>
{/* Turniere dieser Veranstaltung */}
<Typography variant="h6" sx={{fontSize: '12px', fontWeight: 600, mb: 2}}>
Turniere dieser Veranstaltung
</Typography>
<Box sx={{display: 'flex', flexDirection: 'column', gap: 2}}>
{veranstaltung.turniere.map((turnier) => (
<Card key={turnier.nr}>
<CardContent>
<Box sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2}}>
<Box sx={{flex: 1}}>
<Box sx={{display: 'flex', alignItems: 'center', gap: 1.5, mb: 1}}>
<Chip
label={turnier.nr}
size="small"
sx={{fontSize: '10px', fontWeight: 600, bgcolor: 'primary.main', color: 'white'}}
/>
<Typography variant="h6" sx={{fontSize: '13px', fontWeight: 600}}>
{turnier.name}
</Typography>
</Box>
<Box sx={{display: 'flex', gap: 2, mb: 1.5}}>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary'}}>
<CalendarTodayIcon sx={{fontSize: 11, verticalAlign: 'middle', mr: 0.5}}/>
{turnier.datum}
</Typography>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary'}}>
<EmojiEventsIcon sx={{fontSize: 11, verticalAlign: 'middle', mr: 0.5}}/>
{turnier.disziplin}
</Typography>
<Typography variant="body2" sx={{fontSize: '10px', color: 'text.secondary'}}>
{turnier.bewerbeAnzahl} Bewerbe
</Typography>
<Box sx={{display: 'flex', alignItems: 'center', gap: 0.5}}>
{(turnier.kategorie === 'B' || turnier.kategorie === 'A') && (
turnier.znsStatus === 'geladen' ? (
<>
<CheckCircleIcon sx={{fontSize: 11, color: 'success.main'}}/>
<Typography variant="body2" sx={{fontSize: '10px', color: 'success.main'}}>
ZNS N2-Daten geladen
</Typography>
</>
) : (
<>
<WarningIcon sx={{fontSize: 11, color: 'warning.main'}}/>
<Typography variant="body2" sx={{fontSize: '10px', color: 'warning.main'}}>
ZNS N2-Daten ausstehend
</Typography>
</>
)
)}
</Box>
</Box>
</Box>
<IconButton
size="small"
onClick={(e) => handleMenuOpen(e, turnier.nr)}
>
<MoreVertIcon sx={{fontSize: 18}}/>
</IconButton>
</Box>
{/* Actions für dieses Turnier */}
<Box sx={{display: 'flex', gap: 1, flexWrap: 'wrap'}}>
<Button
size="small"
variant="outlined"
startIcon={<FolderOpenIcon sx={{fontSize: 14}}/>}
onClick={() => navigate(`/veranstaltung/${id}/turnier/${turnier.nr}`)}
sx={{fontSize: '10px'}}
>
Öffnen
</Button>
{(turnier.kategorie === 'B' || turnier.kategorie === 'A') && (
<>
<Button
size="small"
variant="outlined"
startIcon={<CloudDownloadIcon sx={{fontSize: 14}}/>}
onClick={() => handleImportZNS(turnier.nr)}
sx={{fontSize: '10px'}}
>
ZNS N2 Import
</Button>
<Button
size="small"
variant="outlined"
startIcon={<CloudUploadIcon sx={{fontSize: 14}}/>}
onClick={() => handleExportZNS(turnier.nr)}
sx={{fontSize: '10px'}}
>
ZNS Export
</Button>
</>
)}
<Button
size="small"
variant="outlined"
startIcon={<UploadIcon sx={{fontSize: 14}}/>}
onClick={() => handleImportLokal(turnier.nr)}
sx={{fontSize: '10px'}}
>
Import
</Button>
<Button
size="small"
variant="outlined"
startIcon={<DownloadIcon sx={{fontSize: 14}}/>}
onClick={() => handleExportLokal(turnier.nr)}
sx={{fontSize: '10px'}}
>
Export
</Button>
<Button
size="small"
variant="outlined"
startIcon={<UsbIcon sx={{fontSize: 14}}/>}
onClick={() => handleImportUSB(turnier.nr)}
sx={{fontSize: '10px'}}
>
USB
</Button>
</Box>
</CardContent>
</Card>
))}
</Box>
{veranstaltung.turniere.length === 0 && (
<Paper variant="outlined" sx={{p: 4, textAlign: 'center'}}>
<Typography variant="body2" sx={{fontSize: '11px', color: 'text.secondary', mb: 2}}>
Noch keine Turniere für diese Veranstaltung angelegt
</Typography>
<Button
variant="contained"
startIcon={<AddIcon/>}
onClick={handleNeuesTurnier}
sx={{fontSize: '10px'}}
>
Erstes Turnier erstellen
</Button>
</Paper>
)}
{/* Context Menu */}
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
>
<MenuItem onClick={() => selectedTurnierId && handleImportLokal(selectedTurnierId)} sx={{fontSize: '10px'}}>
<UploadIcon sx={{fontSize: 14, mr: 1}}/>
Import von lokaler Datei
</MenuItem>
<MenuItem onClick={() => selectedTurnierId && handleExportLokal(selectedTurnierId)} sx={{fontSize: '10px'}}>
<DownloadIcon sx={{fontSize: 14, mr: 1}}/>
Export als lokale Datei
</MenuItem>
<Divider/>
<MenuItem onClick={() => selectedTurnierId && handleImportUSB(selectedTurnierId)} sx={{fontSize: '10px'}}>
<UsbIcon sx={{fontSize: 14, mr: 1}}/>
Import von USB-Stick
</MenuItem>
<MenuItem onClick={() => selectedTurnierId && handleExportUSB(selectedTurnierId)} sx={{fontSize: '10px'}}>
<UsbIcon sx={{fontSize: 14, mr: 1}}/>
Export auf USB-Stick
</MenuItem>
{selectedTurnierId && veranstaltung.turniere.find(t => t.nr === selectedTurnierId)?.kategorie !== 'C' && (
<>
<Divider/>
<MenuItem onClick={() => selectedTurnierId && handleImportZNS(selectedTurnierId)} sx={{fontSize: '10px'}}>
<CloudDownloadIcon sx={{fontSize: 14, mr: 1}}/>
ZNS N2-Daten importieren
</MenuItem>
<MenuItem onClick={() => selectedTurnierId && handleExportZNS(selectedTurnierId)} sx={{fontSize: '10px'}}>
<CloudUploadIcon sx={{fontSize: 14, mr: 1}}/>
ZNS Ergebnisse exportieren
</MenuItem>
</>
)}
</Menu>
</Box>
</Box>
);
}
@@ -0,0 +1,67 @@
"use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import {ChevronDownIcon} from "lucide-react";
import {cn} from "./utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDownIcon
className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export {Accordion, AccordionItem, AccordionTrigger, AccordionContent};
@@ -0,0 +1,157 @@
"use client";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import {cn} from "./utils";
import {buttonVariants} from "./button";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay/>
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({variant: "outline"}), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
@@ -0,0 +1,66 @@
import * as React from "react";
import {cva, type VariantProps} from "class-variance-authority";
import {cn} from "./utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({variant}), className)}
{...props}
/>
);
}
function AlertTitle({className, ...props}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...props}
/>
);
}
export {Alert, AlertTitle, AlertDescription};
@@ -0,0 +1,11 @@
"use client";
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
}
export {AspectRatio};
@@ -0,0 +1,53 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import {cn} from "./utils";
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className,
)}
{...props}
/>
);
}
export {Avatar, AvatarImage, AvatarFallback};
@@ -0,0 +1,46 @@
import * as React from "react";
import {Slot} from "@radix-ui/react-slot";
import {cva, type VariantProps} from "class-variance-authority";
import {cn} from "./utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({variant}), className)}
{...props}
/>
);
}
export {Badge, badgeVariants};
@@ -0,0 +1,109 @@
import * as React from "react";
import {Slot} from "@radix-ui/react-slot";
import {ChevronRight, MoreHorizontal} from "lucide-react";
import {cn} from "./utils";
function Breadcrumb({...props}: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({className, ...props}: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className,
)}
{...props}
/>
);
}
function BreadcrumbItem({className, ...props}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
}
function BreadcrumbPage({className, ...props}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight/>}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4"/>
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
@@ -0,0 +1,58 @@
import * as React from "react";
import {Slot} from "@radix-ui/react-slot";
import {cva, type VariantProps} from "class-variance-authority";
import {cn} from "./utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9 rounded-md",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({variant, size, className}))}
{...props}
/>
);
}
export {Button, buttonVariants};
@@ -0,0 +1,75 @@
"use client";
import * as React from "react";
import {ChevronLeft, ChevronRight} from "lucide-react";
import {DayPicker} from "react-day-picker";
import {cn} from "./utils";
import {buttonVariants} from "./button";
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: React.ComponentProps<typeof DayPicker>) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row gap-2",
month: "flex flex-col gap-4",
caption: "flex justify-center pt-1 relative items-center w-full",
caption_label: "text-sm font-medium",
nav: "flex items-center gap-1",
nav_button: cn(
buttonVariants({variant: "outline"}),
"size-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-x-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md",
),
day: cn(
buttonVariants({variant: "ghost"}),
"size-8 p-0 font-normal aria-selected:opacity-100",
),
day_range_start:
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
day_range_end:
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({className, ...props}) => (
<ChevronLeft className={cn("size-4", className)} {...props} />
),
IconRight: ({className, ...props}) => (
<ChevronRight className={cn("size-4", className)} {...props} />
),
}}
{...props}
/>
);
}
export {Calendar};
@@ -0,0 +1,92 @@
import * as React from "react";
import {cn} from "./utils";
function Card({className, ...props}: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border",
className,
)}
{...props}
/>
);
}
function CardHeader({className, ...props}: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 pt-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({className, ...props}: React.ComponentProps<"div">) {
return (
<h4
data-slot="card-title"
className={cn("leading-none", className)}
{...props}
/>
);
}
function CardDescription({className, ...props}: React.ComponentProps<"div">) {
return (
<p
data-slot="card-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
);
}
function CardAction({className, ...props}: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({className, ...props}: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6 [&:last-child]:pb-6", className)}
{...props}
/>
);
}
function CardFooter({className, ...props}: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 pb-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
@@ -0,0 +1,241 @@
"use client";
import * as React from "react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import {ArrowLeft, ArrowRight} from "lucide-react";
import {cn} from "./utils";
import {Button} from "./button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({className, ...props}: React.ComponentProps<"div">) {
const {carouselRef, orientation} = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
}
function CarouselItem({className, ...props}: React.ComponentProps<"div">) {
const {orientation} = useCarousel();
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const {orientation, scrollPrev, canScrollPrev} = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft/>
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const {orientation, scrollNext, canScrollNext} = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight/>
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};
@@ -0,0 +1,353 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import {cn} from "./utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = {light: "", dark: ".dark"} as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{config}}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config}/>
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({id, config}: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const {config} = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon/>
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const {config} = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon/>
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};
@@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import {CheckIcon} from "lucide-react";
import {cn} from "./utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border bg-input-background dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5"/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export {Checkbox};
@@ -0,0 +1,33 @@
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export {Collapsible, CollapsibleTrigger, CollapsibleContent};
@@ -0,0 +1,178 @@
"use client";
import * as React from "react";
import {Command as CommandPrimitive} from "cmdk";
import {SearchIcon} from "lucide-react";
import {cn} from "./utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./dialog";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<Command
className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50"/>
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className,
)}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
@@ -0,0 +1,252 @@
"use client";
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import {CheckIcon, ChevronRightIcon, CircleIcon} from "lucide-react";
import {cn} from "./utils";
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto"/>
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4"/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current"/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
@@ -0,0 +1,136 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import {XIcon} from "lucide-react";
import {cn} from "./utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay/>
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({className, ...props}: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({className, ...props}: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
@@ -0,0 +1,133 @@
"use client";
import * as React from "react";
import {Drawer as DrawerPrimitive} from "vaul";
import {cn} from "./utils";
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay/>
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className,
)}
{...props}
>
<div
className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block"/>
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({className, ...props}: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function DrawerFooter({className, ...props}: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
@@ -0,0 +1,257 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import {CheckIcon, ChevronRightIcon, CircleIcon} from "lucide-react";
import {cn} from "./utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4"/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current"/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4"/>
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
@@ -0,0 +1,168 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import {Slot} from "@radix-ui/react-slot";
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import {cn} from "./utils";
import {Label} from "./label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{name: props.name}}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const {getFieldState} = useFormContext();
const formState = useFormState({name: fieldContext.name});
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const {id} = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
function FormItem({className, ...props}: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{id}}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const {error, formItemId} = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({...props}: React.ComponentProps<typeof Slot>) {
const {error, formItemId, formDescriptionId, formMessageId} =
useFormField();
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({className, ...props}: React.ComponentProps<"p">) {
const {formDescriptionId} = useFormField();
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function FormMessage({className, ...props}: React.ComponentProps<"p">) {
const {error, formMessageId} = useFormField();
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null;
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
);
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};
@@ -0,0 +1,44 @@
"use client";
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import {cn} from "./utils";
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
export {HoverCard, HoverCardTrigger, HoverCardContent};
@@ -0,0 +1,77 @@
"use client";
import * as React from "react";
import {OTPInput, OTPInputContext} from "input-otp";
import {MinusIcon} from "lucide-react";
import {cn} from "./utils";
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName,
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
function InputOTPGroup({className, ...props}: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center gap-1", className)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const {char, hasFakeCaret, isActive} = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm bg-input-background transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000"/>
</div>
)}
</div>
);
}
function InputOTPSeparator({...props}: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon/>
</div>
);
}
export {InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator};
@@ -0,0 +1,21 @@
import * as React from "react";
import {cn} from "./utils";
function Input({className, type, ...props}: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export {Input};
@@ -0,0 +1,24 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import {cn} from "./utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export {Label};
@@ -0,0 +1,276 @@
"use client";
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import {CheckIcon, ChevronRightIcon, CircleIcon} from "lucide-react";
import {cn} from "./utils";
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className,
)}
{...props}
/>
);
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />;
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
);
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className,
)}
{...props}
/>
);
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</MenubarPortal>
);
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4"/>
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
);
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current"/>
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
);
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4"/>
</MenubarPrimitive.SubTrigger>
);
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
};
@@ -0,0 +1,168 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import {cva} from "class-variance-authority";
import {ChevronDownIcon} from "lucide-react";
import {cn} from "./utils";
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean;
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className,
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport/>}
</NavigationMenuPrimitive.Root>
);
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className,
)}
{...props}
/>
);
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
);
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
);
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
);
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className,
)}
{...props}
/>
);
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center",
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className,
)}
{...props}
/>
</div>
);
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className,
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md"/>
</NavigationMenuPrimitive.Indicator>
);
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
};
@@ -0,0 +1,127 @@
import * as React from "react";
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react";
import {cn} from "./utils";
import {Button, buttonVariants} from "./button";
function Pagination({className, ...props}: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
);
}
function PaginationItem({...props}: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">;
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
/>
);
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon/>
<span className="hidden sm:block">Previous</span>
</PaginationLink>
);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon/>
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4"/>
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};
@@ -0,0 +1,48 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import {cn} from "./utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export {Popover, PopoverTrigger, PopoverContent, PopoverAnchor};
@@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import {cn} from "./utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{transform: `translateX(-${100 - (value || 0)}%)`}}
/>
</ProgressPrimitive.Root>
);
}
export {Progress};
@@ -0,0 +1,45 @@
"use client";
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import {CircleIcon} from "lucide-react";
import {cn} from "./utils";
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2"/>
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export {RadioGroup, RadioGroupItem};
@@ -0,0 +1,56 @@
"use client";
import * as React from "react";
import {GripVerticalIcon} from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import {cn} from "./utils";
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className,
)}
{...props}
/>
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5"/>
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
}
export {ResizablePanelGroup, ResizablePanel, ResizableHandle};
@@ -0,0 +1,58 @@
"use client";
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import {cn} from "./utils";
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar/>
<ScrollAreaPrimitive.Corner/>
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export {ScrollArea, ScrollBar};
@@ -0,0 +1,189 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import {
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
} from "lucide-react";
import {cn} from "./utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-input-background px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50"/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton/>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton/>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4"/>
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4"/>
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4"/>
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import {cn} from "./utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export {Separator};
@@ -0,0 +1,140 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import {XIcon} from "lucide-react";
import {cn} from "./utils";
function Sheet({...props}: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
<SheetOverlay/>
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close
className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4"/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({className, ...props}: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({className, ...props}: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
@@ -0,0 +1,726 @@
"use client";
import * as React from "react";
import {Slot} from "@radix-ui/react-slot";
import {VariantProps, cva} from "class-variance-authority";
import {PanelLeftIcon} from "lucide-react";
import {useIsMobile} from "./use-mobile";
import {cn} from "./utils";
import {Button} from "./button";
import {Input} from "./input";
import {Separator} from "./separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "./sheet";
import {Skeleton} from "./skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className,
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const {isMobile, state, openMobile, setOpenMobile} = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className,
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const {toggleSidebar} = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon/>
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({className, ...props}: React.ComponentProps<"button">) {
const {toggleSidebar} = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
}
function SidebarInset({className, ...props}: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({className, ...props}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({className, ...props}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
}
function SidebarContent({className, ...props}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroup({className, ...props}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({className, ...props}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({className, ...props}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const {isMobile, state} = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({variant, size}), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({className, ...props}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
@@ -0,0 +1,13 @@
import {cn} from "./utils";
function Skeleton({className, ...props}: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
);
}
export {Skeleton};
@@ -0,0 +1,63 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import {cn} from "./utils";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-4 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)}
/>
</SliderPrimitive.Track>
{Array.from({length: _values.length}, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export {Slider};
@@ -0,0 +1,25 @@
"use client";
import {useTheme} from "next-themes";
import {Toaster as Sonner, ToasterProps} from "sonner";
const Toaster = ({...props}: ToasterProps) => {
const {theme = "system"} = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
);
};
export {Toaster};
@@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import {cn} from "./utils";
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-switch-background focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-card dark:data-[state=unchecked]:bg-card-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitive.Root>
);
}
export {Switch};
@@ -0,0 +1,116 @@
"use client";
import * as React from "react";
import {cn} from "./utils";
function Table({className, ...props}: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({className, ...props}: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
}
function TableBody({className, ...props}: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({className, ...props}: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
);
}
function TableRow({className, ...props}: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className,
)}
{...props}
/>
);
}
function TableHead({className, ...props}: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({className, ...props}: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};
@@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import {cn} from "./utils";
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-xl p-[3px] flex",
className,
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-card dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-xl border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export {Tabs, TabsList, TabsTrigger, TabsContent};
@@ -0,0 +1,18 @@
import * as React from "react";
import {cn} from "./utils";
function Textarea({className, ...props}: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"resize-none border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-input-background px-3 py-2 text-base transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export {Textarea};
@@ -0,0 +1,73 @@
"use client";
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import {type VariantProps} from "class-variance-authority";
import {cn} from "./utils";
import {toggleVariants} from "./toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
});
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className,
)}
{...props}
>
<ToggleGroupContext.Provider value={{variant, size}}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
}
export {ToggleGroup, ToggleGroupItem};
@@ -0,0 +1,47 @@
"use client";
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import {cva, type VariantProps} from "class-variance-authority";
import {cn} from "./utils";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({variant, size, className}))}
{...props}
/>
);
}
export {Toggle, toggleVariants};
@@ -0,0 +1,62 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import {cn} from "./utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow
className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]"/>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export {Tooltip, TooltipTrigger, TooltipContent, TooltipProvider};
@@ -0,0 +1,21 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}
@@ -0,0 +1,6 @@
import {clsx, type ClassValue} from "clsx";
import {twMerge} from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -0,0 +1,28 @@
import {createBrowserRouter} from 'react-router';
import {Login} from './components/Login';
import {AdminVerwaltung} from './components/Dashboard';
import {TurnierErstellen} from './components/TurnierErstellen';
import {TurnierAnsicht} from './components/TurnierAnsicht';
export const router = createBrowserRouter([
{
path: '/',
Component: Login,
},
{
path: '/admin',
Component: AdminVerwaltung,
},
{
path: '/veranstaltung/:id',
Component: TurnierErstellen,
},
{
path: '/veranstaltung/:veranstaltungId/turnier/neu',
Component: TurnierAnsicht,
},
{
path: '/veranstaltung/:veranstaltungId/turnier/:nr',
Component: TurnierAnsicht,
},
]);
@@ -0,0 +1,56 @@
import {createTheme} from '@mui/material/styles';
export const theme = createTheme({
palette: {
primary: {
main: '#3F51B5', // Indigo
},
secondary: {
main: '#FF4081',
},
background: {
default: '#fafafa',
paper: '#ffffff',
},
},
typography: {
fontSize: 11,
body1: {
fontSize: '11px',
},
body2: {
fontSize: '11px',
},
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
fontSize: '11px',
},
sizeSmall: {
padding: '4px 12px',
fontSize: '10px',
},
},
},
MuiTextField: {
styleOverrides: {
root: {
'& .MuiInputBase-input': {
fontSize: '11px',
},
},
},
},
MuiTableCell: {
styleOverrides: {
root: {
fontSize: '11px',
padding: '6px 8px',
},
},
},
},
});
@@ -0,0 +1,71 @@
# CSN-C NEU / CSNP-C NEU NEUMARKT/M.
**Turnier-Nr.: 26128** | [cite_start]**Datum: 25. April 2026** [cite: 1, 2]
## Allgemeine Informationen
* **Veranstalter:** Union Reit- u. Fahrverein Neumarkt/M. (6-009) [cite_start][cite: 3]
* [cite_start]**Ort:** Reitanlage Stroblmair, 4212 Neumarkt [cite: 3]
* [cite_start]**Kontakt:** Ursula Stroblmair, Brandstetterweg 2, 4212 Neumarkt [cite: 4]
* **Tel.:** 0664 1832381
* [cite_start]**E-Mail:** reit-stall@gmx.at [cite: 4]
* [cite_start]**Nennungsschluss:** 24.04.2026, 19:00 Uhr [cite: 4]
* [cite_start]**Online-Nennung:** Ab Mittwoch, 22.04.
auf [www.ihremeldestelle.at](http://www.ihremeldestelle.at) [cite: 5]
* [cite_start]**Meldestelle:** Geöffnet ab 24.04., 17:00 Uhr (Tel: +43 681 10769120) [cite: 8]
## Technische Details
* [cite_start]**Austragungsplatz:** 45 x 65 m (Sand/Vlies) [cite: 6]
* [cite_start]**Vorbereitungsplatz:** 20 x 40 m Halle (Sand/Vlies) [cite: 6]
* [cite_start]**Warmreiten:** Draußen (20 x 60 m Sand/Vlies) möglich [cite: 16]
* [cite_start]**Boxen:** Keine Einstallung möglich [cite: 9]
## Funktionäre
* [cite_start]**Turnierleiter:** Ursula Stroblmair [cite: 6]
* [cite_start]**Turnierbeauftragter:** Rudi Kreupl [cite: 7]
* [cite_start]**Richter:** Rudi Kreupl, Helmut Riedler [cite: 7]
* [cite_start]**Parcoursbauchef:** Kurt Reitetschlägerr [cite: 8]
* [cite_start]**Tierarzt:** Dr. Sabine Ötschmaier [cite: 8]
---
## Besondere Bestimmungen
* **Kosten:** Startgeld € 15,- pro Bewerb. [cite_start]Kein Nenngeld, kein Sporteuro. [cite: 11]
* **Teilnahmebedingungen:**
* [cite_start]Für Springprüfungen bis 95 cm: Mitgliedschaft OEPS-Verein und Reiterpass erforderlich. [cite: 12]
* [cite_start]Pferde bis 90 cm müssen **nicht** beim OEPS registriert sein. [cite: 14]
* [cite_start]Pferdepass mit gültigem Impfschutz (§ 11 OTO) ist vorzulegen. [cite: 15]
* [cite_start]Haftpflichtversicherung für jedes Pferd ist Pflicht. [cite: 21]
* **Startregelung:**
* [cite_start]Ein Pferd darf maximal 3x pro Tag starten. [cite: 14]
* [cite_start]In Bewerben bis 95 cm darf ein Pferd mit zwei verschiedenen Reitern starten. [cite: 13]
* [cite_start]**Hunde:** Am gesamten Gelände herrscht Leinenpflicht. [cite: 18]
---
## Bewerbe (Samstag, 25. April 2026 - Beginn 08:00 Uhr)
| Nr. | Bewerb | Höhe | Richtverfahren / Abteilungen |
|:-------|:--------------------------------|:-------|:------------------------------------------------------------------------------------|
| **1** | Pony Stilspringprüfung | 60 cm | [cite_start]RV: § 204/4 (CSNP-C) [cite: 27] |
| **2** | Einlaufspringprüfung | 60 cm | [cite_start]RV: § 204/4 (1. Abt: lizenzfrei / 2. Abt: mit Lizenz) [cite: 27] |
| **3** | Pony Stilspringprüfung | 70 cm | [cite_start]RV: § 204/4 (CSNP-C) [cite: 27] |
| **4** | Einlaufspringprüfung | 70 cm | [cite_start]RV: § 218 (1. Abt: lizenzfrei / 2. Abt: mit Lizenz) [cite: 27] |
| **5** | Pony Stilspringprüfung | 80 cm | [cite_start]RV: § 204/4 (CSNP-C) [cite: 27] |
| **6** | Stilspringprüfung | 80 cm | [cite_start]RV: § 204/4 (1. Abt: lizenzfrei / 2. Abt: R1 & 5-6j. Pferde) [cite: 27] |
| **7** | Pony Stilspringprüfung | 95 cm | [cite_start]RV: § 204/4 (CSNP-C) [cite: 27] |
| **8** | Springreiterbewerb (lizenzfrei) | 95 cm | [cite_start]RV: § 204/4 (CSNP-C) [cite: 27] |
| **9** | Standardspringprüfung | 95 cm | [cite_start]RV: A2 (1. Abt: R1 / 2. Abt: R2 und höher) [cite: 27] |
| **10** | Springpferdeprüfung | 105 cm | [cite_start]RV: § 203/3 (1. Abt: 4-jährig / 2. Abt: 5-6-jährig) [cite: 27] |
| **11** | Stilspringprüfung | 105 cm | [cite_start]RV: § 204/4 (1. Abt: R1) [cite: 27] |
| **12** | Standardspringprüfung | 105 cm | [cite_start]RV: A2 (1. Abt: R1 / 2. Abt: R2/RS2 und höher) [cite: 27] |
| **13** | Stilspringprüfung | 115 cm | [cite_start]RV: § 204/4 (1. Abt: R1) [cite: 28, 30, 31] |
| **14** | Standardspringprüfung | 115 cm | [cite_start]RV: A2 (1. Abt: R1 / 2. Abt: R2/RS2 und höher) [cite: 32, 34, 36] |
---
**Haftung:** Der Veranstalter übernimmt keine Haftung. [cite_start]Teilnehmer haften persönlich für Schäden gegenüber
Dritten. [cite: 19, 20]
@@ -0,0 +1,70 @@
# CDN-C NEU / CDNP-C NEU NEUMARKT/M., OÖ
**Turnier-Nr.: 26129** | [cite_start]**Datum: 26. April 2026** [cite: 37]
## Allgemeine Informationen
* **Veranstalter**: Union Reit- u. Fahrverein Neumarkt/M. (6-009) [cite_start][cite: 38].
* [cite_start]**Ort**: Reitanlage Stroblmair, 4212 Neumarkt[cite: 38].
* [cite_start]**Kontaktadresse**: Ursula Stroblmair, Brandstetterweg 2, 4212 Neumarkt[cite: 39].
* [cite_start]**Telefon**: 0664 1832381[cite: 39].
* [cite_start]**E-Mail**: reit-stall@gmx.at[cite: 39].
* [cite_start]**Nennungsschluss**: 25.04.2026, 19:00 Uhr[cite: 39, 53].
* [cite_start]**Online-Nennung**: Ab Mittwoch, 22.04. auf www.ihremeldestelle.at möglich[cite: 40].
* [cite_start]**Meldestelle**: Geöffnet ab 25.04., 17:00 Uhr (Tel: +43 681 10769120)[cite: 43].
* [cite_start]**Start- und Ergebnislisten**: Ab 20:30 Uhr auf www.ihremeldestelle.at verfügbar[cite: 44].
## Technische Details und Gebühren
* [cite_start]**Austragungsplatz**: 20 x 60 m Sand/Vlies[cite: 41].
* [cite_start]**Vorbereitungsplatz**: 20 x 40 m Halle (Sand/Vlies) und 20 x 60 m (Sand/Vlies)[cite: 41].
* [cite_start]**Boxen**: Keine Einstallung möglich[cite: 44].
* [cite_start]**Kosten**: Startgeld € 15,- pro Bewerb; kein Nenngeld und kein Sporteuro[cite: 40, 47].
## Funktionäre
* [cite_start]**Turnierleiter**: Ursula Stroblmair[cite: 41].
* [cite_start]**Turnierbeauftragte**: Alexandra Schuster[cite: 42].
* [cite_start]**Richter**: Alexandra Schuster, Ulrike Knasmüller-Prinz, Karin Wallner[cite: 42].
* [cite_start]**Steward**: Barbara Hruschka[cite: 42].
* [cite_start]**Tierarzt**: Dr. Sabine Ötschmaier[cite: 42].
---
## Besondere Bestimmungen
* **Teilnahmevoraussetzungen**:
* [cite_start]Für Reiterpass-/Reiternadel-Aufgaben ist die Mitgliedschaft bei einem OEPS-Verein und der Besitz des
Reiterpasses erforderlich[cite: 48].
* [cite_start]Pferde für Reiterpass-/Reiternadel-Aufgaben müssen nicht beim OEPS registriert sein[cite: 50].
* **Pferde**:
* [cite_start]Ein Pferd darf pro Tag maximal 3x starten[cite: 49].
* [cite_start]Ein Pferd darf mit zwei verschiedenen Reitern an den Start gehen[cite: 49].
* [cite_start]Vorlage des Pferdepasses mit gültigem Impfschutz gemäß § 11 OTO ist Pflicht[cite: 51].
* [cite_start]Jedes teilnehmende Pferd muss haftpflichtversichert sein[cite: 57].
* [cite_start]**Haftung**: Der Veranstalter übernimmt keine Haftung jeder Art und Ursache[cite: 55]. [cite_start]
Teilnehmer und Besitzer haften persönlich für Schäden gegenüber Dritten[cite: 56].
* [cite_start]**Sonstiges**: Es gilt Leinenpflicht für Hunde auf dem gesamten Gelände[cite: 54]. [cite_start]
Ausländische Equiden unterliegen der TRACES-Pflicht[cite: 58].
---
## Bewerbe (Sonntag, 26. April 2026 - Beginn 08:00 Uhr)
| Nr. | Bewerb | Aufg. | Details / Abteilungen |
|:-------|:---------------------------------|:---------------|:------------------------------------------------------------------------|
| **1** | Dressurreiterprüfung Reiterpass | R1 | [cite_start]RV: A § 103/5 [cite: 63] |
| **2** | Dressurreiterprüfung Reiternadel | R4 | [cite_start]RV: A § 103/5 [cite: 64] |
| **3** | Dressurreiterprüfung lizenzfrei | LF1 | [cite_start]RV: A § 103/5 [cite: 68] |
| **4** | Dressurreiterprüfung lizenzfrei | LF3 | [cite_start]RV: A § 103/5 [cite: 69] |
| **5** | First Ridden | - [cite_start] | [cite: 71] |
| **6** | Führzügelklasse | - [cite_start] | [cite: 73] |
| **7** | Pony Dressurprüfung Kl. A | P1 | [cite_start]RV: A, § 901 [cite: 75, 76] |
| **8** | Dressurreiterprüfung Kl. A | DRA1 | 1. Abt: R1/RD1; 2. [cite_start]Abt: R2/RD2 u. höher [cite: 78, 79, 81] |
| **9** | Dressurprüfung Kl. A | A5 | 1. Abt: R1/RD1; 2. [cite_start]Abt: R2/RD2 u. höher [cite: 82, 83, 98] |
| **13** | Dressurpferdeprüfung Kl. A | DPA1 | 1. Abt: 4-jähr. Pferde; 2. Abt: 5-6-jähr. [cite_start]Pferde [cite: 85] |
| **14** | Dressurpferdprüfung Kl. L | DPL1 | Für 5-6-jähr. [cite_start]Pferde [cite: 87] |
| **10** | Pony Dressurprüfung Kl. L | P6 | [cite_start]RV: A, § 901 [cite: 89, 90] |
| **11** | Dressurreiterprüfung Kl. L | DRL1 | 1. Abt: R1/RD1; 2. [cite_start]Abt: R2/RD2 u. höher [cite: 89, 92, 97] |
| **12** | Dressurprüfung Kl. L | L3 | 1. Abt: R1/RD1; 2. [cite_start]Abt: R2/RD2 u. höher [cite: 94, 96] |
@@ -0,0 +1,128 @@
# Detaillierte Bewerbs-Parameter: Springen und Dressur
Dieses Dokument beschreibt die genauen Parameter, die in der Turnier-Ausschreibung für die einzelnen Bewerbe der Sparten
Springen (CSN) und Dressur (CDN) definiert werden müssen, basierend auf der aktuellen ÖTO.
---
## 1. Verständnis eines Ausschreibungs-Beispiels
Ein typischer Bewerb im Ausschreibungs-Text sieht oft so aus:
> **6 Stilspringprüfung 80 cm J RV: § 204/4 CSN-C Neu**
> **1. Abt. lizenzfrei 2.Abt. R1 und Reiter mit 5 & 6 jährigen Pferden**
### Aufschlüsselung der Parameter:
* **`6`**: **Bewerbsnummer**. Eine fortlaufende Nummer zur eindeutigen Identifikation des Bewerbs (Prüfung Nr. 6 des
Turniers).
* **`Stilspringprüfung`**: **Art der Prüfung**. Es geht hier nicht rein nach Fehlern und Zeit, sondern der Reiter wird
von Richtern mit einer Wertnote für seinen Sitz, seine Einwirkung und den Rhythmus beurteilt.
* **`80 cm`**: **Klasse / Maximale Hindernishöhe**. In diesem Fall entspricht dies der Einsteigerklasse (E bzw. E0).
* **`J`**: **Startbuchstabe**. Nach diesem Buchstaben wird die Startreihenfolge alphabetisch gelost (oft nach dem Namen
des Pferdes). Ist der Buchstabe "J", startet das Pferd "Jolly Jumper" als erstes, danach "Karino" usw., bis am Ende
das Pferd "Ikarus" startet.
* **`RV: § 204/4`**: **Richtverfahren**. Der direkte Verweis auf den entsprechenden Paragraphen der ÖTO. Hier bedeutet
dies: Stilspringprüfung mit Wertnoten von 0,0 bis 10,0.
* **`CSN-C Neu`**: **Turnierkategorie**. Nationales Springturnier, Kategorie C-Neu. Das ist eine Einsteiger-Kategorie,
bei der auch Reiter ohne Lizenz (nur mit Reiterpass) antreten dürfen. Es gibt hier kein Preisgeld, nur Sachpreise und
Schleifen.
* **`1. Abt. lizenzfrei`**: **Abteilung 1 (Unterteilung)**. Diese Abteilung (erste Siegerehrung/Platzierung) ist
exklusiv für Reiter, die noch keine Turnierlizenz haben.
* **`2. Abt. R1 und Reiter mit 5 & 6 jährigen Pferden`**: **Abteilung 2**. Die zweite Abteilung wertet alle Reiter mit
der Einstiegslizenz (R1) sowie erfahrene Reiter, wenn sie junge Nachwuchspferde reiten. So wird Fairness garantiert.
---
## 2. Sparte Springen (CSN) im Detail
### 2.1 Die Klassen (Höhen für Großpferde)
Die Klassen geben die maximale Höhe der Hindernisse an:
* **Klasse E0 (Einsteiger):** 60 bis 90 cm
* **Klasse A (Leicht):** 105 bis 110 cm
* **Klasse L (Mittelleicht):** 115 bis 120 cm
* **Klasse LM (Leicht-Mittelschwer):** 125 bis 130 cm
* **Klasse M (Mittelschwer):** 135 cm
* **Klasse S (Schwer):** 140 bis 160 cm
*(Hinweis: Für Ponys sind die Höhen reduziert und die Abstände in Kombinationen verkürzt)*
### 2.2 Die Richtverfahren (RV) gemäß § 204 ÖTO
Das Richtverfahren definiert, wie Fehler und Zeiten gewertet werden.
#### Richtverfahren A (Standardspringprüfung)
Es gibt Strafpunkte (Fehler) für Abwürfe (4 Fehlerpunkte) und Verweigerungen/Ungehorsam (4 Fehlerpunkte beim ersten Mal,
beim zweiten Mal ab 115 cm Ausschluss). Zeitüberschreitungen über die "Erlaubte Zeit" geben Zeitfehler (0,25 Punkte pro
Sekunde).
* **A1:** Es gibt keine Zeitwertung. Alle fehlerfreien Ritte (gleiche Punktezahl) sind ex aequo auf Platz 1.
* **A2:** Fehler und Zeit. Bei Punktegleichheit gewinnt die schnellere Umlaufzeit.
* **A3 (Idealzeit):** Eine Idealzeit (meist Erlaubte Zeit minus 10%) wird definiert. Es gewinnt der Reiter, der am
nächsten an der Idealzeit ist (darunter oder darüber). Schützt vor "Bolzen" in Anfängerprüfungen.
* **AM3, AM4, AM5, AM6:** Standardspringen **mit Stechen**. Wer im Grundparcours fehlerfrei bleibt, reitet danach einen
verkürzten Stechparcours auf Zeit.
#### Richtverfahren C (Zeitspringen)
Fehlerpunkte gibt es nicht. Für jeden Abwurf werden stattdessen **Strafsekunden** (meist 4 Sekunden) zur gerittenen Zeit
addiert. Die schnellste Endzeit gewinnt.
### 2.3 Spezial-Richtverfahren / Prüfungsarten
* **Einlaufspringprüfung (§ 218):** Ein Trainingsbewerb (RV A1). Es gibt keine Platzierung. Jeder fehlerfreie Reiter
bekommt eine braune "Clear-Round"-Schleife.
* **Punktespringprüfung (§ 219):** Man sammelt Pluspunkte für fehlerfreie Sprünge. Am Ende steht oft ein schwieriger "
Joker-Sprung", der doppelte Punkte oder bei einem Fehler doppelten Abzug bringt.
* **2-Phasenspringprüfung (§ 220):** Der Parcours ist in zwei Teile geteilt. Wer Phase 1 fehlerfrei schafft, reitet ohne
anzuhalten sofort in Phase 2 (die meist auf Zeit geht). Wer in Phase 1 patzt, wird abgeglockt.
* **Stilspringprüfung:** Bewertung mit Wertnoten von 0 bis 10. Abzüge erfolgen für Ungehorsam (-0,5 oder -1,0) und
Hindernisfehler (-0,5).
---
## 3. Sparte Dressur (CDN) im Detail
### 3.1 Die Klassen und Aufgaben
Die Dressur wird nach vorgegebenen "Aufgaben" geritten (z.B. "Aufgabe A2", "FEI Grand Prix"), die die zu reitenden
Hufschlagfiguren exakt vorgeben.
* **Klasse A:** Grundlagen, Hufschlagfiguren, einfache Galoppwechsel über den Trab.
* **Klasse L:** Beginnende Versammlung, Kurzkehrt, Außengalopp.
* **Klasse LM:** Schulterherein, fliegende Wechsel können vorkommen. (Ab hier wahlweise Trense oder Kandare).
* **Klasse M:** Traversalen, fliegende Galoppwechsel. (Kandarenpflicht).
* **Klasse S:** Pirouetten, Serienwechsel (z.B. Wechsel alle 2 Sprünge), Piaffe, Passage.
### 3.2 Die Richtverfahren (RV) gemäß § 104 ÖTO
Das Richtverfahren definiert die Sitzverteilung und Art der Notengebung der Richter.
#### Richtverfahren A (Gemeinsames Richten)
Typisch für untere bis mittlere Klassen. Die Richtergruppe (die zusammen bei "C" sitzt) einigt sich auf **eine
gemeinsame Wertnote** zwischen 0,0 und 10,0 (z.B. 7,4).
* *Verreiten:* Wird mit einem Abzug von der Gesamtnote bestraft (1. Mal: -0,2 / 2. Mal: -0,4).
#### Richtverfahren B (Getrenntes Richten)
Typisch ab Klasse M und bei Meisterschaften. Mindestens drei Richter sitzen an verschiedenen Stellen (C, H, M) und
werten völlig unabhängig voneinander. Jede einzelne Lektion wird mit einer Note (0 bis 10) bewertet. Am Ende werden die
Punkte addiert und in einen Prozentwert umgerechnet (z.B. 68,542 %).
* *Verreiten:* Führt zu Abzügen bei der Gesamtpunktezahl bei jedem Richter (1. Mal: -2 Pkt. pro Richter / 2. Mal: -4
Pkt. pro Richter).
*Wichtig für alle Dressurprüfungen:* Ein **drittes Verreiten** führt unausweichlich zum Ausschluss (Abglocken) des
Teilnehmers.
### 3.3 Sonderformen
* **Musikkür:** Wird immer nach RV B gerichtet. Hierbei werden zwei getrennte Notensets vergeben: Eine Note für die *
*Technik** (Wurden alle geforderten Lektionen korrekt gezeigt?) und eine Note für die **Künstlerische Ausführung** (
Choreographie, Musikinterpretation, Schwierigkeitsgrad).
* **Dressurreiterprüfung / Dressurpferdeprüfung:** Ähnlich wie im Stilspringen zählt hier primär der Sitz und die
Einwirkung des Reiters (Reiterprüfung) bzw. das Potenzial und die Grundgangarten des jungen Pferdes (Pferdeprüfung).
Wird in der Regel nach RV A (Wertnoten) gerichtet.
@@ -0,0 +1,77 @@
# Struktur einer Turnier-Ausschreibung gemäß ÖTO
Diese Dokumentation beschreibt die notwendigen Felder und Sektionen einer offiziellen Ausschreibung für den
österreichischen Pferdesport (OEPS).
## 1. Allgemeine Turnierdaten (Header)
Dies entspricht im Wesentlichen dem **A-Satz** der ZNS-Schnittstelle.
* **Turniernummer:** Eindeutige vom OEPS vergebene Kennung (z.B. 25123).
* **Veranstalter:** Name des durchführenden Vereins und dessen Vereinsnummer.
* **Austragungsort:** Genaue Adresse der Reitanlage.
* **Datum:** Von-bis Datum des Turniers.
* **Turnierkategorie:** Einstufung (z.B. CSN-B, CDN-A, CCN-C).
* **Nennschluss:** Datum, bis zu dem reguläre Nennungen möglich sind.
## 2. Besondere Bestimmungen (Rechtlicher Rahmen)
* **Regelwerk:** Hinweis auf die gültige ÖTO (Österreichische Turnierordnung) und ggf. FEI-Regeln.
* **Haftungsausschluss:** Verweis auf die Haftungsbestimmungen der ÖTO.
* **Teilnahmeberechtigung:** Allgemeine Einschränkungen (z.B. nur für Mitglieder bestimmter Landesverbände oder geladene
Gäste).
## 3. Funktionäre (Offizielle)
Wichtig für die Zuweisung im **C-Satz**.
* **Turnierleiter:** Verantwortliche Person des Veranstalters.
* **Richterkollegium:** Liste der Richter inkl. deren Qualifikationen (z.B. "D-GP", "S").
* **Technischer Delegierter (TD):** (Vor allem bei Vielseitigkeit).
* **Parcourschef:** Verantwortlich für das Design der Hindernisse.
* **Turniertierarzt & Schmied:** Notwendige medizinische Versorgung.
## 4. Beschaffenheit der Anlage
Informationen, die Einfluss auf das **DressurPruefungSpezifika** oder **SpringenPruefungSpezifika** haben.
* **Austragungsplatz:** Maße (z.B. 20x60m), Bodenbelag (Sand, Gras).
* **Vorbereitungsplatz:** (Abreiteplatz) Maße und Bodenbelag.
## 5. Nennungen & Gebühren
Grundlage für den **Nennungs_Context**.
* **Nennweg:** Hinweis auf das ZNS (Zentrales Nennsystem).
* **Nenngebühr:** Grundgebühr pro Pferd/Reiter-Paar.
* **Startgebühr:** Gebühr pro einzelner Prüfung.
* **Boxen/Einstreu:** Kosten für fixe oder mobile Boxen, inkl. Erst-Einstreu.
* **Zusatzgebühren:** Stromanschluss, Camping, Nachnenngebühren.
## 6. Prüfungs-Programm (Bewerbe)
Dies ist das Herzstück und bildet den **B-Satz** ab. Jede Prüfung muss folgende Details aufweisen:
### Pflichtfelder pro Prüfung:
| Feld | Beschreibung | Beispiel |
|:-------------------|:--------------------------------------|:---------------------------------|
| **Bewerbsnummer** | Fortlaufende Nummer | 01, 02, ... |
| **Bezeichnung** | Name der Prüfung | Standardspringprüfung |
| **Klasse** | Schwierigkeitsgrad | E, A, L, LM, M, S |
| **Abteilungen** | Unterteilung nach Lizenzen | Abt. 1: R1 / Abt. 2: R2 u. höher |
| **Aufgabe** | (Nur Dressur) Spezifische ÖTO-Aufgabe | A2, L1, FEI Grand Prix |
| **Anforderungen** | Erforderliche Lizenzen/Alter | R1 oder höher |
| **Richtverfahren** | Verweis auf ÖTO-Paragraphen | § 218, § 204 |
| **Dotierung** | Preisgeld-Aufstellung | EUR 500,- (150/100/80/...) |
## 7. Stallungen & Unterbringung
* Anreise- und Abreisezeiten.
* Verfügbarkeit von Futter und Einstreu.
* Veterinäramtliche Bestimmungen (Impfschutz-Kontrolle gemäß ÖTO).
## 8. Vorläufige Zeiteinteilung
* Grober Ablaufplan (welcher Tag, welche Prüfungen).
* Hinweis auf die endgültige Zeiteinteilung (meist am Vorabend im Nennungs-System).
@@ -0,0 +1,163 @@
Du bist ein erfahrener UI/UX Designer für professionelle Desktop- und Web-Anwendungen mit Fokus auf Material Design 3
und informationsdichte, tastaturoptimierte Interfaces.
## Projektname & Kontext
**"Meldestelle"** – ein digitales Turnierverwaltungssystem für österreichische Reitsportveranstaltungen (Dressur &
Springen).
Das System ersetzt ein bestehendes Altsystem (SuDo) und muss dessen bewährten, schnellen Workflow übernehmen.
---
## WICHTIG: Es gibt ZWEI völlig verschiedene Apps!
### App 1: "Master Desktop App" (JVM / Windows & Mac)
- **Wer benutzt sie?** Die Meldestelle vor Ort (1–2 Personen am Turnier)
- **Wo läuft sie?** Lokal auf einem Laptop/PC am Turnier
- **Kernprinzip:** Offline-First (kein Internet nötig), absolute Tastaturoptimierung
- **Design-Stil:** Kompakt, informationsdicht, kein verschwendeter Whitespace
- **Navigation:** Navigation Rail (schmale Leiste links) mit Icons + Labels
### App 2: "Meldestelle Web-Portal" (Browser / Web)
- **Wer benutzt es?** Veranstalter (verwalten ihre Turniere) + Reiter (melden sich online an)
- **Wo läuft es?** Im Browser (Desktop & Mobil)
- **Kernprinzip:** Übersichtlich, selbsterklärend, auch für Laien bedienbar
- **Design-Stil:** Moderner SaaS-Look (ähnlich Linear oder Jira)
- **Navigation:** Top App Bar + Sidebar
---
## Rollen im System
| Rolle | App | Was sie tun |
|-------------------------|-------------|-------------------------------------------------------------------|
| **Meldestelle** | Desktop App | Nennungen erfassen, Startlisten erstellen, Ergebnisse eingeben |
| **Veranstalter** | Web-Portal | Turnier anlegen, Bewerbe konfigurieren, Desktop-App herunterladen |
| **Reiter / Teilnehmer** | Web-Portal | Online-Nennung einreichen, eigene Nennungen einsehen |
| **Admin (Owner)** | Web-Portal | Alle Turniere verwalten, Veranstalter anlegen |
---
## SCREENS – Master Desktop App (Priorität: HOCH)
### Desktop Screen 1: Dashboard / Turnier-Übersicht
- Zeigt das aktuell geladene Turnier (Name, Datum, Ort, Kategorie: C-NEU / C)
- Schnellzugriff-Buttons zu den Hauptfunktionen: Nennungen, Bewerbe, Startlisten, Ergebnisse, Kassa
- Offline-Status-Indikator (kleines Badge oder farbiger Punkt: Grün = Online, Rot = Offline)
- Letzte Aktivitäten (z.B. "Zuletzt geändert: Bewerb 3 – Springen C")
### Desktop Screen 2: Nennungs-Maske ⭐ (DAS HERZSTÜCK)
Dies ist die wichtigste Maske der gesamten Anwendung. Das Telefon läutet, ein Reiter nennt sich an – diese Maske muss
blitzschnell bedienbar sein.
Layout (zweiteilig):
- **Oben (Suche & Reiter-Info):**
- Großes Suchfeld (autofokussiert beim Öffnen): Suche nach Kopfnummer ODER Name
- Sofortige Auto-Completion (Dropdown erscheint während der Eingabe)
- Nach Auswahl: Anzeige der Reiter-Metadaten (Name, Verein, Pferd, Besitzer, Lizenz-Status)
- **Unten (Bewerbs-Zuweisung):**
- Liste aller verfügbaren Bewerbe (Nummer, Bezeichnung, Klasse, Sparte D/S)
- Klick oder Enter auf einen Bewerb = Nennung wird erfasst
- Checkbox/Toggle für Startwunsch: "Vorne" / "Hinten" / "Keine Präferenz"
- Anzeige bereits erfasster Nennungen dieses Reiters (rechte Spalte oder Tab)
Wichtig: Kein einziger Standard-Schritt darf eine Maus erfordern. Tab-Flow und Enter müssen alles erledigen.
### Desktop Screen 3: Bewerbe verwalten
Zweigeteilte Master-Detail Ansicht:
- **Links:** Liste aller Bewerbe (Nummer, Kürzel, Sparte-Icon D/S, Klasse, Starteranzahl)
- **Rechts:** Detail-Tabs für den gewählten Bewerb:
- Tab "Bewertung": Richtverfahren (Punkte/Fehler/Zeit), Anzahl Richter
- Tab "Geldpreis": Dotierung nach Platzierung (1. Platz: X €, 2. Platz: Y €, ...)
- Tab "Ort & Zeit": Datum, Uhrzeit, Platz, geschätzte Zeit pro Starter
### Desktop Screen 4: Startlisten-Erstellung
- Schnell-Wechsel zwischen Bewerben: Nummern-Leiste oben (z.B. "1 | 2 | 3 | 4 | 5 ...")
- Für jeden Bewerb: Drag-and-Drop Liste der Starter (Startnummer, Reiter, Pferd)
- Startwünsche sind farblich markiert (z.B. Grün = "Vorne", Blau = "Hinten")
- Button: "Automatisch auslosen" + "Manuell anpassen"
- Button: "Startliste drucken / exportieren"
### Desktop Screen 5: Ergebnis-Erfassung ⭐
Dreigeteiltes Layout (von oben nach unten):
- **Oben:** Gesamtergebnis-Tabelle (Platz, Reiter, Pferd, Ergebnis) – aktualisiert sich live
- **Mitte:** Eingabe-Maske für den aktuellen Starter (groß, gut lesbar):
- Felder je nach Sparte: Dressur = Punkte/Prozent | Springen = Fehler + Zeit
- Enter = Ergebnis speichern + nächster Starter wird automatisch aktiviert
- **Unten:** Warteschlange (nächste Starter in Reihenfolge)
- Doppelklick auf einen Starter in der Warteschlange = dieser kommt als nächstes dran (Reihenfolge-Änderung)
Kein Mausklick für den Standard-Ablauf nötig. Nur Tastatur (Enter, Tab, Zahlen).
---
## SCREENS – Web-Portal (Priorität: MITTEL)
### Web Screen 1: Veranstalter-Dashboard
- Aktuelles Turnier prominent oben: Name, Datum, Status (Nennungen offen / geschlossen)
- Download-Button für die Master Desktop App (groß, auffällig)
- Lizenzschlüssel / Aktivierungscode anzeigen
- Darunter: "Meine Turniere" – Archiv vergangener Turniere (schreibgeschützt, nur Ansicht)
- Offline-Indikator im Header
### Web Screen 2: Online-Nennung (für Reiter)
- Schritt-für-Schritt Formular (Wizard, 3 Schritte):
- Schritt 1: Reiter & Pferd auswählen (Suche nach Name oder Kopfnummer)
- Schritt 2: Bewerbe auswählen (Liste mit Checkboxen, Klasse und Sparte sichtbar)
- Schritt 3: Zusammenfassung + Bestätigung (Nenngeld-Betrag angezeigt)
- Mobilfreundlich (auch am Smartphone bedienbar)
### Web Screen 3: Admin-Bereich (nur für Owner/Admin)
- Turnier anlegen: Formular (Name, Datum, Ort, Kategorie C-NEU/C, Sparten D/S)
- Veranstalter-Übersicht: Liste aller Veranstalter mit Status
- Statistiken: Offene Nennungen, aktive Turniere
---
## Design-Vorgaben
- **Design-System:** Material Design 3
- **Sprache:** Deutsch
- **Theme:** Helles Theme als Standard
- **Farben:** Primär Indigo/Blau (professionell, seriös)
- **Desktop App:** Maximale Informationsdichte, kleine Schrift erlaubt, kein Whitespace-Verschwendung
- **Web Portal:** Moderner SaaS-Look, etwas luftiger, auch für Laien verständlich
- **Icons:** Material Icons (Reitsport-Symbole wo passend: Pferd, Pokal, Stoppuhr)
---
## Deliverables (Was du erstellen sollst)
1. Starte mit diesen 3 Screens (höchste Priorität):
- **Desktop: Nennungs-Maske** (Screen 2 Desktop)
- **Desktop: Ergebnis-Erfassung** (Screen 5 Desktop)
- **Web: Veranstalter-Dashboard** (Screen 1 Web)
2. Für jeden Screen: Einen Figma-Frame
3. Verbindungspfeile zwischen den Screens (User Flow)
4. Wo relevant: Variante "Meldestelle-Ansicht" vs. "Veranstalter-Ansicht"
5. Offline-Indikator-Vorschlag (Badge im Header)
---
## Stil-Referenz
Das Design soll professionell und effizient wirken – ähnlich wie **Linear** (für das Web-Portal) und **ein modernes
Kassensystem** (für die Desktop-App). Kein verspieltes Design, kein unnötiger Schnickschnack. Der Fokus liegt auf
Geschwindigkeit und Übersichtlichkeit.
@@ -0,0 +1,170 @@
Du bist ein erfahrener UI/UX Designer für professionelle Desktop-Anwendungen (Material Design 3).
Deine Aufgabe: Designe die "Nennungs-Maske" für die "Master Desktop App" eines Reitsport-Turnierverwaltungssystems.
## Kontext
Diese Maske ist das absolute Herzstück der Anwendung. Die Meldestelle sitzt am Turnier, das Telefon läutet,
ein Reiter nennt sich an – alles muss in Sekunden erledigt sein. Absolute Tastaturoptimierung ist Pflicht.
Das Altsystem (SuDo) hat einen bewährten Workflow, den wir modernisieren (nicht ersetzen).
## Layout: 3-Spalten-Design (Vollbild, kein Scrolling)
---
### SPALTE LINKS (ca. 30% Breite): Pferd & Reiter Suche
**Bereich 1: Pferd suchen**
- Label "Pferd:" + großes Suchfeld (autofokussiert beim Öffnen der Maske)
- Suche nach: Kopfnummer ODER Pferdename (Auto-Completion Dropdown)
- Button "..." (erweiterte Suche / Browse)
- Button "Leeren" (Feld zurücksetzen)
- Nach Auswahl: Info-Box mit Pferd-Metadaten:
- Pferdename, Rasse, Farbe
- Besitzer (Name)
- Stall/Box-Nummer (falls zugewiesen)
**Bereich 2: Reiter suchen**
- Label "Reiter:" + Suchfeld
- Suche nach: Kopfnummer ODER Reitername
- Button "..." + Button "Leeren"
- Nach Auswahl: Info-Box mit Reiter-Metadaten:
- Name, Verein/Klub
- Lizenz-Nummer + Lizenz-Status (gültig = grünes Badge, abgelaufen = rotes Badge)
- Konto-Saldo (aktueller Schuldenstand, rot wenn negativ)
**Bereich 3: Aktions-Buttons (Mitte der linken Spalte)**
- Sekundäre Buttons (klein, outlined):
- "Pferd Neu anlegen" | "Pferd Bearbeiten"
- "Reiter Neu anlegen" | "Reiter Bearbeiten"
**Bereich 4: Nennungs-Tabelle (untere Hälfte links)**
- Tabs: "Reiter" | "Pferd" | "Bewerbe"
- Toolbar: "Aktualisieren" | "X Nennungen" | "Positionieren" | "Stornieren"
- Tabelle zeigt alle bisherigen Nennungen des gewählten Reiters/Pferdes:
- Spalten: Tag | Pl. | Bewerb-Nr. | Bewerbsname | Startwunsch | Pferd
- Farbmarkierung: Startwunsch "Vorne" = grüner Hintergrund, "Hinten" = blauer Hintergrund
---
### SPALTE MITTE (ca. 15% Breite): Aktions-Hub
Alle Buttons vertikal gestapelt, gut klickbar, mit Icon:
- 🟢 **"Nennung durchführen"** (Primary Button, groß, auffällig – Haupt-Aktion)
- 🔴 **"Nennung stornieren"** (Outlined/Danger Button)
- Trennlinie
- 📋 **"Startliste öffnen"** (navigiert zur Startlisten-Maske)
- 🏆 **"Ergebnisliste öffnen"** (navigiert zur Ergebnis-Maske)
- 💰 **"Abrechnung öffnen"** (navigiert zur Kassa/Abrechnung)
- Trennlinie
- 🔄 **"Nennungstausch"** (öffnet Tausch-Dialog)
Keyboard-Shortcuts sichtbar neben jedem Button (z.B. F5, F6, F7...)
---
### SPALTE RECHTS (ca. 55% Breite): Verkauf, Buchungen & Bewerbsliste
**Oberer Bereich: Tabs "Verkauf" | "Buchungen"**
Tab "Verkauf" (Kassa-Integration):
- Toolbar: "Aktualisieren" | "X Artikel" | "Rückgängig" | "Speichern"
- Tabelle mit Zeilen für jeden buchbaren Artikel:
- Spalten: KNr | [+] Button | Menge (editierbar) | [-] Button | Buchungstext | Betrag | Gebucht
- Typische Zeilen: Belastung, Gutschrift, Boxenpauschale, Ansage, Füttern, Heu, Späne, Stroh, Strom, Y-Nummer,
Z-Nummer
- Menge direkt in der Tabelle editierbar (Klick oder Tab)
- Betrag wird automatisch berechnet (Menge × Einzelpreis)
Tab "Buchungen":
- Toolbar: "Aktualisieren" | "X Buchungen" | "Stornieren"
- Tabelle: KopfNr | Menge | Buchungstext | Soll | Haben
- Zeigt alle bereits gebuchten Transaktionen für diesen Reiter/dieses Pferd
**Unterer Bereich: Bewerbsliste**
- Überschrift "Bewerbsliste"
- Toolbar: "Aktualisieren" | "X Bewerbe" | "Filtern ▼" | "X gefiltert"
- Tabelle aller verfügbaren Bewerbe:
- Spalten: Tag | Pl. | Bewerb-Nr. | Beginn | Nenn. (Anzahl Nennungen) | Bewerbsname
- Sparte-Icon: 🐴 D (Dressur) oder 🚧 S (Springen)
- Klasse: A, L, M, S, Kl.A, Kl.L etc.
- Bereits genannte Bewerbe: farblich hervorgehoben (z.B. blauer Hintergrund)
- Doppelklick auf Bewerb = Nennung wird sofort durchgeführt (schnellste Methode!)
- Filter-Optionen: nach Tag, nach Sparte (D/S), nach Klasse
---
## Separate Maske: Nennungstausch (Modal/Dialog oder eigener Screen)
Layout: 3 Bereiche
**Links: Quell-Nennung (die zu tauschende Nennung)**
- Toolbar: "Aktualisieren" | "X Nennungen" | "Suchen ▼"
- Tabelle: Kopfnr | Pferd | Reiter | Anzahlung | Stall | Konto
- Suchfeld oben zum Filtern
**Mitte: Tausch-Optionen**
- Checkboxen "Übernehmen:":
- ☐ Zahlung (bereits bezahltes Nenngeld übertragen)
- ☐ Box (Stallzuweisung übertragen)
- Button "Tauschen" (Primary)
- Button "Stornieren" (Danger)
**Rechts: Ziel-Nennung (Nachnennungen)**
- Toolbar: "Aktualisieren" | "X Nachnennungen" | "Suchen ▼"
- Tabelle: Kopfnr | Pferd | Reiter | Konto
**Unten: Tausch-Historie**
- Toolbar: "Aktualisieren" | "X Tausch" | "Suchen ▼" | "Stornieren"
- Tabelle: Kopfnr | Pferd | Reiter | Konto | Von Kopfnr | von Pferd | von Reiter | von Konto
---
## Keyboard-Navigation (PFLICHT – dokumentiere es im Design)
| Taste | Aktion |
|-------------------------|-----------------------------------------------|
| Tab | Nächstes Feld (Pferd → Reiter → Bewerbsliste) |
| Enter (in Bewerbsliste) | Nennung durchführen |
| Doppelklick (Bewerb) | Sofort nennen |
| F5 | Nennung durchführen |
| F6 | Nennung stornieren |
| F7 | Startliste öffnen |
| F8 | Ergebnisliste öffnen |
| Escape | Felder leeren / zurücksetzen |
| Strg+T | Nennungstausch öffnen |
---
## Design-Vorgaben
- Material Design 3, Helles Theme
- Primärfarbe: Indigo (#3F51B5)
- Sprache: Deutsch
- Schriftgröße: kompakt (12–13px für Tabellen), kein verschwendeter Whitespace
- Tabellen: Zebra-Streifen (abwechselnde Zeilenhintergrundfarben)
- Aktive/ausgewählte Zeile: Indigo-Highlight
- Lizenz abgelaufen: Rotes Badge/Icon sofort sichtbar
- Negativer Kontostand: Rote Schrift
---
## Deliverables
1. Einen Figma-Frame: "Nennungs-Maske – Leer" (Startzustand, Pferd/Reiter noch nicht gewählt)
2. Einen Figma-Frame: "Nennungs-Maske – Befüllt" (Pferd + Reiter gewählt, Nennungen sichtbar, Bewerb markiert)
3. Einen Figma-Frame: "Nennungstausch-Dialog"
4. Annotations/Kommentare zu den Keyboard-Shortcuts direkt im Frame
Starte mit Frame 2 ("Befüllt") – das zeigt den realen Arbeitsalltag.
@@ -0,0 +1,3 @@
@import './fonts.css';
@import './tailwind.css';
@import './theme.css';
@@ -0,0 +1,5 @@
@import 'tailwindcss' source(none);
@source '../**/*.{js,ts,jsx,tsx}';
@import 'tw-animate-css';
@@ -0,0 +1,180 @@
@custom-variant dark (&:is(.dark *));
:root {
--font-size: 16px;
--background: #ffffff;
--foreground: oklch(0.145 0 0);
--card: #ffffff;
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: #030213;
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.95 0.0058 264.53);
--secondary-foreground: #030213;
--muted: #ececf0;
--muted-foreground: #717182;
--accent: #e9ebef;
--accent-foreground: #030213;
--destructive: #d4183d;
--destructive-foreground: #ffffff;
--border: rgba(0, 0, 0, 0.1);
--input: transparent;
--input-background: #f3f3f5;
--switch-background: #cbced4;
--font-weight-medium: 500;
--font-weight-normal: 400;
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: #030213;
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--font-weight-medium: 500;
--font-weight-normal: 400;
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-input-background: var(--input-background);
--color-switch-background: var(--switch-background);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
/**
* Default typography styles for HTML elements (h1-h4, p, label, button, input).
* These are in @layer base, so Tailwind utility classes (like text-sm, text-lg) automatically override them.
*/
html {
font-size: var(--font-size);
}
h1 {
font-size: var(--text-2xl);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
h2 {
font-size: var(--text-xl);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
h3 {
font-size: var(--text-lg);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
h4 {
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
label {
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
button {
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
input {
font-size: var(--text-base);
font-weight: var(--font-weight-normal);
line-height: 1.5;
}
}