first commit
This commit is contained in:
parent
b4778e436a
commit
112346ca10
|
@ -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
|
|
@ -0,0 +1,12 @@
|
|||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
|
@ -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
|
|
@ -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>
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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 |
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
|
@ -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" }]
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
|
@ -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
|
||||
}
|
||||
});
|
Loading…
Reference in New Issue