diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03ce071 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d24fdb2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +EXPOSE 3000 + +CMD ["npm", "run", "dev"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2a41001 --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..000b55a --- /dev/null +++ b/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + Warehouse Management + + +
+ + + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..4b643d4 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..11f01ea --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,8 @@ + + + \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..faf90fb --- /dev/null +++ b/src/App.tsx @@ -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 ( + + {/* Важно: оборачиваем в AntdApp */} + + + + ); +}; + +export default App; diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx new file mode 100644 index 0000000..7f74ff5 --- /dev/null +++ b/src/components/Dashboard.tsx @@ -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(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: , + label: Инвентарь, + }, + { + key: '2', + icon: , + label: Отчеты, + }, + ]; + + // ✅ Добавляем "Администрирование", если роль admin + if (userRole === 'admin') { + menuItems.push({ + key: 'admin', + icon: , + label: collapsed ? : 'Управление', // ✅ Фикс стрелки + children: [ + { + key: '3', + icon: , + label: Пользователи, + }, + { + key: '4', + icon: , + label: Роли, + }, + { + key: '5', + icon: , + label: Категории, + }, + { + key: '6', + icon: , + label: Локации, + }, + ], + }); + } + + // ✅ Добавляем кнопку "Выйти" в `menuItems`, чтобы она скрывалась при сворачивании + menuItems.push({ + key: 'logout', + icon: , + label: Выйти, + onClick: handleLogout, // ✅ Добавляем обработчик выхода + }); + + return ( + + +
+ + + +
+ {React.createElement(collapsed ? MenuUnfoldOutlined : MenuFoldOutlined, { + className: 'trigger', + onClick: () => setCollapsed(!collapsed), + style: { fontSize: '18px', padding: '0 24px', cursor: 'pointer' }, + })} +
+ + + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + +
+ + ); +}; + +export default Dashboard; diff --git a/src/components/ItemsList.tsx b/src/components/ItemsList.tsx new file mode 100644 index 0000000..4759e7f --- /dev/null +++ b/src/components/ItemsList.tsx @@ -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([]); + 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 ( +
+ + } + value={searchText} + onChange={e => setSearchText(e.target.value)} + /> + + + + + ); +}; + +export default ItemsList; \ No newline at end of file diff --git a/src/components/Login.tsx b/src/components/Login.tsx new file mode 100644 index 0000000..a759354 --- /dev/null +++ b/src/components/Login.tsx @@ -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 ( +
+

Вход в систему

+
+ + } + placeholder="Имя пользователя" + size="large" + /> + + + + } + placeholder="Пароль" + size="large" + /> + + + + + + +
+ ); +}; + +export default Login; \ No newline at end of file diff --git a/src/components/PrivateRoute.tsx b/src/components/PrivateRoute.tsx new file mode 100644 index 0000000..631984a --- /dev/null +++ b/src/components/PrivateRoute.tsx @@ -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 = ({ children, requiredRoles }) => { + const [isAuthenticated, setIsAuthenticated] = useState(null); + const [user, setUser] = useState(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 как вложенный контент */} + +
+ ); + } + + + // Если не авторизован, редиректим на логин + if (!isAuthenticated) { + return ; + } + + // Проверяем роль, если требуется + if (requiredRoles && user && !requiredRoles.includes(user.role)) { + return ; + } + + return <>{children}; +}; + +export default PrivateRoute; diff --git a/src/components/admin/AdminPanel.tsx b/src/components/admin/AdminPanel.tsx new file mode 100644 index 0000000..ea17bd3 --- /dev/null +++ b/src/components/admin/AdminPanel.tsx @@ -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: , + label: 'Пользователи', + onClick: () => navigate('/admin/users') + }, + { + key: 'roles', + icon: , + label: 'Роли и разрешения', + onClick: () => navigate('/admin/roles') + }, + { + key: 'categories', + icon: , + label: 'Категории', + onClick: () => navigate('/admin/categories') + } + ]; + + return ( + + + + + + + + } /> + } /> + } /> + + + + + ); +}; + +export default AdminPanel; \ No newline at end of file diff --git a/src/components/admin/CategoryManagement.tsx b/src/components/admin/CategoryManagement.tsx new file mode 100644 index 0000000..f2c5822 --- /dev/null +++ b/src/components/admin/CategoryManagement.tsx @@ -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(null); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const { message } = AntdApp.useApp(); + // Используем `useGet` для загрузки категорий и локаций + const { data: categories, execute: fetchCategories } = useGet('/admin/categories'); + const { data: locations, execute: fetchLocations } = useGet('/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) => ( + + + + + ), + }, + ]; + + return ( +
+
+ +
+ +
+ + form.submit()} + onCancel={() => { + setIsModalVisible(false); + setEditingCategory(null); + form.resetFields(); + }} + confirmLoading={loading} + > +
+ + + + + + + + +
+ + ); +}; + +export default CategoryManagement; diff --git a/src/components/admin/LocationManagement.tsx b/src/components/admin/LocationManagement.tsx new file mode 100644 index 0000000..0138ebb --- /dev/null +++ b/src/components/admin/LocationManagement.tsx @@ -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(null); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const { message } = AntdApp.useApp(); + + // API хуки + const { data: locations, execute: fetchLocations } = useGet('/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) => ( + + + + + ), + }, + ]; + + return ( +
+ + +
+ + form.submit()} + onCancel={() => { + setIsModalVisible(false); + setEditingLocation(null); + form.resetFields(); + }} + confirmLoading={loading} + > +
+ + + + + + + + +
+ + ); +}; + +export default LocationManagement; \ No newline at end of file diff --git a/src/components/admin/RoleManagement.tsx b/src/components/admin/RoleManagement.tsx new file mode 100644 index 0000000..6ffac62 --- /dev/null +++ b/src/components/admin/RoleManagement.tsx @@ -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(null); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const { message } = AntdApp.useApp(); + // Используем `useGet` для загрузки ролей и разрешений + const { data: roles, execute: fetchRoles } = useGet('/admin/roles'); + const { data: permissions, execute: fetchPermissions } = useGet('/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) => ( + + + + + ), + }, + ]; + + return ( +
+ + +
+ + form.submit()} + onCancel={() => { + setIsModalVisible(false); + setEditingRole(null); + form.resetFields(); + }} + confirmLoading={loading} + > +
+ + + + + + + + + + + {(permissions || []).map(permission => ( +
+ + {permission.description} + +
+ ))} +
+
+ +
+ + ); +}; + +export default RoleManagement; diff --git a/src/components/admin/UserManagement.tsx b/src/components/admin/UserManagement.tsx new file mode 100644 index 0000000..452b734 --- /dev/null +++ b/src/components/admin/UserManagement.tsx @@ -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('/admin/users'); + const { data: roles, execute: fetchRoles } = useGet('/admin/roles'); + const { data: categories, execute: fetchCategories } = useGet('/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) => ( + handleToggleActive(record.id, checked)} + /> + ), + }, + { + title: 'Категории', + dataIndex: 'categories', + key: 'categories', + render: (categories: string[]) => categories.join(', '), + }, + ]; + + return ( +
+ + +
+ + form.submit()} + onCancel={() => setIsModalVisible(false)} + confirmLoading={loading} + > +
+ + + + + + + + + + + + + + + + +
+ + ); +}; + +export default UserManagement; diff --git a/src/components/admin/YearEndManagement.tsx b/src/components/admin/YearEndManagement.tsx new file mode 100644 index 0000000..cc8c0c7 --- /dev/null +++ b/src/components/admin/YearEndManagement.tsx @@ -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(null); + const { message } = AntdApp.useApp(); + // Используем `useGet` для получения статуса + const { data, error, execute: fetchStatus } = useGet('/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 ( + + + + {error && } + + {lastTransfer && ( + + {lastTransfer.year} + + {new Date(lastTransfer.completed_at).toLocaleString()} + + {lastTransfer.status} + {lastTransfer.error_message && ( + + {lastTransfer.error_message} + + )} + + )} + + + + ); +}; + +export default YearEndManagement; diff --git a/src/components/inventory/BoxManagement.tsx b/src/components/inventory/BoxManagement.tsx new file mode 100644 index 0000000..065ffba --- /dev/null +++ b/src/components/inventory/BoxManagement.tsx @@ -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 = ({ categoryId, items, onUpdate }) => { + const [isModalVisible, setIsModalVisible] = useState(false); + const [editingBox, setEditingBox] = useState(null); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const { message } = AntdApp.useApp(); + // Используем `useGet` для загрузки коробок + const { data: boxes, execute: fetchBoxes } = useGet(`/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[]) => ( +
    + {items.map((item) => ( +
  • + {item.item.name} - {item.quantity} шт. +
  • + ))} +
