внедрены базовые страницы нового функционала
This commit is contained in:
403
front/src/app/pages/calendar/CalendarPage.js
Normal file
403
front/src/app/pages/calendar/CalendarPage.js
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Drawer,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Chip,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
Paper,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { CalendarToday as CalendarIcon, FilterList as FilterIcon } from '@mui/icons-material';
|
||||||
|
import { DateCalendar } from '@mui/x-date-pickers/DateCalendar';
|
||||||
|
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||||
|
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||||
|
import ruLocale from 'date-fns/locale/ru';
|
||||||
|
|
||||||
|
const CHAMPIONSHIP_STAGES = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'SWC Зимний чемпионат',
|
||||||
|
stage: '2‑й этап',
|
||||||
|
date: '2026-02-08',
|
||||||
|
class: 'Юниоры',
|
||||||
|
status: 'Идёт',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Honda Winter Cup',
|
||||||
|
stage: '1‑й этап',
|
||||||
|
date: '2026-01-31',
|
||||||
|
class: 'Pro',
|
||||||
|
status: 'Регистрация открыта',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'Кубок Покровска (онлайн)',
|
||||||
|
stage: '1‑й этап',
|
||||||
|
date: '2026-02-01',
|
||||||
|
class: 'Симулятор A',
|
||||||
|
status: 'Предрегистрация',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const CalendarPage = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
|
||||||
|
const [viewDate, setViewDate] = useState(new Date()); // Дата для отображения месяца
|
||||||
|
const [selectedClass, setSelectedClass] = useState('Все');
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const [openDrawer, setOpenDrawer] = useState(false);
|
||||||
|
|
||||||
|
// Фильтрованные этапы для текущего месяца/класса
|
||||||
|
const filteredStages = CHAMPIONSHIP_STAGES.filter((stage) => {
|
||||||
|
const stageDate = new Date(stage.date);
|
||||||
|
const isSameMonth = stageDate.getMonth() === viewDate.getMonth();
|
||||||
|
const isSameYear = stageDate.getFullYear() === viewDate.getFullYear();
|
||||||
|
const classMatch = selectedClass === 'Все' || stage.class === selectedClass;
|
||||||
|
return isSameMonth && isSameYear && classMatch;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Группируем этапы по дате
|
||||||
|
const stagesByDate = filteredStages.reduce((acc, stage) => {
|
||||||
|
const dayKey = new Date(stage.date).toDateString();
|
||||||
|
acc[dayKey] = acc[dayKey] || [];
|
||||||
|
acc[dayKey].push(stage);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Содержимое drawer для мобильных
|
||||||
|
const drawerContent = (
|
||||||
|
<Box sx={{ p: 3, minWidth: 280 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Этапы на {viewDate.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
|
</Typography>
|
||||||
|
{filteredStages.length === 0 ? (
|
||||||
|
<Typography color="text.secondary" sx={{ mt: 2 }}>
|
||||||
|
Нет этапов в этот день
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<List sx={{ mt: 2 }}>
|
||||||
|
{filteredStages.map((stage) => (
|
||||||
|
<ListItem key={stage.id} sx={{ mb: 1, borderBottom: '1px solid', borderColor: 'divider' }}>
|
||||||
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
|
<Typography variant="subtitle1" fontWeight="bold">
|
||||||
|
{stage.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{stage.stage} · {stage.class}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ mt: 0.5 }}>
|
||||||
|
<Chip
|
||||||
|
label={stage.status}
|
||||||
|
size="small"
|
||||||
|
color={
|
||||||
|
stage.status === 'Идёт'
|
||||||
|
? 'warning'
|
||||||
|
: stage.status === 'Регистрация открыта'
|
||||||
|
? 'success'
|
||||||
|
: 'info'
|
||||||
|
}
|
||||||
|
sx={{ fontSize: '0.8rem' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth={false} disableGutters sx={{ px: { xs: 1, sm: 3, md: 6 } }}>
|
||||||
|
{/* Шапка */}
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
mb: 4,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 2
|
||||||
|
}}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<CalendarIcon color="primary" />
|
||||||
|
<Typography variant="h4">Календарь чемпионатов</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Фильтры */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<FilterIcon />}
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Фильтры
|
||||||
|
</Button>
|
||||||
|
{showFilters && (
|
||||||
|
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||||
|
<InputLabel>Класс</InputLabel>
|
||||||
|
<Select value={selectedClass} onChange={(e) => setSelectedClass(e.target.value)}>
|
||||||
|
{['Все', 'Юниоры', 'Взрослые', 'Богатыри', '35+', 'Pro', 'Amateur', 'Симулятор A', 'Симулятор B'].map(
|
||||||
|
(cls) => (
|
||||||
|
<MenuItem key={cls} value={cls}>
|
||||||
|
{cls}
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Основной контент */}
|
||||||
|
<Box sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: '1fr', md: '2fr 1fr' },
|
||||||
|
gap: { xs: 0, md: 4 },
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 1400,
|
||||||
|
mx: 'auto'
|
||||||
|
}}>
|
||||||
|
{/* Календарь */}
|
||||||
|
<Paper variant="outlined" sx={{ borderRadius: 2, overflow: 'hidden', height: '100%' }}>
|
||||||
|
<LocalizationProvider dateAdapter={AdapterDateFns} locale={ruLocale}>
|
||||||
|
<DateCalendar
|
||||||
|
value={viewDate}
|
||||||
|
onChange={(newValue) => {
|
||||||
|
// Не меняем viewDate при клике на день — только навигация по месяцам
|
||||||
|
if (newValue.getMonth() !== viewDate.getMonth()) {
|
||||||
|
setViewDate(newValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
showDaysOutsideCurrentMonth
|
||||||
|
skipDisabledDateSelection
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
'.MuiDayCalendar-dayButton': {
|
||||||
|
minHeight: { xs: 80, sm: 100 },
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
p: 1,
|
||||||
|
},
|
||||||
|
'.MuiPickersDay-root': {
|
||||||
|
fontSize: { xs: '1rem', sm: '1.1rem' },
|
||||||
|
},
|
||||||
|
// Подсветка дней с событиями
|
||||||
|
'&.MuiPickersDay-root[data-selected="true"]': {
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
'&.MuiPickersDay-root:has(.event-chip)': {
|
||||||
|
border: '2px solid',
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: 'primary.dark',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
dayRenderer={(params) => {
|
||||||
|
const dayKey = params.day.toDateString();
|
||||||
|
const events = stagesByDate[dayKey] || [];
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ width: '100%', height: '100%' }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 500,
|
||||||
|
color: params.outsideCurrentMonth ? 'text.disabled' : 'inherit',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{params.day.getDate()}
|
||||||
|
</Typography>
|
||||||
|
{events.length > 0 && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: 0.5,
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 0.25,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{events.map((event, idx) => (
|
||||||
|
<Chip
|
||||||
|
key={idx}
|
||||||
|
label={event.stage}
|
||||||
|
size="small"
|
||||||
|
className="event-chip" // Для CSS-селектора
|
||||||
|
color={
|
||||||
|
event.status === 'Идёт'
|
||||||
|
? 'warning'
|
||||||
|
: event.status === 'Регистрация открыта'
|
||||||
|
? 'success'
|
||||||
|
: 'info'
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
backgroundColor:
|
||||||
|
event.status === 'Идёт'
|
||||||
|
? 'warning.dark'
|
||||||
|
: event.status === 'Регистрация открыта'
|
||||||
|
? 'success.dark'
|
||||||
|
: 'info.dark',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
px: 0.3,
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</LocalizationProvider>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Список этапов (только на десктопе) */}
|
||||||
|
{!isMobile && (
|
||||||
|
<Box sx={{ overflowY: 'auto', maxHeight: 'calc(100vh - 200px)' }}>
|
||||||
|
<Paper variant="outlined" sx={{ borderRadius: 2 }}>
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Этапы на {viewDate.toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' })}
|
||||||
|
</Typography>
|
||||||
|
{filteredStages.length === 0 ? (
|
||||||
|
<Typography color="text.secondary" sx={{ mt: 2 }}>
|
||||||
|
Нет этапов в выбранном периоде
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
filteredStages.map((stage) => (
|
||||||
|
<Box
|
||||||
|
key={stage.id}
|
||||||
|
sx={{
|
||||||
|
p: 2.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: 'action.hover',
|
||||||
|
mb: 2,
|
||||||
|
'&:last-child': { mb: 0 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
fontWeight="bold"
|
||||||
|
noWrap
|
||||||
|
sx={{ mb: 0.5 }}
|
||||||
|
>
|
||||||
|
{stage.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
{stage.stage} · {stage.class}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
<Chip
|
||||||
|
label={stage.status}
|
||||||
|
size="small"
|
||||||
|
color={
|
||||||
|
stage.status === 'Идёт'
|
||||||
|
? 'warning'
|
||||||
|
: stage.status === 'Регистрация открыта'
|
||||||
|
? 'success'
|
||||||
|
: 'info'
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
backgroundColor:
|
||||||
|
stage.status === 'Идёт'
|
||||||
|
? 'warning.dark'
|
||||||
|
: stage.status === 'Регистрация открыта'
|
||||||
|
? 'success.dark'
|
||||||
|
: 'info.dark',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ mt: 1.5 }}
|
||||||
|
>
|
||||||
|
{new Date(stage.date).toLocaleDateString('ru-RU', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
weekday: 'short',
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Drawer для мобильных */}
|
||||||
|
{isMobile && (
|
||||||
|
<Drawer
|
||||||
|
anchor="bottom"
|
||||||
|
open={openDrawer}
|
||||||
|
onClose={() => setOpenDrawer(false)}
|
||||||
|
PaperProps={{ sx: { borderRadius: '16px 16px 0 0' } }}
|
||||||
|
>
|
||||||
|
{drawerContent}
|
||||||
|
</Drawer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Кнопка для открытия drawer на мобильных */}
|
||||||
|
{isMobile && filteredStages.length > 0 && (
|
||||||
|
<Box sx={{ position: 'fixed', bottom: 16, right: 16 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
onClick={() => setOpenDrawer(true)}
|
||||||
|
startIcon={<CalendarIcon />}
|
||||||
|
sx={{
|
||||||
|
boxShadow: 3,
|
||||||
|
'&:hover': { boxShadow: 4 },
|
||||||
|
borderRadius: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Этапы на сегодня
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Подвал */}
|
||||||
|
<Divider sx={{ my: 6 }} />
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
align="center"
|
||||||
|
sx={{ mb: 4 }}
|
||||||
|
>
|
||||||
|
© 2026 КартХолл. Календарь соревнований.
|
||||||
|
</Typography>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CalendarPage;
|
||||||
226
front/src/app/pages/championship/ChampionshipPage.js
Normal file
226
front/src/app/pages/championship/ChampionshipPage.js
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
// src/pages/ChampionshipPage.jsx
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
|
Alert,
|
||||||
|
CircularProgress
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Launch as LaunchIcon, Download as DownloadIcon } from '@mui/icons-material';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
// Импортируем константы (из предыдущего файла)
|
||||||
|
import { MOCK_STAGES, STAGE_STATUSES } from '../../../data/constants';
|
||||||
|
|
||||||
|
const ChampionshipPage = () => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const [championship, setChampionship] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Имитируем запрос на сервер
|
||||||
|
const fetchChampionship = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// В реальном проекте тут будет fetch(`/api/championships/${id}`)
|
||||||
|
// Для примера фильтруем MOCK_STAGES по id этапа (упрощённо)
|
||||||
|
const stagesForChampionship = MOCK_STAGES.filter(stage => stage.id === Number(id));
|
||||||
|
|
||||||
|
if (stagesForChampionship.length === 0) {
|
||||||
|
setError(true);
|
||||||
|
} else {
|
||||||
|
// Формируем данные чемпионата на основе первого подходящего этапа
|
||||||
|
const stage = stagesForChampionship[0];
|
||||||
|
setChampionship({
|
||||||
|
id: stage.id,
|
||||||
|
title: stage.title,
|
||||||
|
description: stage.description || 'Информация о чемпионате отсутствует.',
|
||||||
|
regulationsUrl: '/dummy-regulations.pdf', // Условный URL регламента
|
||||||
|
stages: MOCK_STAGES.filter(s => s.title === stage.title) // Все этапы этого чемпионата
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchChampionship();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4, textAlign: 'center' }}>
|
||||||
|
<CircularProgress />
|
||||||
|
<Typography sx={{ mt: 2 }}>Загрузка данных чемпионата...</Typography>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !championship) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
Чемпионат не найден или произошла ошибка при загрузке данных.
|
||||||
|
</Alert>
|
||||||
|
<Button variant="contained" onClick={() => window.history.back()}>
|
||||||
|
Вернуться назад
|
||||||
|
</Button>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортируем этапы: сначала предстоящие (регистрация открыта / идёт), потом прошедшие
|
||||||
|
const sortedStages = [...championship.stages].sort((a, b) => {
|
||||||
|
const isAPast = a.status === STAGE_STATUSES.COMPLETED;
|
||||||
|
const isBPast = b.status === STAGE_STATUSES.COMPLETED;
|
||||||
|
if (isAPast && !isBPast) return 1;
|
||||||
|
if (!isAPast && isBPast) return -1;
|
||||||
|
return 0; // Сохраняем порядок для этапов одного типа
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
{/* Заголовок и основная информация */}
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
{championship.title}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
{championship.description}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Кнопка загрузки регламента */}
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
href={championship.regulationsUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
sx={{ mb: 4 }}
|
||||||
|
>
|
||||||
|
Скачать регламент
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 4 }} />
|
||||||
|
|
||||||
|
{/* Список этапов */}
|
||||||
|
<Typography variant="h5" gutterBottom>Этапы чемпионата</Typography>
|
||||||
|
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{sortedStages.map((stage) => (
|
||||||
|
<Paper
|
||||||
|
key={stage.id}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
'&:hover': { boxShadow: 3 },
|
||||||
|
transition: 'box-shadow 0.2s'
|
||||||
|
}}
|
||||||
|
onClick={() => window.location.replace("/stages/" + stage.id)}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={600}>
|
||||||
|
{stage.stage}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={stage.status}
|
||||||
|
color={
|
||||||
|
stage.status === STAGE_STATUSES.GOING
|
||||||
|
? 'warning'
|
||||||
|
: stage.status === STAGE_STATUSES.REGISTRATION_OPEN
|
||||||
|
? 'success'
|
||||||
|
: stage.status === STAGE_STATUSES.PRE_REGISTRATION
|
||||||
|
? 'info'
|
||||||
|
: 'default'
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
backgroundColor:
|
||||||
|
stage.status === STAGE_STATUSES.GOING
|
||||||
|
? 'warning.dark'
|
||||||
|
: stage.status === STAGE_STATUSES.REGISTRATION_OPEN
|
||||||
|
? 'success.dark'
|
||||||
|
: stage.status === STAGE_STATUSES.PRE_REGISTRATION
|
||||||
|
? 'info.dark'
|
||||||
|
: 'grey.300',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '0.8rem'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
{new Date(stage.date).toLocaleDateString('ru-RU', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
|
Место: {stage.location}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{stage.description && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
{stage.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Бейджи вместо кнопок */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
{stage.status === STAGE_STATUSES.REGISTRATION_OPEN && (
|
||||||
|
<Chip
|
||||||
|
label="Регистрация открыта"
|
||||||
|
color="success"
|
||||||
|
variant="outlined"
|
||||||
|
icon={<LaunchIcon fontSize="small" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{stage.status === STAGE_STATUSES.COMPLETED && (
|
||||||
|
<Chip
|
||||||
|
label="Результаты"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
icon={<LaunchIcon fontSize="small" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(stage.status === STAGE_STATUSES.GOING ||
|
||||||
|
stage.status === STAGE_STATUSES.PRE_REGISTRATION) && (
|
||||||
|
<Chip
|
||||||
|
label={
|
||||||
|
stage.status === STAGE_STATUSES.GOING
|
||||||
|
? 'Идёт сейчас'
|
||||||
|
: 'Предрегистрация'
|
||||||
|
}
|
||||||
|
color={stage.status === STAGE_STATUSES.GOING ? 'warning' : 'info'}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 4 }} />
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" align="center">
|
||||||
|
© 2026 КартХолл. Все права защищены.
|
||||||
|
</Typography>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChampionshipPage;
|
||||||
195
front/src/app/pages/championship/ChampionshipsPage.js
Normal file
195
front/src/app/pages/championship/ChampionshipsPage.js
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardActions,
|
||||||
|
CardContent,
|
||||||
|
Container,
|
||||||
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableRow,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {Add as AddIcon, Edit as EditIcon, Visibility as VisibilityIcon} from '@mui/icons-material';
|
||||||
|
import Divider from "@mui/material/Divider";
|
||||||
|
|
||||||
|
// Данные чемпионатов (можно заменить на API-запрос)
|
||||||
|
const CHAMPIONSHIPS = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'SWC Зимний чемпионат 2025–2026',
|
||||||
|
season: 'Зима 2025–2026',
|
||||||
|
stages: 5,
|
||||||
|
status: 'Идёт',
|
||||||
|
classes: ['Юниоры', 'Взрослые', 'Богатыри', '35+'],
|
||||||
|
startDate: '18.01.2026',
|
||||||
|
endDate: '08.03.2026',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Honda Winter Cup 2026',
|
||||||
|
season: 'Зима 2026',
|
||||||
|
stages: 3,
|
||||||
|
status: 'Регистрация открыта',
|
||||||
|
classes: ['Pro', 'Amateur'],
|
||||||
|
startDate: '31.01.2026',
|
||||||
|
endDate: '28.02.2026',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'Кубок Покровска 2026 (онлайн)',
|
||||||
|
season: '2026',
|
||||||
|
stages: 4,
|
||||||
|
status: 'Предрегистрация',
|
||||||
|
classes: ['Симулятор A', 'Симулятор B'],
|
||||||
|
startDate: '01.02.2026',
|
||||||
|
endDate: '25.03.2026',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ChampionshipsPage = () => {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl" sx={{py: 6}}>
|
||||||
|
{/* Заголовок и кнопка создания */}
|
||||||
|
<Box sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4}}>
|
||||||
|
<Typography variant="h4">
|
||||||
|
Все чемпионаты
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size={'small'}
|
||||||
|
color="primary"
|
||||||
|
startIcon={<AddIcon/>}
|
||||||
|
href="/championships/create"
|
||||||
|
sx={{px: 3}}
|
||||||
|
>
|
||||||
|
Создать чемпионат
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Список чемпионатов */}
|
||||||
|
<Grid container spacing={4}>
|
||||||
|
{CHAMPIONSHIPS.map((champ) => (
|
||||||
|
<Grid item xs={12} sm={6} md={4} key={champ.id}>
|
||||||
|
<Card variant="outlined" sx={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between', // Растягивает содержимое, прижимает actions вниз
|
||||||
|
transition: 'transform 0.2s',
|
||||||
|
'&:hover': { transform: 'scale(1.02)'
|
||||||
|
}}}>
|
||||||
|
<CardContent>
|
||||||
|
<Box
|
||||||
|
sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'start', mb: 2}}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6">
|
||||||
|
{champ.title}
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title={champ.status}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: champ.status === 'Идёт' ? 'warning.main' :
|
||||||
|
champ.status === 'Регистрация открыта' ? 'success.main' : 'info.main',
|
||||||
|
color: 'white',
|
||||||
|
px: 1,
|
||||||
|
borderRadius: 1,
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{champ.status}
|
||||||
|
</Typography>
|
||||||
|
</Tooltip>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
{champ.season}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Таблица с основными данными */}
|
||||||
|
<Table size="small" sx={{mt: 2}}>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell variant="head">Этапы</TableCell>
|
||||||
|
<TableCell>{champ.stages}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell variant="head">Классы</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{champ.classes.join(', ')}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell variant="head">Начало</TableCell>
|
||||||
|
<TableCell>{champ.startDate}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell variant="head">Конец</TableCell>
|
||||||
|
<TableCell>{champ.endDate}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
<CardActions sx={{ justifyContent: 'flex-end', p: 2 }}>
|
||||||
|
{/* Действия */}
|
||||||
|
<Box sx={{display: 'flex', gap: 1}}>
|
||||||
|
<IconButton
|
||||||
|
component="a"
|
||||||
|
href={`/championships/${champ.id}`}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
title="Посмотреть детали"
|
||||||
|
>
|
||||||
|
<VisibilityIcon fontSize="small"/>
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
component="a"
|
||||||
|
href={`/championships/${champ.id}/edit`}
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
title="Редактировать"
|
||||||
|
>
|
||||||
|
<EditIcon fontSize="small"/>
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Сообщение, если чемпионатов нет */}
|
||||||
|
{CHAMPIONSHIPS.length === 0 && (
|
||||||
|
<Paper sx={{textAlign: 'center', py: 8}}>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
Пока нет ни одного чемпионата. Создайте первый!
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
sx={{mt: 2}}
|
||||||
|
href="/championships/create"
|
||||||
|
>
|
||||||
|
Создать чемпионат
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Подвал */}
|
||||||
|
<Divider sx={{my: 6}}/>
|
||||||
|
<Typography variant="body2" color="text.secondary" align="center">
|
||||||
|
© 2026 КартХолл. Управление чемпионатами.
|
||||||
|
</Typography>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChampionshipsPage;
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import CocktailsPageContent from "./CocktailsPageContent";
|
|
||||||
|
|
||||||
export function MenuPage() {
|
|
||||||
return (
|
|
||||||
<CocktailsPageContent/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
332
front/src/app/pages/home/HomePageContent.js
Normal file
332
front/src/app/pages/home/HomePageContent.js
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import Grid from "@mui/material/Grid";
|
||||||
|
import * as React from "react";
|
||||||
|
import {Card, CardContent} from "@mui/material";
|
||||||
|
import {useUser} from "../../../hooks/useUser";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import Divider from "@mui/material/Divider";
|
||||||
|
import Container from "@mui/material/Container";
|
||||||
|
import Avatar from "@mui/material/Avatar";
|
||||||
|
import {Trophy} from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
// Компонент таймера (упрощённый)
|
||||||
|
const CountdownTimer = ({targetDate}) => {
|
||||||
|
const [timeLeft, setTimeLeft] = React.useState({});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const updateTimer = () => {
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const distance = new Date(targetDate).getTime() - now;
|
||||||
|
|
||||||
|
const days = Math.floor(distance / (1000 * 60 * 60 * 24));
|
||||||
|
const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
|
||||||
|
const seconds = Math.floor((distance % (1000 * 60)) / 1000);
|
||||||
|
|
||||||
|
setTimeLeft({days, hours, minutes, seconds});
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = setInterval(updateTimer, 1000);
|
||||||
|
updateTimer();
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [targetDate]);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Typography variant="h6" color="error" sx={{fontWeight: 'bold'}}>
|
||||||
|
До старта: {timeLeft.days}д {timeLeft.hours}ч {timeLeft.minutes}м {timeLeft.seconds}с
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Основной компонент приветственного блока
|
||||||
|
const HomePageContent = () => {
|
||||||
|
const {user} = useUser();
|
||||||
|
|
||||||
|
// Данные чемпионатов (можно подтянуть из API/state)
|
||||||
|
const championships = [
|
||||||
|
{
|
||||||
|
title: 'SWC Зимний чемпионат 2025–2026',
|
||||||
|
stage: '2‑й этап',
|
||||||
|
date: '08.02.2026',
|
||||||
|
link: '/swc-registration',
|
||||||
|
icon: <Trophy fontSize="large"/>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Honda Winter Cup 2026',
|
||||||
|
stage: '1‑й этап',
|
||||||
|
date: '31.01.2026',
|
||||||
|
link: '/hwc-info',
|
||||||
|
icon: <Trophy fontSize="large"/>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Кубок Покровска 2026 (онлайн)',
|
||||||
|
stage: '1‑й этап',
|
||||||
|
date: '01.02.2026',
|
||||||
|
link: '/pokrovsk-sim',
|
||||||
|
icon: <Trophy fontSize="large"/>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Результаты последнего этапа
|
||||||
|
const results = {
|
||||||
|
Юниоры: ['Пупкин Петя', 'Иванов Ваня', 'Кот Кирилл'],
|
||||||
|
Взрослые: ['Пупкин Петя', 'Иванов Ваня', 'Кот Кирилл'],
|
||||||
|
Богатыри: ['Пупкин Петя', 'Иванов Ваня', 'Кот Кирилл'],
|
||||||
|
'35+': ['Пупкин Петя', 'Иванов Ваня', 'Кот Кирилл'],
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Спонсоры (макеты URL)
|
||||||
|
const sponsors = [
|
||||||
|
{name: 'Минеральная вода Ульянка', logo: '/sponsors/ulyanka.png'},
|
||||||
|
{name: 'Gosha Racing Team', logo: '/sponsors/gosha.png'},
|
||||||
|
{name: 'Diff', logo: '/sponsors/diff.png'},
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl" sx={{py: 3}}>
|
||||||
|
{/* 1. Приветствие */}
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
Добро пожаловать на платформу «КартХолл»!
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" color="text.secondary" gutterBottom sx={{mb: 4}}>
|
||||||
|
Ваш гид по гоночным чемпионатам, этапам и онлайн‑соревнованиям.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* 2. Карточки анонсов */}
|
||||||
|
<Grid container spacing={4} sx={{mb: 6}}>
|
||||||
|
{championships.map((champ, index) => (
|
||||||
|
<Grid item xs={12} sm={6} md={4} key={index}>
|
||||||
|
<Card variant="outlined" sx={{
|
||||||
|
height: '100%',
|
||||||
|
transition: 'transform 0.2s',
|
||||||
|
'&:hover': {transform: 'scale(1.02)'}
|
||||||
|
}}>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{display: 'flex', alignItems: 'center', mb: 2}}>
|
||||||
|
{champ.icon}
|
||||||
|
<Typography variant="h6" sx={{ml: 1}}>
|
||||||
|
{champ.title}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography color="text.secondary" sx={{mb: 1}}>
|
||||||
|
{champ.stage} — {champ.date}
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
href={champ.link}
|
||||||
|
sx={{mt: 2}}
|
||||||
|
>
|
||||||
|
Подробнее
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* 3. Быстрые действия */}
|
||||||
|
<Box sx={{display: 'flex', flexWrap: 'wrap', gap: 2, mb: 6}}>
|
||||||
|
<Button variant="contained" color="secondary" href="/register">
|
||||||
|
Зарегистрироваться на этап
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" href="/calendar">
|
||||||
|
Посмотреть календарь всех этапов
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" href="/rules">
|
||||||
|
Правила гонок
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 4. Спонсоры */}
|
||||||
|
<Box sx={{display: 'flex', overflowX: 'auto', py: 2, gap: 3, mb: 4}}>
|
||||||
|
{sponsors.map((sponsor, idx) => (
|
||||||
|
<Avatar
|
||||||
|
key={idx}
|
||||||
|
src={sponsor.logo}
|
||||||
|
alt={sponsor.name}
|
||||||
|
sx={{width: 300, height: 150, borderRadius: '8px'}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{mb: 4}}>
|
||||||
|
Наши партнёры: поддержка чемпионатов и призов
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* 5. Прокат */}
|
||||||
|
<Box sx={{textAlign: 'center', mb: 6}}>
|
||||||
|
<Typography variant="h5" sx={{mb: 2}}>
|
||||||
|
Не гоняешь? Попробуй прокат!
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary" sx={{mb: 3}}>
|
||||||
|
Ощутите адреналин за рулём прокатного карта. Доступно ежедневно с 10:00 до 22:00.
|
||||||
|
</Typography>
|
||||||
|
<Button variant="contained" color="success" href="/rent">
|
||||||
|
Забронировать карта
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 6. Таймер до ближайшего этапа (Honda Winter Cup) */}
|
||||||
|
<Box sx={{textAlign: 'center', mb: 6}}>
|
||||||
|
<Typography variant="h5" sx={{mb: 2}}>
|
||||||
|
Следующий этап стартует уже:
|
||||||
|
</Typography>
|
||||||
|
<CountdownTimer targetDate="2026-01-31T14:00:00"/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 7. Результаты последнего этапа */}
|
||||||
|
<Box sx={{mb: 6}}>
|
||||||
|
<Typography variant="h5" sx={{mb: 3}}>
|
||||||
|
Итоги 1‑го этапа SWC (18.01.2026)
|
||||||
|
</Typography>
|
||||||
|
{Object.entries(results).map(([category, winners]) => (
|
||||||
|
<Box key={category} sx={{mb: 2}}>
|
||||||
|
<Typography variant="subtitle1" color="primary">
|
||||||
|
{category}:
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
1 место: {winners[0]}, 2 место: {winners[1]}, 3 место: {winners[2]}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
href="/stages"
|
||||||
|
sx={{mt: 3}}
|
||||||
|
>
|
||||||
|
Все результаты
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Разделитель и подвал */}
|
||||||
|
<Divider sx={{my: 4}}/>
|
||||||
|
<Typography variant="body2" color="text.secondary" align="center">
|
||||||
|
© 2026 КартХолл. Все права защищены.
|
||||||
|
</Typography>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <Paper sx={{flexGrow: 1, p: 1}}>
|
||||||
|
// <Box sx={{mb: 3, p: 2}}>
|
||||||
|
// <Typography variant="h3" component="div" sx={{flexGrow: 1}}>
|
||||||
|
// Добро пожаловать на платформу «КартХолл»!
|
||||||
|
// </Typography>
|
||||||
|
// <Typography variant="h6" component="div" sx={{flexGrow: 1, mt: 1}}>
|
||||||
|
// Ваш гид по гоночным чемпионатам, этапам и онлайн‑соревнованиям.
|
||||||
|
// </Typography>
|
||||||
|
// </Box>
|
||||||
|
//
|
||||||
|
// <Box sx={{p: 2}}>
|
||||||
|
//
|
||||||
|
// <Grid spacing={1} rowSpacing={1}>
|
||||||
|
// {anounce.map((item, index) => {
|
||||||
|
// return (
|
||||||
|
// // Карточка анонса
|
||||||
|
// <Grid size={3}>
|
||||||
|
// <Paper sx={{p: 2, mb: 1}}>
|
||||||
|
// <Stack>
|
||||||
|
// <Typography variant="h5">{item.championship}</Typography>
|
||||||
|
// </Stack>
|
||||||
|
// <CardMedia
|
||||||
|
// sx={{
|
||||||
|
// loading: "eager",
|
||||||
|
// borderRadius: 2
|
||||||
|
// }}
|
||||||
|
// // onClick={() => handleSelect(row)}
|
||||||
|
// component="img"
|
||||||
|
// alt={item.name}
|
||||||
|
// height="300"
|
||||||
|
//
|
||||||
|
// image={item.photo}
|
||||||
|
// />
|
||||||
|
// <CardContent sx={{pb: 0, pl: 2, pt: 0}}>
|
||||||
|
// <Typography variant="h6"
|
||||||
|
// mt={2}>{item.name} - {item.message} </Typography>
|
||||||
|
// <Typography> </Typography>
|
||||||
|
// {/*<CocktailDescription row={row}/>*/}
|
||||||
|
// </CardContent>
|
||||||
|
// <CardActions>
|
||||||
|
// <Button variant="contained" color="primary" component="div"
|
||||||
|
// // onClick={() => cocktailClient.drinkCocktail(row.id, createSuccess, createError)}
|
||||||
|
// >
|
||||||
|
// <LocalBarIcon fontSize='small'/> Регистрация
|
||||||
|
// </Button>
|
||||||
|
// </CardActions>
|
||||||
|
// </Paper>
|
||||||
|
// </Grid>
|
||||||
|
// )
|
||||||
|
// })}
|
||||||
|
// </Grid>
|
||||||
|
// </Box>
|
||||||
|
//
|
||||||
|
// <Card sx={{p: 2, mt: 3}}>
|
||||||
|
// {/*todo: под вопросом*/}
|
||||||
|
// <Typography variant={'h6'}>Быстрые действия</Typography>
|
||||||
|
// <Stack direction={"row"} spacing={2} sx={{justifyContent: 'center', alignItems: 'center'}}>
|
||||||
|
// <Button variant={"contained"} size={"large"}>
|
||||||
|
// Зарегистрироваться на этап
|
||||||
|
// </Button>
|
||||||
|
// <Button variant={"contained"} size={"large"}>
|
||||||
|
// Посмотреть календарь этапов
|
||||||
|
// </Button>
|
||||||
|
// </Stack>
|
||||||
|
// </Card>
|
||||||
|
//
|
||||||
|
// <Box sx={{p: 2, my: 3}}>
|
||||||
|
// <Stack direction={'row'} sx={{my: 1}}>
|
||||||
|
// {
|
||||||
|
// sponsors.map((item) => {
|
||||||
|
// return (
|
||||||
|
// <Card sx={{m:1}}>
|
||||||
|
// <CardMedia
|
||||||
|
// sx={{
|
||||||
|
// loading: "eager",
|
||||||
|
// borderRadius: 2
|
||||||
|
// }}
|
||||||
|
// // onClick={() => handleSelect(row)}
|
||||||
|
// component="img"
|
||||||
|
// alt={item.alt}
|
||||||
|
// height="60"
|
||||||
|
//
|
||||||
|
// image={item.photo}
|
||||||
|
// />
|
||||||
|
// </Card>
|
||||||
|
// )
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
// </Stack>
|
||||||
|
// <Typography variant={'h5'}>Наши партнёры: поддержка чемпионатов и призов</Typography>
|
||||||
|
// </Box>
|
||||||
|
//
|
||||||
|
//
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// <Fab sx={{
|
||||||
|
// alpha: '30%',
|
||||||
|
// position: 'sticky',
|
||||||
|
// left: 'calc(100% - 16px)',
|
||||||
|
// bottom: '16px',
|
||||||
|
// color: 'common.white',
|
||||||
|
// bgcolor: blue[600],
|
||||||
|
// '&:hover': {
|
||||||
|
// bgcolor: blue[600],
|
||||||
|
// },
|
||||||
|
// }}
|
||||||
|
// onClick={() => window.window.scrollTo(0, 0)}
|
||||||
|
// aria-label='Expand'
|
||||||
|
// color='inherit'>
|
||||||
|
// <UpIcon/>
|
||||||
|
// </Fab>
|
||||||
|
// </Paper>
|
||||||
|
// );
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HomePageContent;
|
||||||
7
front/src/app/pages/home/MenuPage.js
Normal file
7
front/src/app/pages/home/MenuPage.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import HomePageContent from "./HomePageContent";
|
||||||
|
|
||||||
|
export function MenuPage() {
|
||||||
|
return (
|
||||||
|
<HomePageContent/>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
front/src/app/pages/home/anounce.js
Normal file
26
front/src/app/pages/home/anounce.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export const anounce = [
|
||||||
|
{
|
||||||
|
championship: 'SWC Зимний чемпионат 2025–2026',
|
||||||
|
name: '2‑й этап',
|
||||||
|
date: '08.02.2026',
|
||||||
|
message: 'Открыта регистрация!',
|
||||||
|
new: false,
|
||||||
|
photo: '/assets/background.png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
championship: 'Honda Winter Cup 2026',
|
||||||
|
name: '1‑й этап',
|
||||||
|
date: '31.01.2026',
|
||||||
|
message: 'Успейте зарегистрироваться: осталось 12 мест',
|
||||||
|
new: true,
|
||||||
|
photo: '/assets/background.png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
championship: 'Кубок Покровска 2026 (онлайн)',
|
||||||
|
name: '1‑й этап',
|
||||||
|
date: '01.02.2026',
|
||||||
|
message: 'Участвуйте из дома: подключение через симулятор',
|
||||||
|
new: true,
|
||||||
|
photo: '/assets/background.png'
|
||||||
|
},
|
||||||
|
]
|
||||||
38
front/src/app/pages/home/sponsors.js
Normal file
38
front/src/app/pages/home/sponsors.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export const sponsors = [
|
||||||
|
{
|
||||||
|
alt: 'Ульянка',
|
||||||
|
photo: '/assets/logo_ulyanka-1.svg',
|
||||||
|
link: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
alt: 'Ульянка',
|
||||||
|
photo: '/assets/logo_ulyanka-1.svg',
|
||||||
|
link: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
alt: 'Ульянка',
|
||||||
|
photo: '/assets/logo_ulyanka-1.svg',
|
||||||
|
link: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
alt: 'Ульянка',
|
||||||
|
photo: '/assets/logo_ulyanka-1.svg',
|
||||||
|
link: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
alt: 'Ульянка',
|
||||||
|
photo: '/assets/logo_ulyanka-1.svg',
|
||||||
|
link: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
alt: 'Ульянка',
|
||||||
|
photo: '/assets/logo_ulyanka-1.svg',
|
||||||
|
link: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
alt: 'Ульянка',
|
||||||
|
photo: '/assets/logo_ulyanka-1.svg',
|
||||||
|
link: '#'
|
||||||
|
},
|
||||||
|
|
||||||
|
]
|
||||||
137
front/src/app/pages/stages/StagePage.js
Normal file
137
front/src/app/pages/stages/StagePage.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// src/pages/StagePage.jsx
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Container, Typography, Button, Paper, Box, Divider, Alert, Chip
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Download as DownloadIcon } from '@mui/icons-material';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import {MOCK_STAGES, STAGE_STATUSES} from '../../../data/constants';
|
||||||
|
|
||||||
|
|
||||||
|
const StagePage = () => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const [stage, setStage] = useState(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const foundStage = MOCK_STAGES.find(s => s.id === Number(id));
|
||||||
|
if (foundStage) {
|
||||||
|
setStage(foundStage);
|
||||||
|
} else {
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
Этап не найден.
|
||||||
|
</Alert>
|
||||||
|
<Button variant="contained" onClick={() => window.history.back()}>
|
||||||
|
Вернуться назад
|
||||||
|
</Button>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stage) {
|
||||||
|
return null; // или спиннер
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
<Typography variant="h4" gutterBottom>{stage.title}</Typography>
|
||||||
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||||
|
{stage.stage}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Paper sx={{ p: 4, mb: 4 }}>
|
||||||
|
<Typography variant="h5" gutterBottom>Информация об этапе</Typography>
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Дата
|
||||||
|
</Typography>
|
||||||
|
<Typography>
|
||||||
|
{new Date(stage.date).toLocaleDateString('ru-RU', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Место проведения
|
||||||
|
</Typography>
|
||||||
|
<Typography>{stage.location}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Класс
|
||||||
|
</Typography>
|
||||||
|
<Typography>{stage.class}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Статус
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={stage.status}
|
||||||
|
color={
|
||||||
|
stage.status === STAGE_STATUSES.GOING
|
||||||
|
? 'warning'
|
||||||
|
: stage.status === STAGE_STATUSES.REGISTRATION_OPEN
|
||||||
|
? 'success'
|
||||||
|
: stage.status === STAGE_STATUSES.PRE_REGISTRATION
|
||||||
|
? 'info'
|
||||||
|
: 'default'
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
backgroundColor:
|
||||||
|
stage.status === STAGE_STATUSES.GOING
|
||||||
|
? 'warning.dark'
|
||||||
|
: stage.status === STAGE_STATUSES.REGISTRATION_OPEN
|
||||||
|
? 'success.dark'
|
||||||
|
: stage.status === STAGE_STATUSES.PRE_REGISTRATION
|
||||||
|
? 'info.dark'
|
||||||
|
: 'grey.300',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '0.8rem'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 3 }} />
|
||||||
|
|
||||||
|
<Typography variant="body1" paragraph>
|
||||||
|
{stage.description}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{stage.registrationLink && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="success"
|
||||||
|
href={stage.registrationLink}
|
||||||
|
target="_blank"
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
>
|
||||||
|
Перейти на регистрацию
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 4 }} />
|
||||||
|
<Typography variant="body2" color="text.secondary" align="center">
|
||||||
|
© 2026 КартХолл. Все права защищены.
|
||||||
|
</Typography>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StagePage;
|
||||||
230
front/src/app/pages/stages/StagesPage.js
Normal file
230
front/src/app/pages/stages/StagesPage.js
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableBody,
|
||||||
|
TableContainer,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
InputLabel,
|
||||||
|
FormControl,
|
||||||
|
Box,
|
||||||
|
Pagination,
|
||||||
|
Divider, Chip
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Sort as SortIcon } from '@mui/icons-material';
|
||||||
|
|
||||||
|
// Моковые данные этапов
|
||||||
|
const MOCK_STAGES = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'SWC Зимний чемпионат',
|
||||||
|
stage: '2‑й этап',
|
||||||
|
date: '2026-02-08',
|
||||||
|
class: 'Юниоры',
|
||||||
|
status: 'Идёт',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Honda Winter Cup',
|
||||||
|
stage: '1‑й этап',
|
||||||
|
date: '2026-01-31',
|
||||||
|
class: 'Pro',
|
||||||
|
status: 'Регистрация открыта',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'Кубок Покровска (онлайн)',
|
||||||
|
stage: '1‑й этап',
|
||||||
|
date: '2026-02-01',
|
||||||
|
class: 'Симулятор A',
|
||||||
|
status: 'Предрегистрация',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: 'Гран-при Урала',
|
||||||
|
stage: 'Финальный этап',
|
||||||
|
date: '2026-03-15',
|
||||||
|
class: 'Взрослые',
|
||||||
|
status: 'Предрегистрация',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: 'Открытый кубок Москвы',
|
||||||
|
stage: 'Квалификационный раунд',
|
||||||
|
date: '2026-01-20',
|
||||||
|
class: 'Amateur',
|
||||||
|
status: 'Завершено',
|
||||||
|
},
|
||||||
|
// Добавим ещё для демонстрации пагинации
|
||||||
|
{ id: 6, title: 'Этап 6', stage: 'Тест', date: '2026-04-01', class: 'Pro', status: 'Регистрация открыта' },
|
||||||
|
{ id: 7, title: 'Этап 7', stage: 'Тест', date: '2026-05-01', class: 'Юниоры', status: 'Идёт' },
|
||||||
|
{ id: 8, title: 'Этап 8', stage: 'Тест', date: '2026-06-01', class: 'Взрослые', status: 'Предрегистрация' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const StagesPage = () => {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [rowsPerPage, setRowsPerPage] = useState(5);
|
||||||
|
const [sortOrder, setSortOrder] = useState('asc');
|
||||||
|
const [filteredStages, setFilteredStages] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let sorted = [...MOCK_STAGES];
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.date);
|
||||||
|
const dateB = new Date(b.date);
|
||||||
|
return sortOrder === 'asc' ? dateA - dateB : dateB - dateA;
|
||||||
|
});
|
||||||
|
setFilteredStages(sorted);
|
||||||
|
}, [sortOrder]);
|
||||||
|
|
||||||
|
const handlePageChange = (event, newPage) => {
|
||||||
|
setPage(newPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowsPerPageChange = (event) => {
|
||||||
|
setRowsPerPage(Number(event.target.value));
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentStages = filteredStages.slice(
|
||||||
|
(page - 1) * rowsPerPage,
|
||||||
|
page * rowsPerPage
|
||||||
|
);
|
||||||
|
|
||||||
|
// Обработчик клика по строке
|
||||||
|
const handleRowClick = (stage) => {
|
||||||
|
// Здесь можно:
|
||||||
|
// - перенаправить на страницу этапа: navigate(`/stages/${stage.id}`)
|
||||||
|
// - открыть модалку с деталями
|
||||||
|
// - показать alert (пример ниже)
|
||||||
|
alert(`
|
||||||
|
Этап: ${stage.title}
|
||||||
|
Дата: ${new Date(stage.date).toLocaleDateString('ru-RU')}
|
||||||
|
Статус: ${stage.status}
|
||||||
|
Описание: ${stage.description}
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
Этапы соревнований
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>Сортировка</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={sortOrder}
|
||||||
|
onChange={(e) => setSortOrder(e.target.value)}
|
||||||
|
startAdornment={<SortIcon sx={{ ml: 1 }} />}
|
||||||
|
>
|
||||||
|
<MenuItem value="asc">По возрастанию даты</MenuItem>
|
||||||
|
<MenuItem value="desc">По убыванию даты</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>Строк на странице</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={rowsPerPage}
|
||||||
|
onChange={handleRowsPerPageChange}
|
||||||
|
>
|
||||||
|
<MenuItem value={5}>5</MenuItem>
|
||||||
|
<MenuItem value={10}>10</MenuItem>
|
||||||
|
<MenuItem value={20}>20</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table aria-label="таблица этапов">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Название</TableCell>
|
||||||
|
<TableCell>Этап</TableCell>
|
||||||
|
<TableCell>Дата</TableCell>
|
||||||
|
<TableCell>Класс</TableCell>
|
||||||
|
<TableCell>Статус</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{currentStages.map((stage) => (
|
||||||
|
<TableRow
|
||||||
|
key={stage.id}
|
||||||
|
sx={{
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'action.hover',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={() => handleRowClick(stage)}
|
||||||
|
>
|
||||||
|
<TableCell>{stage.title}</TableCell>
|
||||||
|
<TableCell>{stage.stage}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(stage.date).toLocaleDateString('ru-RU')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{stage.class}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={stage.status}
|
||||||
|
size="small"
|
||||||
|
color={
|
||||||
|
stage.status === 'Идёт'
|
||||||
|
? 'warning'
|
||||||
|
: stage.status === 'Регистрация открыта'
|
||||||
|
? 'success'
|
||||||
|
: stage.status === 'Предрегистрация'
|
||||||
|
? 'info'
|
||||||
|
: 'default'
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
backgroundColor:
|
||||||
|
stage.status === 'Идёт'
|
||||||
|
? 'warning.dark'
|
||||||
|
: stage.status === 'Регистрация открыта'
|
||||||
|
? 'success.dark'
|
||||||
|
: stage.status === 'Предрегистрация'
|
||||||
|
? 'info.dark'
|
||||||
|
: 'grey.300',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Показано {currentStages.length} из {filteredStages.length} этапов
|
||||||
|
</Typography>
|
||||||
|
<Pagination
|
||||||
|
count={Math.ceil(filteredStages.length / rowsPerPage)}
|
||||||
|
page={page}
|
||||||
|
onChange={handlePageChange}
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 4 }} />
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" align="center">
|
||||||
|
© 2026 КартХолл. Все права защищены.
|
||||||
|
</Typography>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StagesPage;
|
||||||
@@ -26,7 +26,7 @@ export function IngredientCard({row, value, infoHandler, changeHandler}) {
|
|||||||
<IconButton size='small' onClick={() => infoHandler(row)}>
|
<IconButton size='small' onClick={() => infoHandler(row)}>
|
||||||
<InfoRoundedIcon/>
|
<InfoRoundedIcon/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton size='small' href={`${paths.bar.ingredientEdit}?id=${row.id}`}>
|
<IconButton size='small' href={`${paths.stg.stages}?id=${row.id}`}>
|
||||||
<EditIcon/>
|
<EditIcon/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {List as ListIcon} from '@phosphor-icons/react/dist/ssr/List';
|
|||||||
import {usePopover} from "../../hooks/usePopover";
|
import {usePopover} from "../../hooks/usePopover";
|
||||||
import {MobileNav} from "./MobileNav";
|
import {MobileNav} from "./MobileNav";
|
||||||
import {UserPopover} from "../core/UserPopover";
|
import {UserPopover} from "../core/UserPopover";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import {renderNavItems} from "./NavItem";
|
||||||
|
import {navItems} from "../../navItems";
|
||||||
|
import {useLocation} from "react-router-dom";
|
||||||
// import Tooltip from "@mui/material/Tooltip";
|
// import Tooltip from "@mui/material/Tooltip";
|
||||||
// import {Badge} from "@mui/material";
|
// import {Badge} from "@mui/material";
|
||||||
// import {useAlert} from "../../hooks/useAlert";
|
// import {useAlert} from "../../hooks/useAlert";
|
||||||
@@ -15,6 +19,8 @@ import {UserPopover} from "../core/UserPopover";
|
|||||||
export function MainNav() {
|
export function MainNav() {
|
||||||
const [openNav, setOpenNav] = React.useState(false);
|
const [openNav, setOpenNav] = React.useState(false);
|
||||||
const userPopover = usePopover();
|
const userPopover = usePopover();
|
||||||
|
const location = useLocation();
|
||||||
|
const pathname = location.pathname;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -32,9 +38,12 @@ export function MainNav() {
|
|||||||
<Stack direction="row" spacing={3}
|
<Stack direction="row" spacing={3}
|
||||||
sx={{alignItems: 'center', justifyContent: 'space-between', minHeight: '64px', px: 2}}>
|
sx={{alignItems: 'center', justifyContent: 'space-between', minHeight: '64px', px: 2}}>
|
||||||
<Stack sx={{alignItems: 'center'}} direction="row" spacing={3}>
|
<Stack sx={{alignItems: 'center'}} direction="row" spacing={3}>
|
||||||
<IconButton onClick={() => setOpenNav(true)} sx={{display: {xl: 'none'}}}>
|
<IconButton onClick={() => setOpenNav(true)} sx={{display: {sm: 'none'}}}>
|
||||||
<ListIcon/>
|
<ListIcon/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
<Box component="nav" sx={{flex: '1 1 auto', p: 1}}>
|
||||||
|
{renderNavItems({items: navItems, pathname: pathname, direction: 'row'})}
|
||||||
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack sx={{alignItems: 'center'}} direction="row" spacing={2}>
|
<Stack sx={{alignItems: 'center'}} direction="row" spacing={2}>
|
||||||
<Avatar onClick={userPopover.handleOpen} ref={userPopover.anchorRef} src="/assets/avatar.png"
|
<Avatar onClick={userPopover.handleOpen} ref={userPopover.anchorRef} src="/assets/avatar.png"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Box from "@mui/material/Box";
|
|||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import {Link} from "react-router-dom";
|
import {Link} from "react-router-dom";
|
||||||
|
|
||||||
export function renderNavItems({items = [], pathname}) {
|
export function renderNavItems({items = [], pathname, direction}) {
|
||||||
const children = items.reduce((acc, curr) => {
|
const children = items.reduce((acc, curr) => {
|
||||||
const {key, ...item} = curr;
|
const {key, ...item} = curr;
|
||||||
acc.push(<NavItem key={key} pathname={pathname} {...item} />);
|
acc.push(<NavItem key={key} pathname={pathname} {...item} />);
|
||||||
@@ -13,7 +13,7 @@ export function renderNavItems({items = [], pathname}) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack key={"stack-NavItem-key"} component="ul" spacing={1} sx={{listStyle: 'none', m: 0, p: 0}}>
|
<Stack key={"stack-NavItem-key"} component="ul" spacing={1} sx={{listStyle: 'none', m: 0, p: 0}} direction={direction}>
|
||||||
{children}
|
{children}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,15 +7,6 @@ import {navItems} from "../../navItems";
|
|||||||
import React, {useEffect, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import {useLocation} from "react-router-dom";
|
import {useLocation} from "react-router-dom";
|
||||||
import {useUser} from "../../hooks/useUser";
|
import {useUser} from "../../hooks/useUser";
|
||||||
import Typography from "@mui/material/Typography";
|
|
||||||
|
|
||||||
function renderSpecialItems(items, label, pathname) {
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
{renderNavItems({items: items, pathname: pathname})}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NavigationMenu() {
|
export function NavigationMenu() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -23,17 +14,10 @@ export function NavigationMenu() {
|
|||||||
const {user} = useUser();
|
const {user} = useUser();
|
||||||
const [items, setItems] = useState(null)
|
const [items, setItems] = useState(null)
|
||||||
|
|
||||||
const userChild = navItems.filter((item) => !item.forBarmen && !item.forAdmin)
|
|
||||||
const barmenChild = navItems.filter((item) => item.forBarmen)
|
|
||||||
const adminChild = navItems.filter((item) => item.forAdmin)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const role = !user ? "USER" : Object.keys(user).length === 0 ? "USER" : user.role
|
|
||||||
const newState = (
|
const newState = (
|
||||||
<Box component="nav" sx={{flex: '1 1 auto', p: '12px'}}>
|
<Box component="nav" sx={{flex: '1 1 auto', p: '12px'}}>
|
||||||
{renderNavItems({items: userChild, pathname: pathname})}
|
{renderNavItems({items: navItems, pathname: pathname, direction: 'column'})}
|
||||||
{role !== "USER" && renderSpecialItems(barmenChild, "Для бармена:", pathname)}
|
|
||||||
{role === "ADMIN" && renderSpecialItems(adminChild, "Для админа", pathname)}
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
setItems(newState)
|
setItems(newState)
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import {Route, Routes} from "react-router-dom";
|
import {Route, Routes} from "react-router-dom";
|
||||||
import {paths} from "../path";
|
import {paths} from "../../path";
|
||||||
import {useAuth} from "../hooks/useAuth";
|
import {useAuth} from "../../hooks/useAuth";
|
||||||
import NotFoundPage from "./pages/notFound/NotFoundPage";
|
import NotFoundPage from "../../app/pages/notFound/NotFoundPage";
|
||||||
import {UserLayout} from "./layout/UserLayout";
|
import {UserLayout} from "../../app/layout/UserLayout";
|
||||||
import {HomeRedirect} from "./HomeRedirect";
|
import {PublicLayout} from "../../app/layout/PublicLayout";
|
||||||
import {PublicLayout} from "./layout/PublicLayout";
|
import LoginPage from "../../app/pages/auth/sign-in/loginPage";
|
||||||
import LoginPage from "./pages/auth/sign-in/loginPage";
|
import {TelegramCode} from "../../app/pages/auth/sign-in/telegram-code";
|
||||||
import {TelegramCode} from "./pages/auth/sign-in/telegram-code";
|
import {MenuPage} from "../../app/pages/home/MenuPage";
|
||||||
import {IngredientsPage} from "./pages/ingredients/IngredientsPage";
|
|
||||||
import {MenuPage} from "./pages/cocktails/MenuPage";
|
|
||||||
import {EditIngredientPage} from "./pages/ingredients/EditIngredientPage";
|
|
||||||
import {EditCocktailPage} from "./pages/cocktails/EditCocktailPage";
|
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {BarChangePage} from "./pages/BarChangePage";
|
import ChampionshipsPage from "../../app/pages/championship/ChampionshipsPage";
|
||||||
import {CalcPage} from "./pages/calc/CalcPage";
|
import CalendarPage from "../../app/pages/calendar/CalendarPage";
|
||||||
|
import StagesPage from "../../app/pages/stages/StagesPage";
|
||||||
|
import ChampionshipPage from "../../app/pages/championship/ChampionshipPage";
|
||||||
|
import StagePage from "../../app/pages/stages/StagePage";
|
||||||
|
|
||||||
export function NavigationRoutes() {
|
export function NavigationRoutes() {
|
||||||
const {auth} = useAuth();
|
const {auth} = useAuth();
|
||||||
@@ -51,9 +50,10 @@ function ElementProvider({isPrivate, children}) {
|
|||||||
|
|
||||||
const authPages = [
|
const authPages = [
|
||||||
{
|
{
|
||||||
children: (<HomeRedirect auth={true}/>),
|
|
||||||
isPrivate: false,
|
|
||||||
path: paths.home,
|
path: paths.home,
|
||||||
|
isPrivate: true,
|
||||||
|
children: (<MenuPage/>),
|
||||||
|
exact: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: paths.auth.signIn,
|
path: paths.auth.signIn,
|
||||||
@@ -61,37 +61,29 @@ const authPages = [
|
|||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: paths.bar.calc,
|
path: paths.chp.championships,
|
||||||
children: (<CalcPage/>),
|
isPrivate: true,
|
||||||
isPrivate: true,
|
children: (<ChampionshipsPage/>),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: paths.dashboard.overview,
|
path: paths.chp.championship,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
children: (<MenuPage/>),
|
children: (<ChampionshipPage/>),
|
||||||
exact: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: paths.bar.list,
|
path: paths.calendar,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
children: (<BarChangePage/>),
|
children: (<CalendarPage/>)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: paths.bar.ingredients,
|
path: paths.stg.stages,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
children: (<IngredientsPage/>)
|
children: (<StagesPage/>)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: paths.bar.ingredientEdit,
|
path: paths.stg.stage,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
forAdmin: true,
|
children: (<StagePage/>)
|
||||||
children: (<EditIngredientPage/>)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: paths.bar.cocktailEdit,
|
|
||||||
isPrivate: true,
|
|
||||||
forAdmin: true,
|
|
||||||
children: (<EditCocktailPage/>)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: paths.notFound,
|
path: paths.notFound,
|
||||||
@@ -102,16 +94,11 @@ const authPages = [
|
|||||||
|
|
||||||
const guestPages = [
|
const guestPages = [
|
||||||
{
|
{
|
||||||
path: paths.dashboard.overview,
|
path: paths.home,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
children: (<MenuPage/>),
|
children: (<MenuPage/>),
|
||||||
exact: true,
|
exact: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
children: (<HomeRedirect auth={true}/>),
|
|
||||||
isPrivate: false,
|
|
||||||
path: paths.home,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: paths.auth.tg,
|
path: paths.auth.tg,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
Reference in New Issue
Block a user