first commit

This commit is contained in:
itqop 2025-02-11 01:24:18 +03:00
parent b4778e436a
commit 112346ca10
28 changed files with 2281 additions and 0 deletions

33
.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
# Зависимости
/node_modules
/.pnp
.pnp.js
# Тестирование
/coverage
# Сборка
/dist
/build
# Env файлы
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Логи
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# IDE
.idea
.vscode
*.swp
*.swo
# OS
.DS_Store
Thumbs.db

12
Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

21
docker-compose.yml Normal file
View File

@ -0,0 +1,21 @@
version: '3.8'
services:
frontend:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
environment:
- NODE_ENV=development
- VITE_API_URL=http://localhost:4000
networks:
- warehouse-network
networks:
warehouse-network:
name: warehouse-network

16
index.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet" />
<title>Warehouse Management</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "warehouse-management-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"@ant-design/icons": "^5.2.6",
"antd": "^5.11.1",
"axios": "^1.6.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.28.2"
},
"devDependencies": {
"@types/node": "^20.9.0",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@vitejs/plugin-react": "^4.1.1",
"typescript": "^5.2.2",
"vite": "^4.5.0",
"vite-plugin-utils": "^0.4.5"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
}
}

8
public/favicon.svg Normal file
View File

@ -0,0 +1,8 @@
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path
d="M16 2L30 28H2L16 2Z"
fill="#4CAF50"
stroke="#388E3C"
stroke-width="2"
/>
</svg>

After

Width:  |  Height:  |  Size: 195 B

24
src/App.tsx Normal file
View File

@ -0,0 +1,24 @@
import React from 'react';
import { RouterProvider } from 'react-router-dom';
import { ConfigProvider, App as AntdApp } from 'antd';
import ruRU from 'antd/locale/ru_RU';
import router from './router';
const App: React.FC = () => {
return (
<ConfigProvider
locale={ruRU}
theme={{
token: {
fontFamily: 'Roboto',
},
}}
>
<AntdApp> {/* Важно: оборачиваем в AntdApp */}
<RouterProvider router={router} future={{ v7_startTransition: true }} />
</AntdApp>
</ConfigProvider>
);
};
export default App;

View File

@ -0,0 +1,152 @@
import React, { useState, useEffect } from 'react';
import { Layout, Menu, MenuProps, theme } from 'antd';
import { Routes, Route, Link, useNavigate } from 'react-router-dom';
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
InboxOutlined,
FileOutlined,
UserOutlined,
SettingOutlined,
LogoutOutlined,
EnvironmentOutlined,
} from '@ant-design/icons';
import CategoryManagement from './admin/CategoryManagement';
import UserManagement from './admin/UserManagement';
import RoleManagement from './admin/RoleManagement';
import ReportGenerator from './reports/ReportGenerator';
import CategoryItems from './inventory/CategoryItems';
import PrivateRoute from './PrivateRoute';
import LocationManagement from './admin/LocationManagement';
const { Header, Sider, Content } = Layout;
const Dashboard: React.FC = () => {
const [collapsed, setCollapsed] = useState(false);
const { token: { colorBgContainer } } = theme.useToken();
const [userRole, setUserRole] = useState<string | null>(null);
const navigate = useNavigate();
// ✅ Получаем роль пользователя через локальное хранилище
useEffect(() => {
const user = localStorage.getItem('user');
const parsedUser = user ? JSON.parse(user) : null;
setUserRole(parsedUser?.role || null);
}, []);
const handleLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
navigate('/login');
};
const menuItems: MenuProps['items'] = [
{
key: '1',
icon: <InboxOutlined />,
label: <Link to="/dashboard/inventory">Инвентарь</Link>,
},
{
key: '2',
icon: <FileOutlined />,
label: <Link to="/dashboard/reports">Отчеты</Link>,
},
];
// ✅ Добавляем "Администрирование", если роль admin
if (userRole === 'admin') {
menuItems.push({
key: 'admin',
icon: <SettingOutlined />,
label: collapsed ? <SettingOutlined /> : 'Управление', // ✅ Фикс стрелки
children: [
{
key: '3',
icon: <UserOutlined />,
label: <Link to="/dashboard/admin/users">Пользователи</Link>,
},
{
key: '4',
icon: <UserOutlined />,
label: <Link to="/dashboard/admin/roles">Роли</Link>,
},
{
key: '5',
icon: <InboxOutlined />,
label: <Link to="/dashboard/admin/categories">Категории</Link>,
},
{
key: '6',
icon: <EnvironmentOutlined />,
label: <Link to="/dashboard/admin/locations">Локации</Link>,
},
],
});
}
// ✅ Добавляем кнопку "Выйти" в `menuItems`, чтобы она скрывалась при сворачивании
menuItems.push({
key: 'logout',
icon: <LogoutOutlined />,
label: <Link to={''} >Выйти</Link>,
onClick: handleLogout, // ✅ Добавляем обработчик выхода
});
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider trigger={null} collapsible collapsed={collapsed}>
<div style={{ height: 32, margin: 16, background: 'rgba(255, 255, 255, 0.2)' }} />
<Menu theme="dark" mode="inline" defaultSelectedKeys={['1']} items={menuItems} />
</Sider>
<Layout>
<Header style={{ padding: 0, background: colorBgContainer }}>
{React.createElement(collapsed ? MenuUnfoldOutlined : MenuFoldOutlined, {
className: 'trigger',
onClick: () => setCollapsed(!collapsed),
style: { fontSize: '18px', padding: '0 24px', cursor: 'pointer' },
})}
</Header>
<Content style={{ margin: '24px 16px', padding: 24, background: colorBgContainer }}>
<Routes>
<Route path="/inventory" element={<CategoryItems categoryId={1} />} />
<Route path="/reports" element={<ReportGenerator />} />
<Route
path="/admin/users"
element={
<PrivateRoute requiredRoles={['admin']}>
<UserManagement />
</PrivateRoute>
}
/>
<Route
path="/admin/roles"
element={
<PrivateRoute requiredRoles={['admin']}>
<RoleManagement />
</PrivateRoute>
}
/>
<Route
path="/admin/categories"
element={
<PrivateRoute requiredRoles={['admin']}>
<CategoryManagement />
</PrivateRoute>
}
/>
<Route
path="/admin/locations"
element={
<PrivateRoute requiredRoles={['admin']}>
<LocationManagement />
</PrivateRoute>
}
/>
</Routes>
</Content>
</Layout>
</Layout>
);
};
export default Dashboard;

