admin panel done
This commit is contained in:
commit
4726582b89
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
19751
package-lock.json
generated
Normal file
19751
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
package.json
Normal file
46
package.json
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "bazar3-adminpanel",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
|
"@testing-library/react": "^13.4.0",
|
||||||
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"axios": "^1.4.0",
|
||||||
|
"bootstrap": "^5.3.0",
|
||||||
|
"framer-motion": "^10.18.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-bootstrap": "^2.8.0",
|
||||||
|
"react-datepicker": "^4.16.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-icons": "^4.10.1",
|
||||||
|
"react-router-dom": "^6.14.1",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"react-toastify": "^9.1.3",
|
||||||
|
"web-vitals": "^2.1.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
20
public/index.html
Normal file
20
public/index.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Kings Admin Panel - Manage teams and results"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<title>Kings Admin Panel</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
public/manifest.json
Normal file
25
public/manifest.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"short_name": "Kings Admin",
|
||||||
|
"name": "Kings Admin Panel",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
48
src/App.js
Normal file
48
src/App.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import Teams from './pages/Teams';
|
||||||
|
import Results from './pages/Results';
|
||||||
|
import PrivateRoute from './utils/PrivateRoute';
|
||||||
|
import Layout from './components/Layout/Layout';
|
||||||
|
import AnimatedRoute from './components/AnimatedRoute';
|
||||||
|
import { AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={
|
||||||
|
<AnimatedRoute>
|
||||||
|
<Login />
|
||||||
|
</AnimatedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="/" element={<PrivateRoute />}>
|
||||||
|
<Route path="/" element={<Layout />}>
|
||||||
|
<Route index element={
|
||||||
|
<AnimatedRoute>
|
||||||
|
<Dashboard />
|
||||||
|
</AnimatedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="teams" element={
|
||||||
|
<AnimatedRoute>
|
||||||
|
<Teams />
|
||||||
|
</AnimatedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="results" element={
|
||||||
|
<AnimatedRoute>
|
||||||
|
<Results />
|
||||||
|
</AnimatedRoute>
|
||||||
|
} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
0
src/components/AnimatedCard.js
Normal file
0
src/components/AnimatedCard.js
Normal file
41
src/components/AnimatedRoute.js
Normal file
41
src/components/AnimatedRoute.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
// Variants for page transitions
|
||||||
|
const pageVariants = {
|
||||||
|
initial: {
|
||||||
|
opacity: 0,
|
||||||
|
y: 20
|
||||||
|
},
|
||||||
|
in: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0
|
||||||
|
},
|
||||||
|
out: {
|
||||||
|
opacity: 0,
|
||||||
|
y: -20
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transition timing
|
||||||
|
const pageTransition = {
|
||||||
|
type: 'tween',
|
||||||
|
ease: 'anticipate',
|
||||||
|
duration: 0.4
|
||||||
|
};
|
||||||
|
|
||||||
|
const AnimatedRoute = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial="initial"
|
||||||
|
animate="in"
|
||||||
|
exit="out"
|
||||||
|
variants={pageVariants}
|
||||||
|
transition={pageTransition}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AnimatedRoute;
|
||||||
0
src/components/AnimatedTable.js
Normal file
0
src/components/AnimatedTable.js
Normal file
0
src/components/FadeInComponent.js
Normal file
0
src/components/FadeInComponent.js
Normal file
25
src/components/Layout/Layout.js
Normal file
25
src/components/Layout/Layout.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { Container, Row, Col } from 'react-bootstrap';
|
||||||
|
import Sidebar from './Sidebar';
|
||||||
|
import Navbar from './Navbar';
|
||||||
|
|
||||||
|
const Layout = () => {
|
||||||
|
return (
|
||||||
|
<div className="admin-layout">
|
||||||
|
<Row className="g-0">
|
||||||
|
<Col md={2} className="sidebar">
|
||||||
|
<Sidebar />
|
||||||
|
</Col>
|
||||||
|
<Col md={10}>
|
||||||
|
<Navbar />
|
||||||
|
<Container fluid className="content-area">
|
||||||
|
<Outlet />
|
||||||
|
</Container>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
27
src/components/Layout/Navbar.js
Normal file
27
src/components/Layout/Navbar.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Navbar, Container, Button } from 'react-bootstrap';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { FiLogOut } from 'react-icons/fi';
|
||||||
|
|
||||||
|
const AdminNavbar = () => {
|
||||||
|
const { logout } = useAuth();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Navbar className="admin-navbar py-2" expand="lg">
|
||||||
|
<Container fluid>
|
||||||
|
<Navbar.Brand>Kings Admin Panel</Navbar.Brand>
|
||||||
|
<div className="ms-auto">
|
||||||
|
<Button variant="outline-secondary" onClick={handleLogout}>
|
||||||
|
<FiLogOut className="me-1" /> Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Navbar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminNavbar;
|
||||||
34
src/components/Layout/Sidebar.js
Normal file
34
src/components/Layout/Sidebar.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { FiHome, FiUsers, FiList } from 'react-icons/fi';
|
||||||
|
|
||||||
|
const Sidebar = () => {
|
||||||
|
return (
|
||||||
|
<div className="sidebar-content">
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<h4 className="text-white">Kings Admin</h4>
|
||||||
|
</div>
|
||||||
|
<div className="sidebar-links">
|
||||||
|
<NavLink to="/" className={({isActive}) =>
|
||||||
|
`sidebar-link ${isActive ? 'active' : ''}`
|
||||||
|
}>
|
||||||
|
<FiHome className="me-2" /> Dashboard
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink to="/teams" className={({isActive}) =>
|
||||||
|
`sidebar-link ${isActive ? 'active' : ''}`
|
||||||
|
}>
|
||||||
|
<FiUsers className="me-2" /> Teams
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink to="/results" className={({isActive}) =>
|
||||||
|
`sidebar-link ${isActive ? 'active' : ''}`
|
||||||
|
}>
|
||||||
|
<FiList className="me-2" /> Results
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
44
src/context/AuthContext.js
Normal file
44
src/context/AuthContext.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import React, { createContext, useState, useContext, useEffect } from 'react';
|
||||||
|
|
||||||
|
const TOKEN_KEY = process.env.REACT_APP_TOKEN_KEY;
|
||||||
|
const AuthContext = createContext();
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext);
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }) => {
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuth = () => {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
setIsAuthenticated(!!token);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = (token) => {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
logout
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{!isLoading && children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
21
src/index.js
Normal file
21
src/index.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||||
|
import './styles.css';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { AuthProvider } from './context/AuthContext';
|
||||||
|
import { ToastContainer } from 'react-toastify';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
<ToastContainer position="top-right" autoClose={3000} />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
117
src/pages/Dashboard.js
Normal file
117
src/pages/Dashboard.js
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, Row, Col, Alert, Spinner } from 'react-bootstrap';
|
||||||
|
import resultsService from '../services/results';
|
||||||
|
import teamsService from '../services/teams';
|
||||||
|
import { formatDateTimeDisplay } from '../utils/dateUtils';
|
||||||
|
|
||||||
|
const Dashboard = () => {
|
||||||
|
const [todayResults, setTodayResults] = useState([]);
|
||||||
|
const [teamCount, setTeamCount] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDashboardData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Get today's results
|
||||||
|
const results = await resultsService.getTodayResults();
|
||||||
|
setTodayResults(results);
|
||||||
|
|
||||||
|
// Get team count
|
||||||
|
const teams = await teamsService.getAll();
|
||||||
|
setTeamCount(teams.length);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching dashboard data:', err);
|
||||||
|
setError('Failed to load dashboard data. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDashboardData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="text-center my-5">
|
||||||
|
<Spinner animation="border" variant="primary" />
|
||||||
|
<p className="mt-2">Loading dashboard data...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard-container">
|
||||||
|
<h2 className="mb-4">Dashboard</h2>
|
||||||
|
|
||||||
|
{error && <Alert variant="danger">{error}</Alert>}
|
||||||
|
|
||||||
|
<Row className="mb-4">
|
||||||
|
<Col md={6}>
|
||||||
|
<Card className="h-100">
|
||||||
|
<Card.Body>
|
||||||
|
<Card.Title>Team Statistics</Card.Title>
|
||||||
|
<div className="d-flex align-items-center justify-content-center" style={{ height: "150px" }}>
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="display-1 text-primary">{teamCount}</h1>
|
||||||
|
<p className="lead">Total Teams</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col md={6}>
|
||||||
|
<Card className="h-100">
|
||||||
|
<Card.Body>
|
||||||
|
<Card.Title>Today's Results</Card.Title>
|
||||||
|
<div className="d-flex align-items-center justify-content-center" style={{ height: "150px" }}>
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="display-1 text-primary">{todayResults.length}</h1>
|
||||||
|
<p className="lead">Results Published Today</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<Card.Title>Today's Results</Card.Title>
|
||||||
|
|
||||||
|
{todayResults.length === 0 ? (
|
||||||
|
<Alert variant="info">No results available for today.</Alert>
|
||||||
|
) : (
|
||||||
|
<Row className="g-3">
|
||||||
|
{todayResults.map((result) => (
|
||||||
|
<Col key={result.id} md={4}>
|
||||||
|
<Card className="result-card">
|
||||||
|
<Card.Body>
|
||||||
|
<h5 className="card-title">{result.team}</h5>
|
||||||
|
<p className="mb-1">
|
||||||
|
<strong>Result:</strong>{' '}
|
||||||
|
{result.visible_result === '-1'
|
||||||
|
? 'Pending'
|
||||||
|
: <span className="text-success fw-bold">{result.visible_result}</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<p className="mb-0 text-muted small">
|
||||||
|
<strong>Time:</strong> {formatDateTimeDisplay(result.result_time)}
|
||||||
|
</p>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
104
src/pages/Login.js
Normal file
104
src/pages/Login.js
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, Form, Button, Alert, Spinner } from 'react-bootstrap';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import authService from '../services/auth';
|
||||||
|
|
||||||
|
const Login = () => {
|
||||||
|
const [accessKey, setAccessKey] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { login, isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
// Redirect if already authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!accessKey.trim() || !password.trim()) {
|
||||||
|
setError('Access key and password are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const { token } = await authService.login(accessKey, password);
|
||||||
|
login(token);
|
||||||
|
|
||||||
|
navigate('/');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to login. Please check your credentials.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-page">
|
||||||
|
<Card className="auth-card">
|
||||||
|
<Card.Header className="bg-primary text-white text-center">
|
||||||
|
<h4>Kings Admin Login</h4>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body className="p-4">
|
||||||
|
{error && <Alert variant="danger">{error}</Alert>}
|
||||||
|
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Access Key</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter your access key"
|
||||||
|
value={accessKey}
|
||||||
|
onChange={(e) => setAccessKey(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group className="mb-4">
|
||||||
|
<Form.Label>Password</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
className="w-100"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Spinner
|
||||||
|
as="span"
|
||||||
|
animation="border"
|
||||||
|
size="sm"
|
||||||
|
role="status"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="me-2"
|
||||||
|
/>
|
||||||
|
Logging in...
|
||||||
|
</>
|
||||||
|
) : 'Login'}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
404
src/pages/Results.js
Normal file
404
src/pages/Results.js
Normal file
@ -0,0 +1,404 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Card, Table, Button, Form, Row, Col, Modal, Spinner, Alert } from 'react-bootstrap';
|
||||||
|
import DatePicker from 'react-datepicker';
|
||||||
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
|
import resultsService from '../services/results';
|
||||||
|
import teamsService from '../services/teams';
|
||||||
|
import { FiEdit, FiTrash2, FiPlus, FiFilter } from 'react-icons/fi';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { formatMySQLDateTime, formatDateTimeDisplay } from '../utils/dateUtils';
|
||||||
|
|
||||||
|
const Results = () => {
|
||||||
|
// Main data state
|
||||||
|
const [results, setResults] = useState([]);
|
||||||
|
const [teams, setTeams] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const [selectedTeam, setSelectedTeam] = useState('');
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
team: '',
|
||||||
|
result: '',
|
||||||
|
result_time: new Date()
|
||||||
|
});
|
||||||
|
const [editingResultId, setEditingResultId] = useState(null);
|
||||||
|
const [formLoading, setFormLoading] = useState(false);
|
||||||
|
|
||||||
|
// Delete confirmation
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
const [deletingResultId, setDeletingResultId] = useState(null);
|
||||||
|
|
||||||
|
const fetchTeams = async () => {
|
||||||
|
try {
|
||||||
|
const data = await teamsService.getAll();
|
||||||
|
setTeams(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching teams:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchResults = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
let data;
|
||||||
|
|
||||||
|
if (selectedTeam) {
|
||||||
|
data = await resultsService.getByTeam(selectedTeam);
|
||||||
|
} else {
|
||||||
|
data = await resultsService.getTodayResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
setResults(data);
|
||||||
|
setError('');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching results:', err);
|
||||||
|
setError('Failed to load results. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initData = async () => {
|
||||||
|
await fetchTeams();
|
||||||
|
await fetchResults();
|
||||||
|
};
|
||||||
|
|
||||||
|
initData();
|
||||||
|
}, [selectedTeam]); // Refetch when filter changes
|
||||||
|
|
||||||
|
const handleOpenCreateModal = () => {
|
||||||
|
setEditingResultId(null);
|
||||||
|
setFormData({
|
||||||
|
team: '',
|
||||||
|
result: '',
|
||||||
|
result_time: new Date()
|
||||||
|
});
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEditModal = (result) => {
|
||||||
|
const team = teams.find(t => t.name === result.team || t.name === result.team_name);
|
||||||
|
setEditingResultId(result.id);
|
||||||
|
setFormData({
|
||||||
|
team: team ? team.name : '',
|
||||||
|
result: result.result || result.visible_result,
|
||||||
|
result_time: new Date(result.result_time)
|
||||||
|
});
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenDeleteModal = (result) => {
|
||||||
|
setDeletingResultId(result.id);
|
||||||
|
setShowDeleteModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateChange = (date) => {
|
||||||
|
setFormData(prev => ({ ...prev, result_time: date }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
setFormLoading(true);
|
||||||
|
|
||||||
|
// Format date for MySQL
|
||||||
|
const formattedData = {
|
||||||
|
...formData,
|
||||||
|
result_time: formatMySQLDateTime(formData.result_time)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate the date format
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(formattedData.result_time)) {
|
||||||
|
toast.error('Invalid date format. Should be YYYY-MM-DD HH:MM:SS');
|
||||||
|
setFormLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingResultId) {
|
||||||
|
// Update existing result
|
||||||
|
await resultsService.update(editingResultId, formattedData);
|
||||||
|
toast.success('Result updated successfully');
|
||||||
|
} else {
|
||||||
|
// Create new result
|
||||||
|
await resultsService.publish(formattedData);
|
||||||
|
toast.success('Result created successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset form and refresh results
|
||||||
|
setShowModal(false);
|
||||||
|
setFormData({
|
||||||
|
team: '',
|
||||||
|
result: '',
|
||||||
|
result_time: new Date()
|
||||||
|
});
|
||||||
|
setEditingResultId(null);
|
||||||
|
await fetchResults();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving result:', err);
|
||||||
|
toast.error(err.response?.data?.error || 'Failed to save result');
|
||||||
|
} finally {
|
||||||
|
setFormLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
setFormLoading(true);
|
||||||
|
await resultsService.delete(deletingResultId);
|
||||||
|
|
||||||
|
toast.success('Result deleted successfully');
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
await fetchResults();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting result:', err);
|
||||||
|
toast.error(err.response?.data?.error || 'Failed to delete result');
|
||||||
|
} finally {
|
||||||
|
setFormLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterChange = useCallback((e) => {
|
||||||
|
setSelectedTeam(e.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading && results.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center my-5">
|
||||||
|
<Spinner animation="border" variant="primary" />
|
||||||
|
<p className="mt-2">Loading results...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="results-container">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>Results Management</h2>
|
||||||
|
<Button variant="primary" onClick={handleOpenCreateModal}>
|
||||||
|
<FiPlus className="me-1" /> Add Result
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <Alert variant="danger">{error}</Alert>}
|
||||||
|
|
||||||
|
<Card className="mb-4">
|
||||||
|
<Card.Body>
|
||||||
|
<Form>
|
||||||
|
<Row>
|
||||||
|
<Col md={6}>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label><FiFilter className="me-1" /> Filter by Team</Form.Label>
|
||||||
|
<Form.Select
|
||||||
|
value={selectedTeam}
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
>
|
||||||
|
<option value="">All Teams</option>
|
||||||
|
{teams.map(team => (
|
||||||
|
<option key={team.id} value={team.name}>
|
||||||
|
{team.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Form.Select>
|
||||||
|
</Form.Group>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="results-table">
|
||||||
|
<Card.Body>
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<Alert variant="info">No results found with the current filter.</Alert>
|
||||||
|
) : (
|
||||||
|
<Table responsive hover>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Team</th>
|
||||||
|
<th>Result</th>
|
||||||
|
<th>Result Time</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{results.map((result, index) => (
|
||||||
|
<tr key={result.id}>
|
||||||
|
<td>{index + 1}</td>
|
||||||
|
<td>{result.team || result.team_name}</td>
|
||||||
|
<td>
|
||||||
|
{(result.visible_result === '-1' || result.result === '-1') ? (
|
||||||
|
<span className="text-muted">Pending</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-success fw-bold">
|
||||||
|
{result.visible_result || result.result}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{formatDateTimeDisplay(result.result_time)}</td>
|
||||||
|
<td>
|
||||||
|
{new Date(result.result_time) > new Date() ? (
|
||||||
|
<span className="badge bg-warning">Scheduled</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge bg-success">Published</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Button
|
||||||
|
variant="outline-primary"
|
||||||
|
size="sm"
|
||||||
|
className="me-2"
|
||||||
|
onClick={() => handleOpenEditModal(result)}
|
||||||
|
>
|
||||||
|
<FiEdit />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline-danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleOpenDeleteModal(result)}
|
||||||
|
>
|
||||||
|
<FiTrash2 />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Create/Edit Modal */}
|
||||||
|
<Modal show={showModal} onHide={() => setShowModal(false)}>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>{editingResultId ? 'Edit Result' : 'Create New Result'}</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<Modal.Body>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Team</Form.Label>
|
||||||
|
<Form.Select
|
||||||
|
name="team"
|
||||||
|
value={formData.team}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
disabled={formLoading}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select a team</option>
|
||||||
|
{teams.map(team => (
|
||||||
|
<option key={team.id} value={team.name}>
|
||||||
|
{team.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Form.Select>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Result</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="text"
|
||||||
|
name="result"
|
||||||
|
placeholder="Enter result"
|
||||||
|
value={formData.result}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
disabled={formLoading}
|
||||||
|
required
|
||||||
|
pattern="[0-9]+"
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Enter numeric result (e.g., 45).
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Result Time</Form.Label>
|
||||||
|
<DatePicker
|
||||||
|
selected={formData.result_time}
|
||||||
|
onChange={handleDateChange}
|
||||||
|
showTimeSelect
|
||||||
|
timeFormat="HH:mm"
|
||||||
|
timeIntervals={1} // Changed from 15 to 1 to allow any minute
|
||||||
|
timeCaption="Time"
|
||||||
|
dateFormat="yyyy-MM-dd HH:mm:ss" // Updated format to match required MySQL format
|
||||||
|
className="form-control"
|
||||||
|
disabled={formLoading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
Date and time when the result should be published (Format: YYYY-MM-DD HH:MM:SS, Indian Standard Time).
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={() => setShowModal(false)} disabled={formLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" type="submit" disabled={formLoading}>
|
||||||
|
{formLoading ? (
|
||||||
|
<>
|
||||||
|
<Spinner
|
||||||
|
as="span"
|
||||||
|
animation="border"
|
||||||
|
size="sm"
|
||||||
|
role="status"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="me-1"
|
||||||
|
/>
|
||||||
|
{editingResultId ? 'Updating...' : 'Creating...'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
editingResultId ? 'Update Result' : 'Create Result'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
<Modal show={showDeleteModal} onHide={() => setShowDeleteModal(false)}>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>Confirm Delete</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
Are you sure you want to delete this result? This action cannot be undone.
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={() => setShowDeleteModal(false)} disabled={formLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={handleDelete} disabled={formLoading}>
|
||||||
|
{formLoading ? (
|
||||||
|
<>
|
||||||
|
<Spinner
|
||||||
|
as="span"
|
||||||
|
animation="border"
|
||||||
|
size="sm"
|
||||||
|
role="status"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="me-1"
|
||||||
|
/>
|
||||||
|
Deleting...
|
||||||
|
</>
|
||||||
|
) : 'Delete Result'}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Results;
|
||||||
253
src/pages/Teams.js
Normal file
253
src/pages/Teams.js
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, Table, Button, Form, Modal, Spinner, Alert } from 'react-bootstrap';
|
||||||
|
import teamsService from '../services/teams';
|
||||||
|
import { FiEdit, FiTrash2, FiPlus } from 'react-icons/fi';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
const Teams = () => {
|
||||||
|
const [teams, setTeams] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [teamName, setTeamName] = useState('');
|
||||||
|
const [editingTeamId, setEditingTeamId] = useState(null);
|
||||||
|
const [formLoading, setFormLoading] = useState(false);
|
||||||
|
|
||||||
|
// Delete confirmation
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
const [deletingTeamId, setDeletingTeamId] = useState(null);
|
||||||
|
const [deletingTeamName, setDeletingTeamName] = useState('');
|
||||||
|
|
||||||
|
const fetchTeams = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await teamsService.getAll();
|
||||||
|
setTeams(data);
|
||||||
|
setError('');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching teams:', err);
|
||||||
|
setError('Failed to load teams. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTeams();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenCreateModal = () => {
|
||||||
|
setEditingTeamId(null);
|
||||||
|
setTeamName('');
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEditModal = (team) => {
|
||||||
|
setEditingTeamId(team.id);
|
||||||
|
setTeamName(team.name);
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenDeleteModal = (team) => {
|
||||||
|
setDeletingTeamId(team.id);
|
||||||
|
setDeletingTeamName(team.name);
|
||||||
|
setShowDeleteModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!teamName.trim()) {
|
||||||
|
toast.error('Team name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setFormLoading(true);
|
||||||
|
|
||||||
|
if (editingTeamId) {
|
||||||
|
// Update existing team
|
||||||
|
await teamsService.update(editingTeamId, { name: teamName });
|
||||||
|
toast.success('Team updated successfully');
|
||||||
|
} else {
|
||||||
|
// Create new team
|
||||||
|
await teamsService.create({ name: teamName });
|
||||||
|
toast.success('Team created successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset form and refresh teams
|
||||||
|
setShowModal(false);
|
||||||
|
setTeamName('');
|
||||||
|
setEditingTeamId(null);
|
||||||
|
await fetchTeams();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving team:', err);
|
||||||
|
toast.error(err.response?.data?.error || 'Failed to save team');
|
||||||
|
} finally {
|
||||||
|
setFormLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
setFormLoading(true);
|
||||||
|
await teamsService.delete(deletingTeamId);
|
||||||
|
|
||||||
|
toast.success('Team deleted successfully');
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
await fetchTeams();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting team:', err);
|
||||||
|
toast.error(err.response?.data?.error || 'Failed to delete team');
|
||||||
|
} finally {
|
||||||
|
setFormLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="text-center my-5">
|
||||||
|
<Spinner animation="border" variant="primary" />
|
||||||
|
<p className="mt-2">Loading teams...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="teams-container">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>Teams Management</h2>
|
||||||
|
<Button variant="primary" onClick={handleOpenCreateModal}>
|
||||||
|
<FiPlus className="me-1" /> Add Team
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <Alert variant="danger">{error}</Alert>}
|
||||||
|
|
||||||
|
<Card className="teams-table">
|
||||||
|
<Card.Body>
|
||||||
|
{teams.length === 0 ? (
|
||||||
|
<Alert variant="info">No teams found. Create a new team to get started.</Alert>
|
||||||
|
) : (
|
||||||
|
<Table responsive hover>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Team Name</th>
|
||||||
|
<th>Created At</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{teams.map((team, index) => (
|
||||||
|
<tr key={team.id}>
|
||||||
|
<td>{index + 1}</td>
|
||||||
|
<td>{team.name}</td>
|
||||||
|
<td>{new Date(team.created_at).toLocaleString('en-IN', {timeZone: 'Asia/Kolkata'})}</td>
|
||||||
|
<td>
|
||||||
|
<Button
|
||||||
|
variant="outline-primary"
|
||||||
|
size="sm"
|
||||||
|
className="me-2"
|
||||||
|
onClick={() => handleOpenEditModal(team)}
|
||||||
|
>
|
||||||
|
<FiEdit />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline-danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleOpenDeleteModal(team)}
|
||||||
|
>
|
||||||
|
<FiTrash2 />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Create/Edit Modal */}
|
||||||
|
<Modal show={showModal} onHide={() => setShowModal(false)}>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>{editingTeamId ? 'Edit Team' : 'Create New Team'}</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<Modal.Body>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>Team Name</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter team name"
|
||||||
|
value={teamName}
|
||||||
|
onChange={(e) => setTeamName(e.target.value)}
|
||||||
|
disabled={formLoading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={() => setShowModal(false)} disabled={formLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" type="submit" disabled={formLoading}>
|
||||||
|
{formLoading ? (
|
||||||
|
<>
|
||||||
|
<Spinner
|
||||||
|
as="span"
|
||||||
|
animation="border"
|
||||||
|
size="sm"
|
||||||
|
role="status"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="me-1"
|
||||||
|
/>
|
||||||
|
{editingTeamId ? 'Updating...' : 'Creating...'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
editingTeamId ? 'Update Team' : 'Create Team'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
<Modal show={showDeleteModal} onHide={() => setShowDeleteModal(false)}>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>Confirm Delete</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
Are you sure you want to delete the team <strong>{deletingTeamName}</strong>?
|
||||||
|
This action cannot be undone.
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={() => setShowDeleteModal(false)} disabled={formLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={handleDelete} disabled={formLoading}>
|
||||||
|
{formLoading ? (
|
||||||
|
<>
|
||||||
|
<Spinner
|
||||||
|
as="span"
|
||||||
|
animation="border"
|
||||||
|
size="sm"
|
||||||
|
role="status"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="me-1"
|
||||||
|
/>
|
||||||
|
Deleting...
|
||||||
|
</>
|
||||||
|
) : 'Delete Team'}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Teams;
|
||||||
10
src/services/auth.js
Normal file
10
src/services/auth.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import http from '../utils/http';
|
||||||
|
|
||||||
|
const authService = {
|
||||||
|
login: async (accessKey, password) => {
|
||||||
|
const response = await http.post('/admin/login', { accessKey, password });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default authService;
|
||||||
30
src/services/results.js
Normal file
30
src/services/results.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import http from '../utils/http';
|
||||||
|
|
||||||
|
const resultsService = {
|
||||||
|
getByTeam: async (team) => {
|
||||||
|
const response = await http.get(`/admin/results?team=${team}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
publish: async (resultData) => {
|
||||||
|
const response = await http.post('/admin/results', resultData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id, resultData) => {
|
||||||
|
const response = await http.put(`/admin/results/${id}`, resultData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id) => {
|
||||||
|
const response = await http.delete(`/admin/results/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTodayResults: async () => {
|
||||||
|
const response = await http.get('/api/today');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default resultsService;
|
||||||
25
src/services/teams.js
Normal file
25
src/services/teams.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import http from '../utils/http';
|
||||||
|
|
||||||
|
const teamsService = {
|
||||||
|
getAll: async () => {
|
||||||
|
const response = await http.get('/api/teams');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (teamData) => {
|
||||||
|
const response = await http.post('/admin/teams', teamData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id, teamData) => {
|
||||||
|
const response = await http.put(`/admin/teams/${id}`, teamData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id) => {
|
||||||
|
const response = await http.delete(`/admin/teams/${id}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default teamsService;
|
||||||
171
src/styles.css
Normal file
171
src/styles.css
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: #f5f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add a CSS animation for page transitions */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
padding: 20px;
|
||||||
|
animation: fadeIn 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #343a40;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link {
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 10px 15px;
|
||||||
|
display: block;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link:hover, .sidebar-link.active {
|
||||||
|
color: white;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-navbar {
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-page {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-table, .results-table {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button animations */
|
||||||
|
.btn {
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animate form controls */
|
||||||
|
.form-control, .form-select {
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal animations */
|
||||||
|
.modal {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
transform: scale(0.8);
|
||||||
|
opacity: 0;
|
||||||
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.show .modal-content {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table row hover effect */
|
||||||
|
.table tbody tr {
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:hover {
|
||||||
|
background-color: rgba(0, 123, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge animations */
|
||||||
|
.badge {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated loading spinner */
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.1); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-border {
|
||||||
|
animation: spinner-border 0.75s linear infinite, pulse 2s ease infinite !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard stats animation */
|
||||||
|
.display-1.text-primary {
|
||||||
|
transition: all 0.5s ease;
|
||||||
|
animation: fadeIn 0.8s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-1.text-primary:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
text-shadow: 0 0 10px rgba(0, 123, 255, 0.5);
|
||||||
|
}
|
||||||
11
src/utils/PrivateRoute.js
Normal file
11
src/utils/PrivateRoute.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Navigate, Outlet } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
const PrivateRoute = () => {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
return isAuthenticated ? <Outlet /> : <Navigate to="/login" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PrivateRoute;
|
||||||
68
src/utils/dateUtils.js
Normal file
68
src/utils/dateUtils.js
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Date utility functions for the admin panel
|
||||||
|
* All functions consider Indian Standard Time (IST/UTC+5:30)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Format a date to YYYY-MM-DD format
|
||||||
|
export const formatDate = (date) => {
|
||||||
|
const options = { timeZone: 'Asia/Kolkata' };
|
||||||
|
const dateObj = date ? new Date(date) : new Date();
|
||||||
|
const year = dateObj.toLocaleString('en-US', { year: 'numeric', ...options });
|
||||||
|
const month = dateObj.toLocaleString('en-US', { month: '2-digit', ...options });
|
||||||
|
const day = dateObj.toLocaleString('en-US', { day: '2-digit', ...options });
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format date and time to display friendly format
|
||||||
|
export const formatDateTimeDisplay = (dateTimeStr) => {
|
||||||
|
const options = {
|
||||||
|
timeZone: 'Asia/Kolkata',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
};
|
||||||
|
|
||||||
|
const date = new Date(dateTimeStr);
|
||||||
|
return date.toLocaleString('en-US', options);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update format to ensure proper MySQL datetime format (YYYY-MM-DD HH:MM:SS)
|
||||||
|
export const formatMySQLDateTime = (date) => {
|
||||||
|
// Create a date object in IST timezone
|
||||||
|
const dateObj = date ? new Date(date) : new Date();
|
||||||
|
|
||||||
|
// Get the India time string from the date
|
||||||
|
const options = { timeZone: 'Asia/Kolkata' };
|
||||||
|
const indianTime = dateObj.toLocaleString('en-US', options);
|
||||||
|
|
||||||
|
// Parse the Indian time string into a new Date object
|
||||||
|
const istDate = new Date(indianTime);
|
||||||
|
|
||||||
|
// Format with leading zeros
|
||||||
|
const year = istDate.getFullYear();
|
||||||
|
const month = String(istDate.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(istDate.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(istDate.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(istDate.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(istDate.getSeconds()).padStart(2, '0');
|
||||||
|
|
||||||
|
// Return in MySQL format
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get current date in IST
|
||||||
|
export const getCurrentIndianDate = () => {
|
||||||
|
return formatDate();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if a given time is in the future (IST)
|
||||||
|
export const isTimeInFuture = (dateTime) => {
|
||||||
|
const now = new Date();
|
||||||
|
const options = { timeZone: 'Asia/Kolkata' };
|
||||||
|
const nowInIST = new Date(now.toLocaleString('en-US', options));
|
||||||
|
const checkTime = new Date(dateTime);
|
||||||
|
|
||||||
|
return checkTime > nowInIST;
|
||||||
|
};
|
||||||
37
src/utils/http.js
Normal file
37
src/utils/http.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_URL = process.env.REACT_APP_API_URL;
|
||||||
|
const TOKEN_KEY = process.env.REACT_APP_TOKEN_KEY;
|
||||||
|
|
||||||
|
const http = axios.create({
|
||||||
|
baseURL: API_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add token to requests if available
|
||||||
|
http.interceptors.request.use(
|
||||||
|
config => {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
error => Promise.reject(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle 401 responses
|
||||||
|
http.interceptors.response.use(
|
||||||
|
response => response,
|
||||||
|
error => {
|
||||||
|
if (error.response && error.response.status === 401) {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
window.location = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default http;
|
||||||
Loading…
x
Reference in New Issue
Block a user