add admin

This commit is contained in:
aurinex
2025-07-14 00:21:15 +05:00
parent 2bc57b7d0b
commit 4a5eba9826
9 changed files with 2894 additions and 9 deletions

View File

@ -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

View 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;

View 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;
};

View File

@ -5,5 +5,5 @@
body { body {
padding: 0; padding: 0;
margin: 0; margin: 0;
background-color: #505050; background-color: white;
} }

View 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;

View 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;

View 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

File diff suppressed because it is too large Load Diff

View File

@ -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;
}
};