View File

@ -0,0 +1,89 @@
import React, { useState, useEffect } from 'react';
import { Table, Input, Button, Space } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
interface Item {
id: number;
name: string;
code: string;
quantity: number;
category: string;
}
const ItemsList: React.FC = () => {
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(false);
const [searchText, setSearchText] = useState('');
useEffect(() => {
fetchItems();
}, []);
const fetchItems = async () => {
setLoading(true);
try {
const response = await fetch('/api/items');
const data = await response.json();
setItems(data);
} catch (error) {
console.error('Ошибка при загрузке товаров:', error);
} finally {
setLoading(false);
}
};
const columns = [
{
title: 'Название',
dataIndex: 'name',
key: 'name',
sorter: (a: Item, b: Item) => a.name.localeCompare(b.name),
},
{
title: 'Код',
dataIndex: 'code',
key: 'code',
},
{
title: 'Количество',
dataIndex: 'quantity',
key: 'quantity',
sorter: (a: Item, b: Item) => a.quantity - b.quantity,
},
{
title: 'Категория',
dataIndex: 'category',
key: 'category',
filters: [
{ text: 'Запчасти', value: 'Запчасти' },
{ text: 'Топливо', value: 'Топливо' },
{ text: 'Масла', value: 'Масла' },
],
},
];
return (
<div className="items-list">
<Space style={{ marginBottom: 16 }}>
<Input
placeholder="Поиск товаров"
prefix={<SearchOutlined />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
/>
<Button type="primary" onClick={() => fetchItems()}>
Обновить
</Button>
</Space>
<Table
columns={columns}
dataSource={items}
loading={loading}
rowKey="id"
pagination={{ pageSize: 10 }}
/>
</div>
);
};
export default ItemsList;

91
src/components/Login.tsx Normal file
View File

@ -0,0 +1,91 @@
import React, { useState } from 'react';
import { Form, Input, Button, App as AntdApp } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
interface LoginForm {
username: string;
password: string;
}
const Login: React.FC = () => {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { message } = AntdApp.useApp();
const onFinish = async (values: LoginForm) => {
setLoading(true);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
});
if (!response.ok) {
throw new Error('Ошибка авторизации');
}
const data = await response.json();
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
message.success('Успешная авторизация');
console.log("Успешная авторизация");
navigate('/dashboard');
} catch (error) {
message.error('Неверное имя пользователя или пароль');
} finally {
setLoading(false);
}
};
return (
<div style={{ maxWidth: 400, margin: '100px auto', padding: '20px' }}>
<h1 style={{ textAlign: 'center', marginBottom: 30 }}>Вход в систему</h1>
<Form
name="login"
onFinish={onFinish}
autoComplete="off"
>
<Form.Item
name="username"
rules={[{ required: true, message: 'Введите имя пользователя' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="Имя пользователя"
size="large"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: 'Введите пароль' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="Пароль"
size="large"
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
size="large"
>
Войти
</Button>
</Form.Item>
</Form>
</div>
);
};
export default Login;

View File

@ -0,0 +1,86 @@
import React, { useEffect, useState } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { Spin } from 'antd';
import { useApi } from '../hooks/useApi';
interface PrivateRouteProps {
children: React.ReactNode;
requiredRoles?: string[]; // Теперь соответствует router.tsx
}
interface User {
id: number;
username: string;
role: string;
}
const PrivateRoute: React.FC<PrivateRouteProps> = ({ children, requiredRoles }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const [user, setUser] = useState<User | null>(null);
const location = useLocation();
const { execute: validateToken } = useApi<{ valid: boolean; user: User }>('/auth/validate', 'POST');
useEffect(() => {
let isMounted = true;
const checkAuth = async () => {
const token = localStorage.getItem('token');
if (!token) {
if (isMounted) setIsAuthenticated(false);
return;
}
try {
const response = await validateToken({});
if (response?.valid) {
if (isMounted) {
setIsAuthenticated(true);
setUser(response.user);
}
} else {
if (isMounted) {
setIsAuthenticated(false);
localStorage.removeItem('token');
}
}
} catch {
if (isMounted) {
setIsAuthenticated(false);
localStorage.removeItem('token');
}
}
};
checkAuth();
return () => {
isMounted = false;
};
}, []);
// Показываем спиннер во время проверки
if (isAuthenticated === null) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" tip="Проверка авторизации...">
<div style={{ height: '1px' }} /> {/* Пустой div как вложенный контент */}
</Spin>
</div>
);
}
// Если не авторизован, редиректим на логин
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
// Проверяем роль, если требуется
if (requiredRoles && user && !requiredRoles.includes(user.role)) {
return <Navigate to="/dashboard" replace />;
}
return <>{children}</>;
};
export default PrivateRoute;

View File

@ -0,0 +1,58 @@
import React from 'react';
import { Layout, Menu } from 'antd';
import { UserOutlined, TeamOutlined, AppstoreOutlined } from '@ant-design/icons';
import { Routes, Route, useNavigate } from 'react-router-dom';
import UserManagement from './UserManagement';
import RoleManagement from './RoleManagement';
import CategoryManagement from './CategoryManagement';
const { Content, Sider } = Layout;
const AdminPanel: React.FC = () => {
const navigate = useNavigate();
const menuItems = [
{
key: 'users',
icon: <UserOutlined />,
label: 'Пользователи',
onClick: () => navigate('/admin/users')
},
{
key: 'roles',
icon: <TeamOutlined />,
label: 'Роли и разрешения',
onClick: () => navigate('/admin/roles')
},
{
key: 'categories',
icon: <AppstoreOutlined />,
label: 'Категории',
onClick: () => navigate('/admin/categories')
}
];
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider width={250} theme="light">
<Menu
mode="inline"
defaultSelectedKeys={['users']}
style={{ height: '100%', borderRight: 0 }}
items={menuItems}
/>
</Sider>
<Layout style={{ padding: '24px' }}>
<Content style={{ background: '#fff', padding: 24, margin: 0, minHeight: 280 }}>
<Routes>
<Route path="users" element={<UserManagement />} />
<Route path="roles" element={<RoleManagement />} />
<Route path="categories" element={<CategoryManagement />} />
</Routes>
</Content>
</Layout>
</Layout>
);
};
export default AdminPanel;