+ ), + }, + { + title: 'Действия', + key: 'actions', + render: (_: any, record: Box) => ( + + + + + ), + }, + ]; + + return ( +
+ + +
+ + form.submit()} + onCancel={() => { + setIsModalVisible(false); + setEditingBox(null); + form.resetFields(); + }} + confirmLoading={loading} + width={800} + > +
+ + + + + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...restField }) => ( + + + + + + + + + + ))} + + + + + )} + + +
+ + ); +}; + +export default BoxManagement; diff --git a/src/components/inventory/CategoryItems.tsx b/src/components/inventory/CategoryItems.tsx new file mode 100644 index 0000000..21fe4d1 --- /dev/null +++ b/src/components/inventory/CategoryItems.tsx @@ -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 = ({ categoryId }) => { + const [isModalVisible, setIsModalVisible] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const { message } = AntdApp.useApp(); + // Используем `useGet` для загрузки товаров + const { data: items, execute: fetchItems } = useGet(`/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) => ( + + + + + ), + }, + ]; + + const tabItems = [ + { + key: 'items', + label: 'Товары', + children: ( + <> +
+ +
+ +
+ + form.submit()} + onCancel={() => { + setIsModalVisible(false); + setEditingItem(null); + form.resetFields(); + }} + confirmLoading={loading} + > +
+ + + + + + + + + + + + + + + + +
+ + ), + }, + { + key: 'boxes', + label: 'Коробки', + children: , + }, + ]; + + return ( +
+ +
+ ); +}; + +export default CategoryItems; diff --git a/src/components/reports/ReportGenerator.tsx b/src/components/reports/ReportGenerator.tsx new file mode 100644 index 0000000..b197d8c --- /dev/null +++ b/src/components/reports/ReportGenerator.tsx @@ -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(); + const [loading, setLoading] = useState(false); + const { message } = AntdApp.useApp(); + + // Используем `useGet` для загрузки локаций + const { data: locations, execute: fetchLocations } = useGet('/locations'); + const { data: categories, execute: fetchCategories } = useGet(''); + + // API-запрос для скачивания отчётов + const reportApi = useApi('', '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 ( + + layout="vertical" onFinish={handleDownload}> + + + Инвентарный отчет + Отчет по коробкам + + + + + + Excel + PDF + + + + + + + + + + + + + + + + + ); +}; + +export default ReportGenerator; diff --git a/src/hooks/useApi.ts b/src/hooks/useApi.ts new file mode 100644 index 0000000..b4a8b8d --- /dev/null +++ b/src/hooks/useApi.ts @@ -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 { + data: T | null; + loading: boolean; + error: string | null; + execute: (...args: any[]) => Promise; +} + +export function useApi( + endpoint: string, + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'GET' +): UseApiResponse { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const execute = useCallback( + async (...args: any[]): Promise => { + 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(`${API_URL}${endpoint}`, { headers }); + break; + case 'POST': + response = await axios.post(`${API_URL}${endpoint}`, args[0], { headers }); + break; + case 'PUT': + response = await axios.put(`${API_URL}${endpoint}`, args[0], { headers }); + break; + case 'PATCH': // Добавляем поддержку PATCH-запросов + response = await axios.patch(`${API_URL}${endpoint}`, args[0], { headers }); + break; + case 'DELETE': + response = await axios.delete(`${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(endpoint: string) { + return useApi(endpoint, 'GET'); +} + +export function usePost(endpoint: string) { + return useApi(endpoint, 'POST'); +} + +export function usePut(endpoint: string) { + return useApi(endpoint, 'PUT'); +} + +export function usePatch(endpoint: string) { + return useApi(endpoint, 'PATCH'); +} + +export function useDelete(endpoint: string) { + return useApi(endpoint, 'DELETE'); +} + +export default useApi; diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..dc3f4b5 --- /dev/null +++ b/src/main.tsx @@ -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( + + + +); diff --git a/src/router.tsx b/src/router.tsx new file mode 100644 index 0000000..4159bcc --- /dev/null +++ b/src/router.tsx @@ -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: + }, + { + path: '/dashboard/*', + element: , + children: [ + { + path: ':categoryId', + element: + } + ] + }, + { + path: '/admin', + element: , + children: [ + { + path: 'users', + element: + }, + { + path: 'roles', + element: + }, + { + path: 'categories', + element: + } + ] + }, + { + path: '/', + element: + } +], { + future: { + v7_relativeSplatPath: true + } +}); + +export default router; diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 0000000..b6a493b --- /dev/null +++ b/src/styles/global.css @@ -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; +} \ No newline at end of file diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..f5e1a25 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5d1e601 --- /dev/null +++ b/tsconfig.json @@ -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" }] +} \ No newline at end of file diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..862dfb2 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..66543b6 --- /dev/null +++ b/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 + } +});