add admin
This commit is contained in:
52
src/App.tsx
52
src/App.tsx
@ -1,28 +1,64 @@
|
|||||||
import { Routes, Route } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import MainPage from './pages/MainPage.tsx'
|
import MainPage from './pages/MainPage.tsx'
|
||||||
import CarPage from './pages/CarPage.tsx'
|
import CarPage from './pages/CarPage.tsx'
|
||||||
import Header from './components/Header.tsx'
|
import Header from './components/Header.tsx'
|
||||||
|
import AdminHeader from './components/AdminHeader.tsx'
|
||||||
|
import LoginPage from './pages/admin/LoginPage.tsx'
|
||||||
|
import AdminMainPage from './pages/admin/AdminMainPage.tsx'
|
||||||
|
import { AuthProvider, useAuth } from './context/AuthContext.tsx'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
|
// Защищенный маршрут для админ-панели
|
||||||
|
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const { isAuth } = useAuth();
|
||||||
|
|
||||||
|
if (!isAuth) {
|
||||||
|
return <Navigate to="/administrator" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppRoutes = () => {
|
||||||
|
const { isAuth } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
const isAdminLoginPage = location.pathname === "/administrator";
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
{isAdminLoginPage ? null : isAuth ? <AdminHeader /> : <Header />}
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={<MainPage />}
|
||||||
<>
|
|
||||||
<MainPage />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/car/:id"
|
path="/car/:id"
|
||||||
element={<CarPage />}
|
element={<CarPage />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/administrator"
|
||||||
|
element={<LoginPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/administrator/main"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AdminMainPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
113
src/components/AdminHeader.tsx
Normal file
113
src/components/AdminHeader.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Box, Typography, Button, AppBar, Toolbar } from "@mui/material";
|
||||||
|
import logo from "../assets/icon/autobro.png";
|
||||||
|
import { useResponsive } from "../theme/useResponsive";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { scrollToAnchor } from "../utils/scrollUtils";
|
||||||
|
|
||||||
|
const AdminHeader = () => {
|
||||||
|
const { isMobile } = useResponsive();
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
navigate("/administrator", { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScrollToAnchor = (anchor: string) => {
|
||||||
|
scrollToAnchor(anchor, () => {}, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppBar position="fixed" sx={{ bgcolor: "#2D2D2D" }}>
|
||||||
|
<Toolbar sx={{ justifyContent: "space-between", height: "5vw", userSelect: "none" }}>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={logo}
|
||||||
|
alt="logo"
|
||||||
|
onClick={() => navigate("/administrator/main")}
|
||||||
|
sx={{
|
||||||
|
width: isMobile ? "17vw" : "9vw",
|
||||||
|
height: isMobile ? "6vw" : "2.8vw",
|
||||||
|
cursor: "pointer",
|
||||||
|
"&:hover": { scale: 1.1 },
|
||||||
|
transition: "all 0.4s ease",
|
||||||
|
marginRight: "2vw",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isMobile && (
|
||||||
|
<Box sx={{ display: "flex", gap: "1vw"}}>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleScrollToAnchor("#vehicles")}
|
||||||
|
sx={{
|
||||||
|
color: "white",
|
||||||
|
fontFamily: "Unbounded",
|
||||||
|
textTransform: "none",
|
||||||
|
fontSize: isMobile ? "3vw" : "1.2vw",
|
||||||
|
"&:hover": { color: "#C27664" }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Автомобили
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleScrollToAnchor("#personal")}
|
||||||
|
sx={{
|
||||||
|
color: "white",
|
||||||
|
fontFamily: "Unbounded",
|
||||||
|
textTransform: "none",
|
||||||
|
fontSize: isMobile ? "3vw" : "1.2vw",
|
||||||
|
"&:hover": { color: "#C27664" }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Персонал
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", gap: "2vw" }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: "Unbounded",
|
||||||
|
fontSize: isMobile ? "3vw" : "1.2vw",
|
||||||
|
color: "white",
|
||||||
|
alignSelf: "center"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Панель администратора
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleLogout}
|
||||||
|
sx={{
|
||||||
|
bgcolor: "#C27664",
|
||||||
|
color: "white",
|
||||||
|
fontSize: isMobile ? "2.5vw" : "1vw",
|
||||||
|
padding: isMobile ? "1.5vw 3vw" : "0.5vw 2vw",
|
||||||
|
textTransform: "none",
|
||||||
|
borderRadius: isMobile ? "3vw" : "1vw",
|
||||||
|
fontWeight: "light",
|
||||||
|
fontFamily: "Unbounded",
|
||||||
|
"&:hover": { bgcolor: "#945B4D" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Выйти
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
|
||||||
|
{/* Пустой блок для компенсации фиксированного хедера */}
|
||||||
|
<Box sx={{ height: isMobile ? "15vw" : "5vw", bgcolor: "#2D2D2D" }} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminHeader;
|
67
src/context/AuthContext.tsx
Normal file
67
src/context/AuthContext.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { checkAuth } from '../utils/api';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
isAuth: boolean;
|
||||||
|
token: string | null;
|
||||||
|
login: (token: string) => void;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [isAuth, setIsAuth] = useState<boolean>(false);
|
||||||
|
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const verifyToken = async () => {
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const isActive = await checkAuth(token);
|
||||||
|
setIsAuth(isActive);
|
||||||
|
if (!isActive) {
|
||||||
|
// Если токен недействителен, удаляем его
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
setToken(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка проверки токена:', error);
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
setToken(null);
|
||||||
|
setIsAuth(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsAuth(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
verifyToken();
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const login = (token: string) => {
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
setToken(token);
|
||||||
|
setIsAuth(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
setToken(null);
|
||||||
|
setIsAuth(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ isAuth, token, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = (): AuthContextType => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth должен использоваться внутри AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
@ -5,5 +5,5 @@
|
|||||||
body {
|
body {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-color: #505050;
|
background-color: white;
|
||||||
}
|
}
|
||||||
|
50
src/pages/admin/AdminMainPage.tsx
Normal file
50
src/pages/admin/AdminMainPage.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Box, Typography, Container } from '@mui/material';
|
||||||
|
import Vehicle from './Vehicle';
|
||||||
|
import Personal from './Personal';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import Divider from '../../components/Divider';
|
||||||
|
|
||||||
|
const AdminMainPage = () => {
|
||||||
|
const isAuth = localStorage.getItem('token') !== null;
|
||||||
|
|
||||||
|
// Перенаправление на страницу логина, если пользователь не авторизован
|
||||||
|
if (!isAuth) {
|
||||||
|
return <Navigate to="/administrator" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ width: '100%', color: 'black', minHeight: '100vh' }}>
|
||||||
|
<Container maxWidth="lg" sx={{ pt: "1vw" }}>
|
||||||
|
<Box id="vehicles">
|
||||||
|
<Typography
|
||||||
|
variant="h5"
|
||||||
|
component="h2"
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'Unbounded',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
</Typography>
|
||||||
|
<Vehicle />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider marginBottomDivider='1vw' marginTopDivider='5vw' />
|
||||||
|
|
||||||
|
<Box id="personal">
|
||||||
|
<Typography
|
||||||
|
variant="h5"
|
||||||
|
component="h2"
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'Unbounded',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
</Typography>
|
||||||
|
<Personal />
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminMainPage;
|
135
src/pages/admin/LoginPage.tsx
Normal file
135
src/pages/admin/LoginPage.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Box, Typography, TextField, Button, Paper, Alert, CircularProgress } from '@mui/material';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { getAuthToken, getCurrentUser } from '../../utils/api';
|
||||||
|
|
||||||
|
const LoginPage = () => {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { login, isAuth } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Проверяем авторизацию при загрузке страницы
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuth) {
|
||||||
|
navigate('/administrator/main', { replace: true });
|
||||||
|
}
|
||||||
|
}, [isAuth, navigate]);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!username || !password) {
|
||||||
|
setError('Введите имя пользователя и пароль');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Получение токена авторизации
|
||||||
|
const tokenData = await getAuthToken(username, password);
|
||||||
|
const accessToken = tokenData.access_token;
|
||||||
|
|
||||||
|
// Получение данных пользователя и проверка активности
|
||||||
|
const userData = await getCurrentUser(accessToken);
|
||||||
|
|
||||||
|
// Проверка активности пользователя
|
||||||
|
if (!userData.is_active) {
|
||||||
|
throw new Error('Учетная запись не активна');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем токен и перенаправляем пользователя
|
||||||
|
login(accessToken);
|
||||||
|
// Перенаправление произойдет автоматически благодаря useEffect
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Произошла ошибка');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
bgcolor: '#f5f5f5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
p: 4,
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '400px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="h5"
|
||||||
|
sx={{
|
||||||
|
mb: 3,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontFamily: 'Unbounded',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Вход в панель администратора
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Имя пользователя"
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Пароль"
|
||||||
|
type="password"
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
onClick={handleLogin}
|
||||||
|
disabled={loading}
|
||||||
|
sx={{
|
||||||
|
bgcolor: "#C27664",
|
||||||
|
color: "white",
|
||||||
|
textTransform: "none",
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontWeight: "light",
|
||||||
|
fontFamily: "Unbounded",
|
||||||
|
"&:hover": { bgcolor: "#945B4D" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? <CircularProgress size={24} color="inherit" /> : 'Войти'}
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginPage;
|
968
src/pages/admin/Personal.tsx
Normal file
968
src/pages/admin/Personal.tsx
Normal file
@ -0,0 +1,968 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
IconButton,
|
||||||
|
Grid,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardMedia,
|
||||||
|
CardActions,
|
||||||
|
Chip,
|
||||||
|
Divider,
|
||||||
|
InputAdornment,
|
||||||
|
Tooltip,
|
||||||
|
Fade,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Avatar
|
||||||
|
} from '@mui/material';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import ViewListIcon from '@mui/icons-material/ViewList';
|
||||||
|
import ViewModuleIcon from '@mui/icons-material/ViewModule';
|
||||||
|
import PersonIcon from '@mui/icons-material/Person';
|
||||||
|
import BadgeIcon from '@mui/icons-material/Badge';
|
||||||
|
import WorkIcon from '@mui/icons-material/Work';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import {
|
||||||
|
fetchTeam,
|
||||||
|
createTeamMember,
|
||||||
|
updateTeamMember,
|
||||||
|
deleteTeamMember,
|
||||||
|
getImageUrl
|
||||||
|
} from '../../utils/api';
|
||||||
|
|
||||||
|
// Определяем типы здесь, так как они не экспортируются из api.ts
|
||||||
|
interface TeamMember {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
surname: string;
|
||||||
|
role: string;
|
||||||
|
photo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamResponse {
|
||||||
|
staff: TeamMember[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Personal = () => {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const [staff, setStaff] = useState<TeamMember[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [totalStaff, setTotalStaff] = useState(0);
|
||||||
|
const [viewMode, setViewMode] = useState<'list' | 'grid'>('grid');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// Состояния для модальных окон
|
||||||
|
const [openCreateDialog, setOpenCreateDialog] = useState(false);
|
||||||
|
const [openEditDialog, setOpenEditDialog] = useState(false);
|
||||||
|
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
|
||||||
|
|
||||||
|
// Состояние для выбранного сотрудника
|
||||||
|
const [selectedMember, setSelectedMember] = useState<TeamMember | null>(null);
|
||||||
|
|
||||||
|
// Состояния для формы
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
surname: '',
|
||||||
|
role: '',
|
||||||
|
});
|
||||||
|
const [photoFile, setPhotoFile] = useState<File | null>(null);
|
||||||
|
const [photoPreview, setPhotoPreview] = useState('');
|
||||||
|
|
||||||
|
// Загрузка списка сотрудников
|
||||||
|
const loadStaff = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data: TeamResponse = await fetchTeam();
|
||||||
|
setStaff(data.staff);
|
||||||
|
setTotalStaff(data.total);
|
||||||
|
setError('');
|
||||||
|
} catch (err) {
|
||||||
|
setError('Не удалось загрузить список сотрудников');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStaff();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Фильтрация сотрудников по поисковому запросу
|
||||||
|
const filteredStaff = staff.filter(member =>
|
||||||
|
member.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
member.surname.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
member.role.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Обработчики для формы
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[name]: value
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
setPhotoFile(file);
|
||||||
|
setPhotoPreview(URL.createObjectURL(file));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Открытие диалога создания
|
||||||
|
const handleOpenCreateDialog = () => {
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
surname: '',
|
||||||
|
role: '',
|
||||||
|
});
|
||||||
|
setPhotoFile(null);
|
||||||
|
setPhotoPreview('');
|
||||||
|
setOpenCreateDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Открытие диалога редактирования
|
||||||
|
const handleOpenEditDialog = (member: TeamMember) => {
|
||||||
|
setSelectedMember(member);
|
||||||
|
setFormData({
|
||||||
|
name: member.name,
|
||||||
|
surname: member.surname,
|
||||||
|
role: member.role,
|
||||||
|
});
|
||||||
|
setPhotoPreview(getImageUrl(member.photo));
|
||||||
|
setPhotoFile(null);
|
||||||
|
setOpenEditDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Открытие диалога удаления
|
||||||
|
const handleOpenDeleteDialog = (member: TeamMember) => {
|
||||||
|
setSelectedMember(member);
|
||||||
|
setOpenDeleteDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Закрытие диалогов
|
||||||
|
const handleCloseDialogs = () => {
|
||||||
|
setOpenCreateDialog(false);
|
||||||
|
setOpenEditDialog(false);
|
||||||
|
setOpenDeleteDialog(false);
|
||||||
|
setSelectedMember(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Создание сотрудника
|
||||||
|
const handleCreateMember = async () => {
|
||||||
|
try {
|
||||||
|
const memberData = {
|
||||||
|
name: formData.name,
|
||||||
|
surname: formData.surname,
|
||||||
|
role: formData.role,
|
||||||
|
photo: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
await createTeamMember(memberData, photoFile || undefined);
|
||||||
|
handleCloseDialogs();
|
||||||
|
loadStaff();
|
||||||
|
} catch (err) {
|
||||||
|
setError('Не удалось создать сотрудника');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обновление сотрудника
|
||||||
|
const handleUpdateMember = async () => {
|
||||||
|
if (!selectedMember) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const memberData = {
|
||||||
|
name: formData.name,
|
||||||
|
surname: formData.surname,
|
||||||
|
role: formData.role,
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateTeamMember(selectedMember.id, memberData, photoFile || undefined);
|
||||||
|
handleCloseDialogs();
|
||||||
|
loadStaff();
|
||||||
|
} catch (err) {
|
||||||
|
setError('Не удалось обновить сотрудника');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Удаление сотрудника
|
||||||
|
const handleDeleteMember = async () => {
|
||||||
|
if (!selectedMember) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteTeamMember(selectedMember.id);
|
||||||
|
handleCloseDialogs();
|
||||||
|
loadStaff();
|
||||||
|
} catch (err) {
|
||||||
|
setError('Не удалось удалить сотрудника');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Валидация формы
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name.trim() !== '' &&
|
||||||
|
formData.surname.trim() !== '' &&
|
||||||
|
formData.role.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для получения инициалов из имени и фамилии
|
||||||
|
const getInitials = (name: string, surname: string) => {
|
||||||
|
return `${name.charAt(0)}${surname.charAt(0)}`.toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Рендер таблицы
|
||||||
|
const renderTable = () => (
|
||||||
|
<TableContainer
|
||||||
|
component={Paper}
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table>
|
||||||
|
<TableHead sx={{ bgcolor: '#2D2D2D' }}>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ color: 'white', fontWeight: 'bold' }}>ID</TableCell>
|
||||||
|
<TableCell sx={{ color: 'white', fontWeight: 'bold' }}>Фото</TableCell>
|
||||||
|
<TableCell sx={{ color: 'white', fontWeight: 'bold' }}>Имя</TableCell>
|
||||||
|
<TableCell sx={{ color: 'white', fontWeight: 'bold' }}>Фамилия</TableCell>
|
||||||
|
<TableCell sx={{ color: 'white', fontWeight: 'bold' }}>Должность</TableCell>
|
||||||
|
<TableCell sx={{ color: 'white', fontWeight: 'bold' }}>Действия</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{filteredStaff.map((member) => (
|
||||||
|
<TableRow
|
||||||
|
key={member.id}
|
||||||
|
sx={{
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: '#f5f5f5'
|
||||||
|
},
|
||||||
|
transition: 'background-color 0.3s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableCell>{member.id}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Avatar
|
||||||
|
src={getImageUrl(member.photo)}
|
||||||
|
alt={`${member.name} ${member.surname}`}
|
||||||
|
sx={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getInitials(member.name, member.surname)}
|
||||||
|
</Avatar>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 'medium' }}>{member.name}</TableCell>
|
||||||
|
<TableCell>{member.surname}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
icon={<WorkIcon />}
|
||||||
|
label={member.role}
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: '#e3f2fd', color: '#1565c0' }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip title="Редактировать" arrow TransitionComponent={Fade} TransitionProps={{ timeout: 600 }}>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => handleOpenEditDialog(member)}
|
||||||
|
color="primary"
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'rgba(25, 118, 210, 0.1)',
|
||||||
|
mr: 1,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'rgba(25, 118, 210, 0.2)',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Удалить" arrow TransitionComponent={Fade} TransitionProps={{ timeout: 600 }}>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => handleOpenDeleteDialog(member)}
|
||||||
|
color="error"
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'rgba(211, 47, 47, 0.1)',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'rgba(211, 47, 47, 0.2)',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Рендер сетки карточек
|
||||||
|
const renderGrid = () => (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{filteredStaff.map((member) => (
|
||||||
|
<Grid item xs={12} sm={6} md={4} lg={3} key={member.id}>
|
||||||
|
<Card
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
borderRadius: 2,
|
||||||
|
transition: 'transform 0.3s, box-shadow 0.3s',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'translateY(-5px)',
|
||||||
|
boxShadow: '0 10px 20px rgba(0,0,0,0.1)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ position: 'relative', pt: '100%', overflow: 'hidden' }}>
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
image={getImageUrl(member.photo)}
|
||||||
|
alt={`${member.name} ${member.surname}`}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<CardContent sx={{ flexGrow: 1, pt: 2 }}>
|
||||||
|
<Typography gutterBottom variant="h6" component="div" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
||||||
|
{member.name} {member.surname}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Chip
|
||||||
|
icon={<WorkIcon />}
|
||||||
|
label={member.role}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: '#e3f2fd',
|
||||||
|
color: '#1565c0',
|
||||||
|
mt: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<CardActions sx={{ justifyContent: 'space-between', p: 1.5 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<EditIcon />}
|
||||||
|
onClick={() => handleOpenEditDialog(member)}
|
||||||
|
sx={{
|
||||||
|
color: '#1976d2',
|
||||||
|
'&:hover': { bgcolor: 'rgba(25, 118, 210, 0.1)' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Изменить
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<DeleteIcon />}
|
||||||
|
onClick={() => handleOpenDeleteDialog(member)}
|
||||||
|
sx={{
|
||||||
|
color: '#d32f2f',
|
||||||
|
'&:hover': { bgcolor: 'rgba(211, 47, 47, 0.1)' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
{/* Заголовок и кнопки управления */}
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
mb: 3,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: 'linear-gradient(to right, #2D2D2D, #3D3D3D)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 2
|
||||||
|
}}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<PersonIcon sx={{ fontSize: 40, color: '#C27664' }} />
|
||||||
|
<Typography variant="h5" sx={{ fontFamily: 'Unbounded', fontWeight: 'bold', color: 'white' }}>
|
||||||
|
Управление персоналом
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleOpenCreateDialog}
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
sx={{
|
||||||
|
bgcolor: "#C27664",
|
||||||
|
color: "white",
|
||||||
|
textTransform: "none",
|
||||||
|
fontFamily: "Unbounded",
|
||||||
|
borderRadius: 2,
|
||||||
|
px: 3,
|
||||||
|
"&:hover": { bgcolor: "#945B4D" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Добавить сотрудника
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Панель поиска и переключения вида */}
|
||||||
|
<Paper
|
||||||
|
elevation={2}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
mb: 3,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: 2,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 2
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
placeholder="Поиск сотрудников..."
|
||||||
|
variant="outlined"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
minWidth: 300,
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
borderRadius: 2
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mr: 2 }}>
|
||||||
|
Всего: {totalStaff} сотрудников
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={viewMode}
|
||||||
|
onChange={(_, newValue) => setViewMode(newValue)}
|
||||||
|
sx={{
|
||||||
|
'& .MuiTab-root': { minWidth: 'auto' },
|
||||||
|
'& .Mui-selected': { color: '#C27664 !important' },
|
||||||
|
'& .MuiTabs-indicator': { backgroundColor: '#C27664' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
icon={<ViewListIcon />}
|
||||||
|
value="list"
|
||||||
|
sx={{ minWidth: 'auto' }}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
icon={<ViewModuleIcon />}
|
||||||
|
value="grid"
|
||||||
|
sx={{ minWidth: 'auto' }}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Сообщения об ошибках */}
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2, borderRadius: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Индикатор загрузки */}
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: 400
|
||||||
|
}}>
|
||||||
|
<CircularProgress sx={{ color: '#C27664' }} />
|
||||||
|
</Box>
|
||||||
|
) : filteredStaff.length === 0 ? (
|
||||||
|
<Paper
|
||||||
|
elevation={2}
|
||||||
|
sx={{
|
||||||
|
p: 4,
|
||||||
|
textAlign: 'center',
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: '#f9f9f9'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
Сотрудники не найдены
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
Попробуйте изменить параметры поиска или добавьте нового сотрудника
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
// Отображение списка сотрудников в зависимости от выбранного режима
|
||||||
|
viewMode === 'list' ? renderTable() : renderGrid()
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Диалог создания сотрудника */}
|
||||||
|
<Dialog
|
||||||
|
open={openCreateDialog}
|
||||||
|
onClose={handleCloseDialogs}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: { borderRadius: 2 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{
|
||||||
|
fontFamily: 'Unbounded',
|
||||||
|
bgcolor: '#2D2D2D',
|
||||||
|
color: 'white',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1
|
||||||
|
}}>
|
||||||
|
<AddIcon /> Добавить сотрудника
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent sx={{ mt: 2 }}>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<TextField
|
||||||
|
name="name"
|
||||||
|
label="Имя"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<PersonIcon color="action" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
name="surname"
|
||||||
|
label="Фамилия"
|
||||||
|
value={formData.surname}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<BadgeIcon color="action" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
name="role"
|
||||||
|
label="Должность"
|
||||||
|
value={formData.role}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<WorkIcon color="action" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
component="label"
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
borderRadius: 2,
|
||||||
|
p: 1.5,
|
||||||
|
borderColor: '#C27664',
|
||||||
|
color: '#C27664',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: '#945B4D',
|
||||||
|
bgcolor: 'rgba(194, 118, 100, 0.1)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Загрузить фото
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handlePhotoChange}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Paper
|
||||||
|
elevation={2}
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: '#f5f5f5'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{photoPreview ? (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={photoPreview}
|
||||||
|
alt="Предпросмотр"
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
p: 2
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography color="text.secondary" sx={{ p: 4, textAlign: 'center' }}>
|
||||||
|
Предпросмотр фото
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ p: 2, bgcolor: '#f5f5f5' }}>
|
||||||
|
<Button
|
||||||
|
onClick={handleCloseDialogs}
|
||||||
|
sx={{
|
||||||
|
color: '#666',
|
||||||
|
borderRadius: 2,
|
||||||
|
px: 3
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateMember}
|
||||||
|
disabled={!isFormValid()}
|
||||||
|
variant="contained"
|
||||||
|
sx={{
|
||||||
|
bgcolor: "#C27664",
|
||||||
|
color: "white",
|
||||||
|
borderRadius: 2,
|
||||||
|
px: 3,
|
||||||
|
"&:hover": { bgcolor: "#945B4D" },
|
||||||
|
"&.Mui-disabled": {
|
||||||
|
bgcolor: "rgba(194, 118, 100, 0.5)",
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Создать
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Диалог редактирования сотрудника */}
|
||||||
|
<Dialog
|
||||||
|
open={openEditDialog}
|
||||||
|
onClose={handleCloseDialogs}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: { borderRadius: 2 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{
|
||||||
|
fontFamily: 'Unbounded',
|
||||||
|
bgcolor: '#2D2D2D',
|
||||||
|
color: 'white',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1
|
||||||
|
}}>
|
||||||
|
<EditIcon /> Редактировать сотрудника
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent sx={{ mt: 2 }}>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<TextField
|
||||||
|
name="name"
|
||||||
|
label="Имя"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<PersonIcon color="action" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
name="surname"
|
||||||
|
label="Фамилия"
|
||||||
|
value={formData.surname}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<BadgeIcon color="action" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
name="role"
|
||||||
|
label="Должность"
|
||||||
|
value={formData.role}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<WorkIcon color="action" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
component="label"
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
borderRadius: 2,
|
||||||
|
p: 1.5,
|
||||||
|
borderColor: '#C27664',
|
||||||
|
color: '#C27664',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: '#945B4D',
|
||||||
|
bgcolor: 'rgba(194, 118, 100, 0.1)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Изменить фото
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handlePhotoChange}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Paper
|
||||||
|
elevation={2}
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: '#f5f5f5'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{photoPreview ? (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={photoPreview}
|
||||||
|
alt="Предпросмотр"
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
p: 2
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography color="text.secondary" sx={{ p: 4, textAlign: 'center' }}>
|
||||||
|
Предпросмотр фото
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ p: 2, bgcolor: '#f5f5f5' }}>
|
||||||
|
<Button
|
||||||
|
onClick={handleCloseDialogs}
|
||||||
|
sx={{
|
||||||
|
color: '#666',
|
||||||
|
borderRadius: 2,
|
||||||
|
px: 3
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleUpdateMember}
|
||||||
|
disabled={!isFormValid()}
|
||||||
|
variant="contained"
|
||||||
|
sx={{
|
||||||
|
bgcolor: "#C27664",
|
||||||
|
color: "white",
|
||||||
|
borderRadius: 2,
|
||||||
|
px: 3,
|
||||||
|
"&:hover": { bgcolor: "#945B4D" },
|
||||||
|
"&.Mui-disabled": {
|
||||||
|
bgcolor: "rgba(194, 118, 100, 0.5)",
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Диалог удаления сотрудника */}
|
||||||
|
<Dialog
|
||||||
|
open={openDeleteDialog}
|
||||||
|
onClose={handleCloseDialogs}
|
||||||
|
PaperProps={{
|
||||||
|
sx: { borderRadius: 2 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{
|
||||||
|
fontFamily: 'Unbounded',
|
||||||
|
bgcolor: '#2D2D2D',
|
||||||
|
color: 'white',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1
|
||||||
|
}}>
|
||||||
|
<DeleteIcon /> Удалить сотрудника
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent sx={{ mt: 2, minWidth: 400 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
{selectedMember && (
|
||||||
|
<Avatar
|
||||||
|
src={getImageUrl(selectedMember.photo)}
|
||||||
|
alt={`${selectedMember.name} ${selectedMember.surname}`}
|
||||||
|
sx={{
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
mr: 2
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedMember && getInitials(selectedMember.name, selectedMember.surname)}
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||||
|
{selectedMember?.name} {selectedMember?.surname}
|
||||||
|
</Typography>
|
||||||
|
{selectedMember && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{selectedMember.role}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||||
|
Вы уверены, что хотите удалить этого сотрудника? Это действие нельзя отменить.
|
||||||
|
</Alert>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ p: 2, bgcolor: '#f5f5f5' }}>
|
||||||
|
<Button
|
||||||
|
onClick={handleCloseDialogs}
|
||||||
|
sx={{
|
||||||
|
color: '#666',
|
||||||
|
borderRadius: 2,
|
||||||
|
px: 3
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleDeleteMember}
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
px: 3
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Personal;
|
1415
src/pages/admin/Vehicle.tsx
Normal file
1415
src/pages/admin/Vehicle.tsx
Normal file
File diff suppressed because it is too large
Load Diff
101
src/utils/api.ts
101
src/utils/api.ts
@ -7,6 +7,15 @@ export interface Car {
|
|||||||
year: number;
|
year: number;
|
||||||
mileage: number;
|
mileage: number;
|
||||||
price: number;
|
price: number;
|
||||||
|
base_price: number;
|
||||||
|
country_of_origin: string;
|
||||||
|
drive_type: string;
|
||||||
|
engine_type: string;
|
||||||
|
engine_capacity?: number;
|
||||||
|
engine_power?: number;
|
||||||
|
electric_motor_power?: number;
|
||||||
|
hybrid_type?: string;
|
||||||
|
power_ratio?: string;
|
||||||
image: string;
|
image: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,6 +93,9 @@ export const createCar = async (carData: Omit<Car, 'id'>, image?: File): Promise
|
|||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/cars`, {
|
const response = await fetch(`${API_BASE_URL}/cars`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -112,6 +124,9 @@ export const updateCar = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/cars/${carId}`, {
|
const response = await fetch(`${API_BASE_URL}/cars/${carId}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@ -130,6 +145,9 @@ export const updateCar = async (
|
|||||||
export const deleteCar = async (carId: number): Promise<void> => {
|
export const deleteCar = async (carId: number): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/cars/${carId}`, {
|
const response = await fetch(`${API_BASE_URL}/cars/${carId}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -189,6 +207,9 @@ export const createTeamMember = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/personal`, {
|
const response = await fetch(`${API_BASE_URL}/personal`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@ -218,6 +239,9 @@ export const updateTeamMember = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/personal/${memberId}`, {
|
const response = await fetch(`${API_BASE_URL}/personal/${memberId}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@ -236,6 +260,9 @@ export const updateTeamMember = async (
|
|||||||
export const deleteTeamMember = async (memberId: number): Promise<void> => {
|
export const deleteTeamMember = async (memberId: number): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/personal/${memberId}`, {
|
const response = await fetch(`${API_BASE_URL}/personal/${memberId}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -246,3 +273,77 @@ export const deleteTeamMember = async (memberId: number): Promise<void> => {
|
|||||||
return handleApiError(error);
|
return handleApiError(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// LOGIN
|
||||||
|
|
||||||
|
// Типы данных для авторизации
|
||||||
|
export interface TokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserData {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение токена авторизации
|
||||||
|
export const getAuthToken = async (username: string, password: string): Promise<TokenResponse> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Неверное имя пользователя или пароль');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
localStorage.setItem('token', data.access_token);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Получение данных текущего пользователя
|
||||||
|
export const getCurrentUser = async (token: string): Promise<UserData> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/users/me`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Не удалось получить данные пользователя');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Проверка авторизации пользователя
|
||||||
|
export const checkAuth = async (token: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const userData = await getCurrentUser(token);
|
||||||
|
return userData.is_active;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Authentication check failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Reference in New Issue
Block a user