View File

@ -0,0 +1,178 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Modal, Form, Input, Select, Space, App as AntdApp } from 'antd';
import { useGet, usePost, usePut, useDelete } from '../../hooks/useApi';
interface Location {
id: number;
name: string;
}
interface Category {
id: number;
name: string;
location_id: number;
location: Location;
}
const CategoryManagement: React.FC = () => {
const [isModalVisible, setIsModalVisible] = useState(false);
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const { message } = AntdApp.useApp();
// Используем `useGet` для загрузки категорий и локаций
const { data: categories, execute: fetchCategories } = useGet<Category[]>('/admin/categories');
const { data: locations, execute: fetchLocations } = useGet<Location[]>('/admin/locations');
// Используем `usePost`, `usePut` и `useDelete` для работы с категориями
const { execute: createCategory } = usePost('/admin/categories');
const { execute: updateCategory } = usePut(`/admin/categories/${editingCategory?.id}`);
const { execute: deleteCategory } = useDelete(`/admin/categories`);
// Загружаем данные при монтировании компонента
useEffect(() => {
fetchCategories();
fetchLocations();
}, []);
// Отправка формы (создание или обновление категории)
const handleSubmit = async (values: any) => {
setLoading(true);
try {
if (editingCategory) {
await updateCategory(values);
message.success('Категория обновлена');
} else {
await createCategory(values);
message.success('Категория создана');
}
setIsModalVisible(false);
form.resetFields();
setEditingCategory(null);
fetchCategories(); // Обновляем список категорий
} catch {
message.error('Ошибка при сохранении категории');
} finally {
setLoading(false);
}
};
// Открытие модального окна для редактирования категории
const handleEdit = (category: Category) => {
setEditingCategory(category);
form.setFieldsValue({
name: category.name,
location_id: category.location_id
});
setIsModalVisible(true);
};
// Удаление категории
const handleDelete = async (categoryId: number) => {
try {
await deleteCategory(categoryId);
message.success('Категория удалена');
fetchCategories(); // Обновляем список категорий
} catch {
message.error('Ошибка при удалении категории');
}
};
// Определение столбцов таблицы
const columns = [
{
title: 'Название',
dataIndex: 'name',
key: 'name',
},
{
title: 'Локация',
dataIndex: ['location', 'name'],
key: 'location',
},
{
title: 'Действия',
key: 'actions',
render: (_: any, record: Category) => (
<Space>
<Button type="link" onClick={() => handleEdit(record)}>
Редактировать
</Button>
<Button
type="link"
danger
onClick={() => handleDelete(record.id)}
>
Удалить
</Button>
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', gap: 16 }}>
<Button
type="primary"
onClick={() => {
setEditingCategory(null);
form.resetFields();
setIsModalVisible(true);
}}
>
Создать категорию
</Button>
</div>
<Table
columns={columns}
dataSource={categories || []}
rowKey="id"
loading={!categories}
/>
<Modal
title={editingCategory ? 'Редактировать категорию' : 'Создать категорию'}
open={isModalVisible}
onOk={() => form.submit()}
onCancel={() => {
setIsModalVisible(false);
setEditingCategory(null);
form.resetFields();
}}
confirmLoading={loading}
>
<Form
form={form}
onFinish={handleSubmit}
layout="vertical"
>
<Form.Item
name="name"
label="Название"
rules={[{ required: true, message: 'Введите название категории' }]}
>
<Input />
</Form.Item>
<Form.Item
name="location_id"
label="Локация"
rules={[{ required: true, message: 'Выберите локацию' }]}
>
<Select>
{(locations || []).map(location => (
<Select.Option key={location.id} value={location.id}>
{location.name}
</Select.Option>
))}
</Select>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default CategoryManagement;

View File

@ -0,0 +1,152 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Modal, Form, Input, Space, App as AntdApp } from 'antd';
import { useGet, usePost, usePut, useDelete } from '../../hooks/useApi';
interface Location {
id: number;
name: string;
description?: string;
}
const LocationManagement: React.FC = () => {
const [isModalVisible, setIsModalVisible] = useState(false);
const [editingLocation, setEditingLocation] = useState<Location | null>(null);
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const { message } = AntdApp.useApp();
// API хуки
const { data: locations, execute: fetchLocations } = useGet<Location[]>('/admin/locations');
const { execute: createLocation } = usePost('/admin/locations');
const { execute: updateLocation } = usePut(`/admin/locations/${editingLocation?.id}`);
const { execute: deleteLocation } = useDelete(`/admin/locations`);
useEffect(() => {
fetchLocations();
}, []);
const handleSubmit = async (values: any) => {
setLoading(true);
try {
if (editingLocation) {
await updateLocation(values);
message.success('Локация обновлена');
} else {
await createLocation(values);
message.success('Локация создана');
}
setIsModalVisible(false);
form.resetFields();
setEditingLocation(null);
fetchLocations();
} catch {
message.error('Ошибка при сохранении локации');
} finally {
setLoading(false);
}
};
const handleEdit = (location: Location) => {
setEditingLocation(location);
form.setFieldsValue({
name: location.name,
description: location.description
});
setIsModalVisible(true);
};
const handleDelete = async (locationId: number) => {
try {
await deleteLocation(locationId);
message.success('Локация удалена');
fetchLocations();
} catch {
message.error('Ошибка при удалении локации');
}
};
const columns = [
{
title: 'Название',
dataIndex: 'name',
key: 'name',
},
{
title: 'Описание',
dataIndex: 'description',
key: 'description',
},
{
title: 'Действия',
key: 'actions',
render: (_: any, record: Location) => (
<Space>
<Button type="link" onClick={() => handleEdit(record)}>
Редактировать
</Button>
<Button type="link" danger onClick={() => handleDelete(record.id)}>
Удалить
</Button>
</Space>
),
},
];
return (
<div>
<Button
type="primary"
onClick={() => {
setEditingLocation(null);
form.resetFields();
setIsModalVisible(true);
}}
style={{ marginBottom: 16 }}
>
Создать локацию
</Button>
<Table
columns={columns}
dataSource={locations || []}
rowKey="id"
loading={!locations}
/>
<Modal
title={editingLocation ? 'Редактировать локацию' : 'Создать локацию'}
open={isModalVisible}
onOk={() => form.submit()}
onCancel={() => {
setIsModalVisible(false);
setEditingLocation(null);
form.resetFields();
}}
confirmLoading={loading}
>
<Form
form={form}
onFinish={handleSubmit}
layout="vertical"
>
<Form.Item
name="name"
label="Название"
rules={[{ required: true, message: 'Введите название локации' }]}
>
<Input />
</Form.Item>
<Form.Item
name="description"
label="Описание"
>
<Input.TextArea />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default LocationManagement;

View File

@ -0,0 +1,197 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Modal, Form, Input, Checkbox, Space, App as AntdApp } from 'antd';
import { useGet, usePost, usePut, useDelete } from '../../hooks/useApi';
interface Permission {
id: number;
name: string;
description: string;
}
interface Role {
id: number;
name: string;
description: string;
permissions: Permission[];
}
const RoleManagement: React.FC = () => {
const [isModalVisible, setIsModalVisible] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const { message } = AntdApp.useApp();
// Используем `useGet` для загрузки ролей и разрешений
const { data: roles, execute: fetchRoles } = useGet<Role[]>('/admin/roles');
const { data: permissions, execute: fetchPermissions } = useGet<Permission[]>('/admin/permissions');
// Используем `usePost`, `usePut` и `useDelete` для работы с ролями
const { execute: createRole } = usePost('/admin/roles');
const { execute: updateRole } = usePut(`/admin/roles/${editingRole?.id}`);
const { execute: deleteRole } = useDelete(`/admin/roles`);
// Загружаем данные при монтировании компонента
useEffect(() => {
fetchRoles();
fetchPermissions();
}, []);
// Открытие модального окна для редактирования роли
const handleEdit = (role: Role) => {
setEditingRole(role);
form.setFieldsValue({
name: role.name,
description: role.description,
permissions: role.permissions.map(p => p.id),
});
setIsModalVisible(true);
};
// Отправка формы (создание или обновление роли)
const handleSubmit = async (values: any) => {
setLoading(true);
try {
if (editingRole) {
await updateRole(values);
message.success('Роль обновлена');
} else {
await createRole(values);
message.success('Роль создана');
}
setIsModalVisible(false);
form.resetFields();
setEditingRole(null);
fetchRoles(); // Обновляем список ролей
} catch {
message.error('Ошибка при сохранении роли');
} finally {
setLoading(false);
}
};
// Удаление роли
const handleDelete = async (roleId: number) => {
try {
await deleteRole(roleId);
message.success('Роль удалена');
fetchRoles(); // Обновляем список ролей
} catch {
message.error('Ошибка при удалении роли');
}
};
// Определение столбцов таблицы
const columns = [
{
title: 'Название',
dataIndex: 'name',
key: 'name',
},
{
title: 'Описание',
dataIndex: 'description',
key: 'description',
},
{
title: 'Разрешения',
dataIndex: 'permissions',
key: 'permissions',
render: (perms: Permission[]) => perms.map(p => p.name).join(', '),
},
{
title: 'Действия',
key: 'actions',
render: (_: any, record: Role) => (
<Space>
<Button type="link" onClick={() => handleEdit(record)}>
Редактировать
</Button>
<Button
type="link"
danger
onClick={() => handleDelete(record.id)}
disabled={record.name === 'admin'} // Защита от удаления роли админа
>
Удалить
</Button>
</Space>
),
},
];
return (
<div>
<Button
type="primary"
onClick={() => {
setEditingRole(null);
form.resetFields();
setIsModalVisible(true);
}}
style={{ marginBottom: 16 }}
>
Создать роль
</Button>
<Table
columns={columns}
dataSource={roles || []}
rowKey="id"
loading={!roles}
/>
<Modal
title={editingRole ? 'Редактировать роль' : 'Создать роль'}
open={isModalVisible}
onOk={() => form.submit()}
onCancel={() => {
setIsModalVisible(false);
setEditingRole(null);
form.resetFields();
}}
confirmLoading={loading}
>
<Form
form={form}
onFinish={handleSubmit}
layout="vertical"
>
<Form.Item
name="name"
label="Название"
rules={[{ required: true, message: 'Введите название роли' }]}
>
<Input disabled={editingRole?.name === 'admin'} />
</Form.Item>
<Form.Item
name="description"
label="Описание"
>
<Input.TextArea />
</Form.Item>
<Form.Item
name="permissions"
label="Разрешения"
>
<Checkbox.Group>
{(permissions || []).map(permission => (
<div key={permission.id} style={{ marginBottom: 8 }}>
<Checkbox
value={permission.id}
disabled={editingRole?.name === 'admin'}
>
{permission.description}
</Checkbox>
</div>
))}
</Checkbox.Group>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default RoleManagement;

View File

@ -0,0 +1,181 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Modal, Form, Input, Select, Switch, App as AntdApp } from 'antd';
import { useGet, usePost, usePatch } from '../../hooks/useApi';
interface User {
id: number;
username: string;
role: string;
is_active: boolean;
categories: string[];
}
interface Role {
id: number;
name: string;
}
interface Category {
id: number;
name: string;
}
const UserManagement: React.FC = () => {
const [isModalVisible, setIsModalVisible] = useState(false);
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const { message } = AntdApp.useApp();
// Используем `useGet` для загрузки пользователей, ролей и категорий
const { data: users, execute: fetchUsers } = useGet<User[]>('/admin/users');
const { data: roles, execute: fetchRoles } = useGet<Role[]>('/admin/roles');
const { data: categories, execute: fetchCategories } = useGet<Category[]>('/admin/categories');
// Используем `usePost` для создания пользователя
const { execute: createUser } = usePost('/admin/users');
// Используем `usePatch` для обновления статуса пользователя
const { execute: updateUserStatus } = usePatch(`/admin/users`);
// Загружаем данные при монтировании компонента
useEffect(() => {
fetchUsers();
fetchRoles();
fetchCategories();
}, []);
// Обработчик создания пользователя
const handleCreate = async (values: any) => {
setLoading(true);
try {
await createUser(values);
message.success('Пользователь создан');
setIsModalVisible(false);
form.resetFields();
fetchUsers();
} catch {
message.error('Ошибка при создании пользователя');
} finally {
setLoading(false);
}
};
// Обработчик изменения статуса пользователя (активен/не активен)
const handleToggleActive = async (userId: number, isActive: boolean) => {
try {
await updateUserStatus(`${userId}`, { is_active: isActive });
message.success('Статус пользователя обновлен');
fetchUsers();
} catch {
message.error('Ошибка при обновлении статуса');
}
};
// Определение столбцов таблицы
const columns = [
{
title: 'Имя пользователя',
dataIndex: 'username',
key: 'username',
},
{
title: 'Роль',
dataIndex: 'role',
key: 'role',
},
{
title: 'Активен',
dataIndex: 'is_active',
key: 'is_active',
render: (active: boolean, record: User) => (
<Switch
checked={active}
onChange={(checked) => handleToggleActive(record.id, checked)}
/>
),
},
{
title: 'Категории',
dataIndex: 'categories',
key: 'categories',
render: (categories: string[]) => categories.join(', '),
},
];
return (
<div>
<Button
type="primary"
onClick={() => setIsModalVisible(true)}
style={{ marginBottom: 16 }}
>
Создать пользователя
</Button>
<Table
columns={columns}
dataSource={users || []}
rowKey="id"
loading={!users}
/>
<Modal
title="Создать пользователя"
open={isModalVisible}
onOk={() => form.submit()}
onCancel={() => setIsModalVisible(false)}
confirmLoading={loading}
>
<Form
form={form}
onFinish={handleCreate}
layout="vertical"
>
<Form.Item
name="username"
label="Имя пользователя"
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item
name="password"
label="Пароль"
rules={[{ required: true }]}
>
<Input.Password />
</Form.Item>
<Form.Item
name="role_id"
label="Роль"
rules={[{ required: true }]}
>
<Select>
{(roles || []).map((role) => (
<Select.Option key={role.id} value={role.id}>
{role.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name="categories"
label="Доступные категории"
>
<Select mode="multiple">
{(categories || []).map((category) => (
<Select.Option key={category.id} value={category.id}>
{category.name}
</Select.Option>
))}
</Select>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default UserManagement;

View File

@ -0,0 +1,88 @@
import React, { useState, useEffect } from 'react';
import { Button, Card, Descriptions, Alert, App as AntdApp } from 'antd';
import { useGet, usePost } from '../../hooks/useApi';
interface TransferStatus {
id: number;
year: number;
completed_at: string;
status: string;
error_message?: string;
}
const YearEndManagement: React.FC = () => {
const [loading, setLoading] = useState(false);
const [lastTransfer, setLastTransfer] = useState<TransferStatus | null>(null);
const { message } = AntdApp.useApp();
// Используем `useGet` для получения статуса
const { data, error, execute: fetchStatus } = useGet<TransferStatus>('/year-end/status');
// Используем `usePost` для выполнения переноса
const { execute: transferData } = usePost('/year-end/transfer');
// Обновляем состояние, когда `data` загружено
useEffect(() => {
if (data) setLastTransfer(data);
}, [data]);
// Обработчик клика на кнопку переноса
const handleTransfer = async () => {
const currentYear = new Date().getFullYear();
const confirm = window.confirm(
`Вы уверены, что хотите выполнить перенос остатков за ${currentYear} год?`
);
if (!confirm) return;
setLoading(true);
try {
await transferData(); // Выполняем POST-запрос
message.success('Перенос остатков успешно выполнен');
fetchStatus(); // Обновляем статус
} catch {
message.error('Ошибка при переносе остатков');
} finally {
setLoading(false);
}
};
return (
<Card title="Управление переносом остатков">
<Alert
message="Внимание"
description="Перенос остатков создаст новые категории для следующего года и перенесет все товары с ненулевым количеством."
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
{error && <Alert message="Ошибка" description={error} type="error" showIcon />}
{lastTransfer && (
<Descriptions title="Последний перенос" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="Год">{lastTransfer.year}</Descriptions.Item>
<Descriptions.Item label="Дата выполнения">
{new Date(lastTransfer.completed_at).toLocaleString()}
</Descriptions.Item>
<Descriptions.Item label="Статус">{lastTransfer.status}</Descriptions.Item>
{lastTransfer.error_message && (
<Descriptions.Item label="Ошибка">
{lastTransfer.error_message}
</Descriptions.Item>
)}
</Descriptions>
)}
<Button
type="primary"
onClick={handleTransfer}
loading={loading}
disabled={loading}
>
Выполнить перенос остатков
</Button>
</Card>
);
};
export default YearEndManagement;

View File

@ -0,0 +1,230 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Modal, Form, Input, InputNumber, Select, Space, App as AntdApp } from 'antd';
import { useGet, usePost, usePut, useDelete } from '../../hooks/useApi';
interface Item {
id: number;
name: string;
code: string;
quantity: number;
}
interface BoxItem {
id: number;
item_id: number;
quantity: number;
item: Item;
}
interface Box {
id: number;
name: string;
items: BoxItem[];
}
interface BoxManagementProps {
categoryId: number;
items: Item[];
onUpdate: () => void;
}
const BoxManagement: React.FC<BoxManagementProps> = ({ categoryId, items, onUpdate }) => {
const [isModalVisible, setIsModalVisible] = useState(false);
const [editingBox, setEditingBox] = useState<Box | null>(null);
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const { message } = AntdApp.useApp();
// Используем `useGet` для загрузки коробок
const { data: boxes, execute: fetchBoxes } = useGet<Box[]>(`/inventory/categories/${categoryId}/boxes`);
// Используем `usePost`, `usePut`, `useDelete`
const { execute: createBox } = usePost('/inventory/boxes');
const { execute: updateBox } = usePut(`/inventory/boxes/${editingBox?.id}`);
const { execute: deleteBox } = useDelete(`/inventory/boxes/${editingBox?.id}`);
// Загружаем данные при монтировании компонента
useEffect(() => {
fetchBoxes();
}, [categoryId]);
// Отправка формы (создание или обновление коробки)
const handleSubmit = async (values: any) => {
setLoading(true);
try {
const boxData = {
name: values.name,
category_id: categoryId,
items: values.items?.map((item: any) => ({
item_id: item.item_id,
quantity: item.quantity,
})) || [],
};
if (editingBox) {
await updateBox(boxData);
message.success('Коробка обновлена');
} else {
await createBox(boxData);
message.success('Коробка создана');
}
setIsModalVisible(false);
form.resetFields();
setEditingBox(null);
fetchBoxes();
onUpdate();
} catch {
message.error('Ошибка при сохранении коробки');
} finally {
setLoading(false);
}
};
// Открытие модального окна для редактирования коробки
const handleEdit = (box: Box) => {
setEditingBox(box);
form.setFieldsValue({
name: box.name,
items: box.items.map((item) => ({
item_id: item.item_id,
quantity: item.quantity,
})),
});
setIsModalVisible(true);
};
// Удаление коробки
const handleDelete = async (boxId: number) => {
try {
await deleteBox(boxId);
message.success('Коробка удалена');
fetchBoxes();
onUpdate();
} catch {
message.error('Ошибка при удалении коробки');
}
};
// Определение столбцов таблицы
const columns = [
{
title: 'Название коробки',
dataIndex: 'name',
key: 'name',
},
{
title: 'Содержимое',
dataIndex: 'items',
key: 'items',
render: (items: BoxItem[]) => (
<ul style={{ margin: 0, paddingLeft: 20 }}>
{items.map((item) => (
<li key={item.id}>
{item.item.name} - {item.quantity} шт.
</li>
))}
</ul>
),
},
{
title: 'Действия',
key: 'actions',
render: (_: any, record: Box) => (
<Space>
<Button type="link" onClick={() => handleEdit(record)}>
Редактировать
</Button>
<Button type="link" danger onClick={() => handleDelete(record.id)}>
Удалить
</Button>
</Space>
),
},
];
return (
<div>
<Button
type="primary"
onClick={() => {
setEditingBox(null);
form.resetFields();
setIsModalVisible(true);
}}
style={{ marginBottom: 16 }}
>
Создать коробку
</Button>
<Table
columns={columns}
dataSource={boxes || []}
rowKey="id"
loading={!boxes}
/>
<Modal
title={editingBox ? 'Редактировать коробку' : 'Создать коробку'}
open={isModalVisible}
onOk={() => form.submit()}
onCancel={() => {
setIsModalVisible(false);
setEditingBox(null);
form.resetFields();
}}
confirmLoading={loading}
width={800}
>
<Form form={form} onFinish={handleSubmit} layout="vertical">
<Form.Item
name="name"
label="Название коробки"
rules={[{ required: true, message: 'Введите название коробки' }]}
>
<Input />
</Form.Item>
<Form.List name="items">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space key={key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
<Form.Item
{...restField}
name={[name, 'item_id']}
rules={[{ required: true, message: 'Выберите товар' }]}
>
<Select style={{ width: 300 }}>
{items.map((item) => (
<Select.Option key={item.id} value={item.id}>
{item.name} ({item.code})
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
{...restField}
name={[name, 'quantity']}
rules={[{ required: true, message: 'Введите количество' }]}
>
<InputNumber min={1} placeholder="Количество" />
</Form.Item>
<Button type="link" danger onClick={() => remove(name)}>
Удалить
</Button>
</Space>
))}
<Form.Item>
<Button type="dashed" onClick={() => add()} block>
Добавить товар
</Button>
</Form.Item>
</>
)}
</Form.List>
</Form>
</Modal>
</div>
);
};
export default BoxManagement;

View File

@ -0,0 +1,206 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Modal, Form, Input, InputNumber, Space, Tabs, App as AntdApp } from 'antd';
import { useGet, usePost, usePut, useDelete } from '../../hooks/useApi';
import BoxManagement from './BoxManagement';
interface Item {
id: number;
name: string;
code: string;
description: string;
quantity: number;
category_id: number;
}
interface CategoryItemsProps {
categoryId: number;
}
const CategoryItems: React.FC<CategoryItemsProps> = ({ categoryId }) => {
const [isModalVisible, setIsModalVisible] = useState(false);
const [editingItem, setEditingItem] = useState<Item | null>(null);
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const { message } = AntdApp.useApp();
// Используем `useGet` для загрузки товаров
const { data: items, execute: fetchItems } = useGet<Item[]>(`/inventory/categories/${categoryId}/items`);
// Используем `usePost`, `usePut`, `useDelete` для управления товарами
const { execute: createItem } = usePost('/inventory/items');
const { execute: updateItem } = usePut(`/inventory/items/${editingItem?.id}`);
const { execute: deleteItem } = useDelete(`/inventory/items/${editingItem?.id}`);
// Загружаем данные при монтировании компонента
useEffect(() => {
fetchItems();
}, [categoryId]);
// Отправка формы (создание или обновление товара)
const handleSubmit = async (values: any) => {
setLoading(true);
try {
const itemData = {
...values,
category_id: categoryId,
};
if (editingItem) {
await updateItem(itemData);
message.success('Товар обновлен');
} else {
await createItem(itemData);
message.success('Товар создан');
}
setIsModalVisible(false);
form.resetFields();
setEditingItem(null);
fetchItems();
} catch {
message.error('Ошибка при сохранении товара');
} finally {
setLoading(false);
}
};
// Открытие модального окна для редактирования товара
const handleEdit = (item: Item) => {
setEditingItem(item);
form.setFieldsValue({
name: item.name,
code: item.code,
description: item.description,
quantity: item.quantity,
});
setIsModalVisible(true);
};
// Удаление товара
const handleDelete = async (itemId: number) => {
try {
await deleteItem(itemId);
message.success('Товар удален');
fetchItems();
} catch {
message.error('Ошибка при удалении товара');
}
};
// Определение столбцов таблицы
const columns = [
{
title: 'Код',
dataIndex: 'code',
key: 'code',
},
{
title: 'Название',
dataIndex: 'name',
key: 'name',
},
{
title: 'Описание',
dataIndex: 'description',
key: 'description',
},
{
title: 'Количество',
dataIndex: 'quantity',
key: 'quantity',
},
{
title: 'Действия',
key: 'actions',
render: (_: any, record: Item) => (
<Space>
<Button type="link" onClick={() => handleEdit(record)}>
Редактировать
</Button>
<Button type="link" danger onClick={() => handleDelete(record.id)}>
Удалить
</Button>
</Space>
),
},
];
const tabItems = [
{
key: 'items',
label: 'Товары',
children: (
<>
<div style={{ marginBottom: 16 }}>
<Button
type="primary"
onClick={() => {
setEditingItem(null);
form.resetFields();
setIsModalVisible(true);
}}
>
Добавить товар
</Button>
</div>
<Table columns={columns} dataSource={items || []} rowKey="id" loading={!items} />
<Modal
title={editingItem ? 'Редактировать товар' : 'Добавить товар'}
open={isModalVisible}
onOk={() => form.submit()}
onCancel={() => {
setIsModalVisible(false);
setEditingItem(null);
form.resetFields();
}}
confirmLoading={loading}
>
<Form form={form} onFinish={handleSubmit} layout="vertical">
<Form.Item
name="code"
label="Код"
rules={[{ required: true, message: 'Введите код товара' }]}
>
<Input />
</Form.Item>
<Form.Item
name="name"
label="Название"
rules={[{ required: true, message: 'Введите название товара' }]}
>
<Input />
</Form.Item>
<Form.Item name="description" label="Описание">
<Input.TextArea />
</Form.Item>
<Form.Item
name="quantity"
label="Количество"
rules={[{ required: true, message: 'Введите количество' }]}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</>
),
},
{
key: 'boxes',
label: 'Коробки',
children: <BoxManagement categoryId={categoryId} items={items || []} onUpdate={fetchItems} />,
},
];
return (
<div>
<Tabs defaultActiveKey="items" items={tabItems} />
</div>
);
};
export default CategoryItems;

View File

@ -0,0 +1,165 @@
import React, { useState, useEffect } from 'react';
import { Card, Form, Select, Button, Radio, App as AntdApp } from 'antd';
import { DownloadOutlined } from '@ant-design/icons';
import { useGet, useApi } from '../../hooks/useApi';
interface Location {
id: number;
name: string;
}
interface Category {
id: number;
name: string;
location_id: number;
}
interface FormValues {
reportType: 'inventory' | 'boxes';
format: 'excel' | 'pdf';
locationId?: number;
categoryId?: number;
}
const ReportGenerator: React.FC = () => {
const [selectedLocation, setSelectedLocation] = useState<number | undefined>();
const [loading, setLoading] = useState(false);
const { message } = AntdApp.useApp();
// Используем `useGet` для загрузки локаций
const { data: locations, execute: fetchLocations } = useGet<Location[]>('/locations');
const { data: categories, execute: fetchCategories } = useGet<Category[]>('');
// API-запрос для скачивания отчётов
const reportApi = useApi<Blob>('', 'GET');
// Загружаем локации при монтировании компонента
useEffect(() => {
fetchLocations();
}, []);
useEffect(() => {
if (selectedLocation) {
fetchCategories(`/locations/${selectedLocation}/categories`);
} else {
fetchCategories('');
}
}, [selectedLocation]);
// Обработчик скачивания отчёта
const handleDownload = async (values: FormValues) => {
const { reportType, format, locationId, categoryId } = values;
setLoading(true);
try {
let url = '/reports/';
if (reportType === 'inventory') {
url += `inventory?format=${format}`;
if (locationId) url += `&locationId=${locationId}`;
if (categoryId) url += `&categoryId=${categoryId}`;
} else {
if (!categoryId) {
message.error('Выберите категорию для отчета по коробкам');
setLoading(false);
return;
}
url += `boxes/${categoryId}?format=${format}`;
}
// Выполняем запрос на скачивание отчета
const response = await reportApi.execute(url, {
headers: { 'Content-Type': 'application/json' },
responseType: 'blob', // Важно для скачивания файла
timeout: 30000, // Долгий таймаут для больших отчетов
});
if (!response) {
message.error('Ошибка при генерации отчета');
return;
}
// Создаем Blob и скачиваем файл
const blob = new Blob([response], {
type:
format === 'excel'
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
: 'application/pdf',
});
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = `report-${new Date().toISOString()}.${format}`;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(downloadUrl);
message.success('Отчет успешно сгенерирован');
} catch {
message.error('Ошибка при генерации отчета');
} finally {
setLoading(false);
}
};
return (
<Card title="Генерация отчетов">
<Form<FormValues> layout="vertical" onFinish={handleDownload}>
<Form.Item
name="reportType"
label="Тип отчета"
rules={[{ required: true, message: 'Выберите тип отчета' }]}
>
<Radio.Group>
<Radio.Button value="inventory">Инвентарный отчет</Radio.Button>
<Radio.Button value="boxes">Отчет по коробкам</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item
name="format"
label="Формат"
rules={[{ required: true, message: 'Выберите формат' }]}
>
<Radio.Group>
<Radio.Button value="excel">Excel</Radio.Button>
<Radio.Button value="pdf">PDF</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item name="locationId" label="Локация">
<Select
placeholder="Выберите локацию"
onChange={(value) => setSelectedLocation(value)}
allowClear
>
{(locations || []).map((location) => (
<Select.Option key={location.id} value={location.id}>
{location.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="categoryId" label="Категория">
<Select placeholder="Выберите категорию" allowClear disabled={!selectedLocation}>
{(categories || []).map((category) => (
<Select.Option key={category.id} value={category.id}>
{category.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item>
<Button type="primary" icon={<DownloadOutlined />} loading={loading} htmlType="submit">
Скачать отчет
</Button>
</Form.Item>
</Form>
</Card>
);
};
export default ReportGenerator;

97
src/hooks/useApi.ts Normal file
View File

@ -0,0 +1,97 @@
import { useState, useCallback } from 'react';
import axios from 'axios';
const API_URL = import.meta.env.VITE_API_URL || '/api';
interface ApiError {
message: string;
}
interface UseApiResponse<T> {
data: T | null;
loading: boolean;
error: string | null;
execute: (...args: any[]) => Promise<T | null>;
}
export function useApi<T>(
endpoint: string,
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'GET'
): UseApiResponse<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const execute = useCallback(
async (...args: any[]): Promise<T | null> => {
try {
setLoading(true);
setError(null);
const token = localStorage.getItem('token');
const headers = token ? { Authorization: `Bearer ${token}` } : {};
let response;
switch (method) {
case 'GET':
response = await axios.get<T>(`${API_URL}${endpoint}`, { headers });
break;
case 'POST':
response = await axios.post<T>(`${API_URL}${endpoint}`, args[0], { headers });
break;
case 'PUT':
response = await axios.put<T>(`${API_URL}${endpoint}`, args[0], { headers });
break;
case 'PATCH': // Добавляем поддержку PATCH-запросов
response = await axios.patch<T>(`${API_URL}${endpoint}`, args[0], { headers });
break;
case 'DELETE':
response = await axios.delete<T>(`${API_URL}${endpoint}`, { headers });
break;
default:
throw new Error(`Неподдерживаемый метод: ${method}`);
}
setData(response.data);
return response.data;
} catch (err: any) {
if (err && err.isAxiosError) {
const apiError = err.response?.data as ApiError | undefined;
const errorMessage = apiError?.message || 'Произошла ошибка на сервере';
setError(errorMessage);
} else {
setError('Произошла неизвестная ошибка');
}
return null;
} finally {
setLoading(false);
}
},
[endpoint, method]
);
return { data, loading, error, execute };
}
// Вспомогательные функции для типичных запросов
export function useGet<T>(endpoint: string) {
return useApi<T>(endpoint, 'GET');
}
export function usePost<T>(endpoint: string) {
return useApi<T>(endpoint, 'POST');
}
export function usePut<T>(endpoint: string) {
return useApi<T>(endpoint, 'PUT');
}
export function usePatch<T>(endpoint: string) {
return useApi<T>(endpoint, 'PATCH');
}
export function useDelete<T>(endpoint: string) {
return useApi<T>(endpoint, 'DELETE');
}
export default useApi;

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/global.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

50
src/router.tsx Normal file
View File

@ -0,0 +1,50 @@
import { createBrowserRouter } from 'react-router-dom';
import Login from './components/Login';
import Dashboard from './components/Dashboard';
import AdminPanel from './components/admin/AdminPanel';
import PrivateRoute from './components/PrivateRoute';
const router = createBrowserRouter([
{
path: '/login',
element: <Login />
},
{
path: '/dashboard/*',
element: <PrivateRoute><Dashboard /></PrivateRoute>,
children: [
{
path: ':categoryId',
element: <Dashboard />
}
]
},
{
path: '/admin',
element: <PrivateRoute requiredRoles={['admin']}><AdminPanel /></PrivateRoute>,
children: [
{
path: 'users',
element: <AdminPanel />
},
{
path: 'roles',
element: <AdminPanel />
},
{
path: 'categories',
element: <AdminPanel />
}
]
},
{
path: '/',
element: <PrivateRoute><Dashboard /></PrivateRoute>
}
], {
future: {
v7_relativeSplatPath: true
}
});
export default router;

12
src/styles/global.css Normal file
View File

@ -0,0 +1,12 @@
body {
margin: 0;
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

66
vite.config.ts Normal file
View File

@ -0,0 +1,66 @@
import { defineConfig, Plugin } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import fs from 'fs';
function fixSourceMaps(): Plugin {
let currentInterval: NodeJS.Timeout | null = null;
return {
name: 'fix-source-map',
enforce: 'post',
transform(source) {
if (currentInterval) {
return source;
}
currentInterval = setInterval(() => {
const nodeModulesPath = path.join(process.cwd(), 'node_modules', '.vite', 'deps');
if (fs.existsSync(nodeModulesPath)) {
clearInterval(currentInterval!);
currentInterval = null;
const files = fs.readdirSync(nodeModulesPath);
files.forEach((file) => {
const mapFile = `${file}.map`;
const mapPath = path.join(nodeModulesPath, mapFile);
if (fs.existsSync(mapPath)) {
let mapData = JSON.parse(fs.readFileSync(mapPath, 'utf8'));
if (!mapData.sources || mapData.sources.length === 0) {
mapData.sources = [path.relative(mapPath, path.join(nodeModulesPath, file))];
fs.writeFileSync(mapPath, JSON.stringify(mapData), 'utf8');
}
}
});
}
}, 100);
return source;
}
};
}
export default defineConfig({
plugins: [
react(),
fixSourceMaps() as Plugin // Фикс source maps
],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://127.0.0.1:4000',
changeOrigin: true,
secure: false,
ws: true
}
}
}
,
resolve: {
preserveSymlinks: true // Иногда помогает с проблемами путей в Vite
}
});