commit without balances
This commit is contained in:
commit
676a87ddc4
8
.idea/.gitignore
vendored
Normal file
8
.idea/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
12
.idea/front_v3.iml
Normal file
12
.idea/front_v3.iml
Normal file
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
6
.idea/inspectionProfiles/Project_Default.xml
Normal file
6
.idea/inspectionProfiles/Project_Default.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
8
.idea/modules.xml
Normal file
8
.idea/modules.xml
Normal file
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/front_v3.iml" filepath="$PROJECT_DIR$/.idea/front_v3.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
6
.idea/vcs.xml
Normal file
6
.idea/vcs.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
3
project/.bolt/config.json
Normal file
3
project/.bolt/config.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"template": "bolt-vite-react-ts"
|
||||
}
|
8
project/.bolt/prompt
Normal file
8
project/.bolt/prompt
Normal file
@ -0,0 +1,8 @@
|
||||
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
|
||||
|
||||
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
|
||||
|
||||
Use icons from lucide-react for logos.
|
||||
|
||||
Use stock photos from unsplash where appropriate, only valid URLs you know exist. Do not download the images, only link to them in image tags.
|
||||
|
31
project/.dockerignore
Normal file
31
project/.dockerignore
Normal file
@ -0,0 +1,31 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Build output
|
||||
dist
|
||||
build
|
||||
|
||||
# Version control
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
1
project/.env
Normal file
1
project/.env
Normal file
@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost:8080
|
24
project/.gitignore
vendored
Normal file
24
project/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
30
project/Dockerfile
Normal file
30
project/Dockerfile
Normal file
@ -0,0 +1,30 @@
|
||||
FROM node:20-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built assets from build stage
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
28
project/eslint.config.js
Normal file
28
project/eslint.config.js
Normal file
@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
16
project/index.html
Normal file
16
project/index.html
Normal file
@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Molva Admin Panel</title>
|
||||
<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=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
54
project/nginx.conf
Normal file
54
project/nginx.conf
Normal file
@ -0,0 +1,54 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
|
||||
# Проксирование запросов к бэкенду
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:8080/; # Проксируем запросы к бэкенду
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Добавляем заголовки CORS (если нужно)
|
||||
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
|
||||
|
||||
# Обработка предварительных запросов (OPTIONS)
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
|
||||
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# Обслуживание статики фронтенда
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Кэширование статических файлов
|
||||
location /assets {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
|
||||
# Отключение кэширования для service worker
|
||||
location /service-worker.js {
|
||||
expires -1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||
}
|
||||
}
|
7184
project/package-lock.json
generated
Normal file
7184
project/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
project/package.json
Normal file
36
project/package.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "molva-admin-panel",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.8",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-hot-toast": "^2.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.1",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.11",
|
||||
"globals": "^15.9.0",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.3.0",
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
6
project/postcss.config.js
Normal file
6
project/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
58
project/src/App.tsx
Normal file
58
project/src/App.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import Layout from './components/Layout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import TicketBalances from './pages/TicketBalances'
|
||||
import CompanyValidation from './pages/CompanyValidation'
|
||||
import UserValidation from './pages/UserValidation'
|
||||
import Login from './pages/Login'
|
||||
|
||||
function App() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user is authenticated
|
||||
const authStatus = localStorage.getItem('isAuthenticated')
|
||||
const user = localStorage.getItem('user')
|
||||
if (authStatus === 'true' && user) {
|
||||
setIsAuthenticated(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogin = () => {
|
||||
setIsAuthenticated(true)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('user')
|
||||
localStorage.removeItem('isAuthenticated')
|
||||
setIsAuthenticated(false)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<>
|
||||
<Login onLogin={handleLogin} />
|
||||
<Toaster position="top-right" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Layout onLogout={handleLogout}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/tickets" element={<TicketBalances />} />
|
||||
<Route path="/companies" element={<CompanyValidation />} />
|
||||
<Route path="/users" element={<UserValidation />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
<Toaster position="top-right" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
110
project/src/api/index.ts
Normal file
110
project/src/api/index.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import axios from 'axios'
|
||||
import type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
Ticket,
|
||||
TicketInfo,
|
||||
GetValidationTicketsRequest,
|
||||
GetValidationTicketsResponse,
|
||||
GetValidationTicketByIdResponse,
|
||||
UpdateValidationStatusByTicketIdRequest,
|
||||
UpdateValidationStatusByTicketIdResponse,
|
||||
CompanyValidationTicket,
|
||||
DashboardData
|
||||
} from '../types/api'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Add auth token to requests if it exists
|
||||
api.interceptors.request.use((config) => {
|
||||
try {
|
||||
const userStr = localStorage.getItem('user')
|
||||
if (userStr) {
|
||||
const user = JSON.parse(userStr)
|
||||
if (user && user.ID) { // Changed from id to ID to match Go struct
|
||||
config.headers.Authorization = `Bearer ${user.ID}`
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting auth header:', error)
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Add response interceptor for error handling
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
// Ensure the response data is an object
|
||||
if (response.data && typeof response.data === 'object') {
|
||||
return response
|
||||
}
|
||||
throw new Error('Неверный формат ответа от сервера')
|
||||
},
|
||||
(error) => {
|
||||
if (error.response) {
|
||||
// Server responded with error status
|
||||
const message = error.response.data?.error || 'Ошибка сервера'
|
||||
throw new Error(message)
|
||||
} else if (error.request) {
|
||||
// Request made but no response
|
||||
throw new Error('Нет ответа от сервера. Проверьте подключение.')
|
||||
} else {
|
||||
// Request setup error
|
||||
throw new Error('Ошибка при выполнении запроса')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Auth endpoints
|
||||
export const auth = {
|
||||
register: (data: RegisterRequest): Promise<{ data: RegisterResponse }> =>
|
||||
api.post('/api/v1/register', data),
|
||||
|
||||
login: (data: LoginRequest): Promise<{ data: LoginResponse }> =>
|
||||
api.post('/api/v1/login', data),
|
||||
}
|
||||
|
||||
// User validation endpoints
|
||||
export const userValidation = {
|
||||
getValidationTickets: (params?: GetValidationTicketsRequest): Promise<{ data: GetValidationTicketsResponse }> =>
|
||||
api.get('/api/v1/users/validations', { params }),
|
||||
|
||||
getValidationTicketById: (id: string): Promise<{ data: GetValidationTicketByIdResponse }> =>
|
||||
api.get(`/api/v1/users/validation/${id}`),
|
||||
|
||||
updateValidationStatus: (
|
||||
id: string,
|
||||
data: UpdateValidationStatusByTicketIdRequest
|
||||
): Promise<{ data: UpdateValidationStatusByTicketIdResponse }> =>
|
||||
api.put(`/api/v1/users/validation/${id}`, data),
|
||||
}
|
||||
|
||||
// Company validation endpoints
|
||||
export const companyValidation = {
|
||||
getValidationTickets: (): Promise<{ data: CompanyValidationTicket[] }> =>
|
||||
api.get('/api/v1/companies/validations'),
|
||||
|
||||
getValidationTicketById: (id: string): Promise<{ data: CompanyValidationTicket }> =>
|
||||
api.get(`/api/v1/companies/validation/${id}`),
|
||||
|
||||
updateValidationStatus: (
|
||||
id: string,
|
||||
data: UpdateValidationStatusByTicketIdRequest
|
||||
): Promise<{ data: UpdateValidationStatusByTicketIdResponse }> =>
|
||||
api.put(`/api/v1/companies/validation/${id}`, data),
|
||||
}
|
||||
|
||||
// Dashboard endpoints
|
||||
export const dashboard = {
|
||||
getDashboardData: (): Promise<{ data: DashboardData }> =>
|
||||
api.get('/api/v1/dashboard'),
|
||||
}
|
158
project/src/components/Layout.tsx
Normal file
158
project/src/components/Layout.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
import React from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Ticket,
|
||||
Building2,
|
||||
Users,
|
||||
Bell,
|
||||
Search,
|
||||
LogOut,
|
||||
Menu,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children, onLogout }) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false)
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Дашборд', href: '/', icon: LayoutDashboard },
|
||||
{ name: 'Тикеты на балансы', href: '/tickets', icon: Ticket },
|
||||
{ name: 'Валидация компаний', href: '/companies', icon: Building2 },
|
||||
{ name: 'Валидация пользователей', href: '/users', icon: Users },
|
||||
]
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return location.pathname === path
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token')
|
||||
onLogout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Mobile menu */}
|
||||
<div className={`fixed inset-0 z-40 lg:hidden ${isMobileMenuOpen ? 'block' : 'hidden'}`}>
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" onClick={() => setIsMobileMenuOpen(false)}></div>
|
||||
<div className="fixed inset-y-0 left-0 flex flex-col w-64 max-w-xs bg-white">
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-200">
|
||||
<div className="flex items-center">
|
||||
<span className="text-xl font-semibold text-primary">Molva Admin</span>
|
||||
</div>
|
||||
<button onClick={() => setIsMobileMenuOpen(false)}>
|
||||
<X className="w-6 h-6 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<nav className="px-3 py-4">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={`flex items-center px-3 py-2 mt-2 text-sm font-medium rounded-md ${
|
||||
isActive(item.href)
|
||||
? 'bg-primary-50 text-primary'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
<item.icon className={`mr-3 h-5 w-5 ${isActive(item.href) ? 'text-primary' : 'text-gray-500'}`} />
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
|
||||
<div className="flex flex-col flex-1 min-h-0 bg-white border-r border-gray-200">
|
||||
<div className="flex items-center h-16 px-6 border-b border-gray-200">
|
||||
<span className="text-xl font-semibold text-primary">Molva Admin</span>
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 overflow-y-auto">
|
||||
<nav className="flex-1 px-3 py-4">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={`flex items-center px-3 py-2 mt-2 text-sm font-medium rounded-md ${
|
||||
isActive(item.href)
|
||||
? 'bg-primary-50 text-primary'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<item.icon className={`mr-3 h-5 w-5 ${isActive(item.href) ? 'text-primary' : 'text-gray-500'}`} />
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="lg:pl-64">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between h-16 px-4 bg-white border-b border-gray-200 sm:px-6 lg:px-8">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center p-2 text-gray-500 rounded-md lg:hidden hover:bg-gray-100 hover:text-gray-600"
|
||||
onClick={() => setIsMobileMenuOpen(true)}
|
||||
>
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
<div className="flex flex-1 justify-between">
|
||||
<div className="flex flex-1 ml-4 lg:ml-0">
|
||||
<div className="relative w-full max-w-xs">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<Search className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="block w-full py-2 pl-10 pr-3 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Поиск..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center ml-4 md:ml-6">
|
||||
<button className="p-1 text-gray-500 rounded-full hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
|
||||
<Bell className="w-6 h-6" />
|
||||
</button>
|
||||
<div className="ml-3 relative">
|
||||
<div className="flex items-center">
|
||||
<div className="h-8 w-8 rounded-full bg-primary-100 flex items-center justify-center text-primary-600 font-medium">
|
||||
АА
|
||||
</div>
|
||||
<span className="ml-2 text-sm font-medium text-gray-700 hidden md:block">Админ</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="ml-4 p-1 text-gray-500 rounded-full hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="p-4 sm:p-6 lg:p-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
59
project/src/index.css
Normal file
59
project/src/index.css
Normal file
@ -0,0 +1,59 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900 font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary text-white hover:bg-primary-600 focus:ring-primary-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-secondary text-white hover:bg-secondary-600 focus:ring-secondary-500;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 focus:ring-primary-500;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply bg-green-100 text-green-800;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply bg-yellow-100 text-yellow-800;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
@apply bg-red-100 text-red-800;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
@apply bg-blue-100 text-blue-800;
|
||||
}
|
||||
}
|
13
project/src/main.tsx
Normal file
13
project/src/main.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
466
project/src/pages/CompanyValidation.tsx
Normal file
466
project/src/pages/CompanyValidation.tsx
Normal file
@ -0,0 +1,466 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Filter, Search, ChevronDown, ChevronUp, Check, X, ExternalLink, FileText, Download, Building2 } from 'lucide-react'
|
||||
import { companyValidation } from '../api'
|
||||
import { CompanyValidationTicket } from '../types/api'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const CompanyValidation: React.FC = () => {
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
const [sortColumn, setSortColumn] = useState('date')
|
||||
const [sortDirection, setSortDirection] = useState('desc')
|
||||
const [selectedStatus, setSelectedStatus] = useState<string[]>([])
|
||||
const [selectedType, setSelectedType] = useState<string[]>([])
|
||||
const [activeCompany, setActiveCompany] = useState<string | null>(null)
|
||||
const [tickets, setTickets] = useState<CompanyValidationTicket[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [activeTicket, setActiveTicket] = useState<CompanyValidationTicket | null>(null)
|
||||
const [comment, setComment] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchTickets()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCompany) {
|
||||
fetchTicketDetails(activeCompany)
|
||||
}
|
||||
}, [activeCompany])
|
||||
|
||||
const fetchTickets = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await companyValidation.getValidationTickets()
|
||||
setTickets(response.data)
|
||||
} catch (error) {
|
||||
console.error('Error fetching tickets:', error)
|
||||
toast.error('Ошибка при загрузке тикетов')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTicketDetails = async (id: string) => {
|
||||
try {
|
||||
const response = await companyValidation.getValidationTicketById(id)
|
||||
setActiveTicket(response.data)
|
||||
} catch (error) {
|
||||
console.error('Error fetching ticket details:', error)
|
||||
toast.error('Ошибка при загрузке деталей тикета')
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusUpdate = async (id: string, status: 'approved' | 'rejected') => {
|
||||
try {
|
||||
await companyValidation.updateValidationStatus(id, { status, comment })
|
||||
toast.success(`Статус успешно обновлен: ${status === 'approved' ? 'Одобрен' : 'Отклонен'}`)
|
||||
fetchTickets() // Refresh the list
|
||||
setActiveCompany(null) // Reset active company
|
||||
setComment('') // Reset comment
|
||||
} catch (error) {
|
||||
console.error('Error updating status:', error)
|
||||
toast.error('Ошибка при обновлении статуса')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortColumn(column)
|
||||
setSortDirection('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStatus = (status: string) => {
|
||||
if (selectedStatus.includes(status)) {
|
||||
setSelectedStatus(selectedStatus.filter(s => s !== status))
|
||||
} else {
|
||||
setSelectedStatus([...selectedStatus, status])
|
||||
}
|
||||
}
|
||||
|
||||
const toggleType = (type: string) => {
|
||||
if (selectedType.includes(type)) {
|
||||
setSelectedType(selectedType.filter(t => t !== type))
|
||||
} else {
|
||||
setSelectedType([...selectedType, type])
|
||||
}
|
||||
}
|
||||
|
||||
const filteredCompanies = tickets.filter(company => {
|
||||
let statusMatch = true
|
||||
let typeMatch = true
|
||||
|
||||
if (selectedStatus.length > 0) {
|
||||
statusMatch = selectedStatus.includes(company.status)
|
||||
}
|
||||
|
||||
if (selectedType.length > 0) {
|
||||
typeMatch = selectedType.includes(company.type)
|
||||
}
|
||||
|
||||
return statusMatch && typeMatch
|
||||
})
|
||||
|
||||
const sortedCompanies = [...filteredCompanies].sort((a, b) => {
|
||||
if (sortColumn === 'date') {
|
||||
return sortDirection === 'asc'
|
||||
? new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
: new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
} else {
|
||||
const valueA = a[sortColumn as keyof typeof a].toString().toLowerCase()
|
||||
const valueB = b[sortColumn as keyof typeof b].toString().toLowerCase()
|
||||
return sortDirection === 'asc'
|
||||
? valueA.localeCompare(valueB)
|
||||
: valueB.localeCompare(valueA)
|
||||
}
|
||||
})
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return <span className="badge badge-success">Одобрена</span>
|
||||
case 'rejected':
|
||||
return <span className="badge badge-danger">Отклонена</span>
|
||||
case 'pending':
|
||||
return <span className="badge badge-warning">На проверке</span>
|
||||
default:
|
||||
return <span className="badge badge-info">В обработке</span>
|
||||
}
|
||||
}
|
||||
|
||||
const getSortIcon = (column: string) => {
|
||||
if (sortColumn !== column) return null
|
||||
return sortDirection === 'asc' ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Валидация компаний</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Проверка и подтверждение компаний для работы в системе
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="card">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="relative w-full md:w-64">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<Search className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="input pl-10"
|
||||
placeholder="Поиск по названию, ИНН..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="btn btn-outline flex items-center gap-2"
|
||||
onClick={() => setFilterOpen(!filterOpen)}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
Фильтры
|
||||
{(selectedStatus.length > 0 || selectedType.length > 0) && (
|
||||
<span className="bg-primary text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{selectedStatus.length + selectedType.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filterOpen && (
|
||||
<div className="mt-4 p-4 border border-gray-200 rounded-md bg-gray-50">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-2">Статус</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
className={`btn ${selectedStatus.includes('pending') ? 'bg-yellow-100 text-yellow-800 border-yellow-300' : 'btn-outline'}`}
|
||||
onClick={() => toggleStatus('pending')}
|
||||
>
|
||||
На проверке
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${selectedStatus.includes('approved') ? 'bg-green-100 text-green-800 border-green-300' : 'btn-outline'}`}
|
||||
onClick={() => toggleStatus('approved')}
|
||||
>
|
||||
Одобрена
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${selectedStatus.includes('rejected') ? 'bg-red-100 text-red-800 border-red-300' : 'btn-outline'}`}
|
||||
onClick={() => toggleStatus('rejected')}
|
||||
>
|
||||
Отклонена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-2">Тип компании</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
className={`btn ${selectedType.includes('ООО') ? 'bg-blue-100 text-blue-800 border-blue-300' : 'btn-outline'}`}
|
||||
onClick={() => toggleType('ООО')}
|
||||
>
|
||||
ООО
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${selectedType.includes('ИП') ? 'bg-blue-100 text-blue-800 border-blue-300' : 'btn-outline'}`}
|
||||
onClick={() => toggleType('ИП')}
|
||||
>
|
||||
ИП
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${selectedType.includes('АО') ? 'bg-blue-100 text-blue-800 border-blue-300' : 'btn-outline'}`}
|
||||
onClick={() => toggleType('АО')}
|
||||
>
|
||||
АО
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${selectedType.includes('ЗАО') ? 'bg-blue-100 text-blue-800 border-blue-300' : 'btn-outline'}`}
|
||||
onClick={() => toggleType('ЗАО')}
|
||||
>
|
||||
ЗАО
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => handleSort('id')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
ID {getSortIcon('id')}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Название {getSortIcon('name')}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => handleSort('type')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Тип {getSortIcon('type')}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => handleSort('status')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Статус {getSortIcon('status')}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => handleSort('date')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Дата {getSortIcon('date')}
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Действия
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{sortedCompanies.map((company) => (
|
||||
<tr
|
||||
key={company.id}
|
||||
className={`hover:bg-gray-50 cursor-pointer ${activeCompany === company.id ? 'bg-primary-50' : ''}`}
|
||||
onClick={() => setActiveCompany(company.id)}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-primary">
|
||||
{company.id}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<div>{company.name}</div>
|
||||
<div className="text-gray-500 text-xs">{company.email}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{company.type}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{getStatusBadge(company.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{company.date}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end gap-2">
|
||||
{company.status === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
className="p-1 text-green-600 hover:text-green-900"
|
||||
title="Одобрить"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStatusUpdate(company.id, 'approved');
|
||||
}}
|
||||
>
|
||||
<Check className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 text-red-600 hover:text -900"
|
||||
title="Отклонить"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStatusUpdate(company.id, 'rejected');
|
||||
}}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="p-1 text-primary hover:text-primary-700"
|
||||
title="Подробнее"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setActiveCompany(company.id);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700">
|
||||
Показано <span className="font-medium">{sortedCompanies.length}</span> из <span className="font-medium">{tickets.length}</span> компаний
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
{activeTicket ? (
|
||||
<div className="card">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">{activeTicket.name}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{activeTicket.id}</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Тип компании</h4>
|
||||
<p className="mt-1 text-sm text-gray-900">{activeTicket.type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Владелец</h4>
|
||||
<p className="mt-1 text-sm text-gray-900">{activeTicket.owner}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Email</h4>
|
||||
<p className="mt-1 text-sm text-gray-900">{activeTicket.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Статус</h4>
|
||||
<div className="mt-1">{getStatusBadge(activeTicket.status)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Дата регистрации</h4>
|
||||
<p className="mt-1 text-sm text-gray-900">{activeTicket.date}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Документы</h4>
|
||||
<div className="mt-2 space-y-2">
|
||||
{activeTicket.documents.map((doc, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded-md">
|
||||
<div className="flex items-center">
|
||||
<FileText className="w-5 h-5 text-gray-500 mr-2" />
|
||||
<span className="text-sm text-gray-900">{doc}</span>
|
||||
</div>
|
||||
<button className="text-primary hover:text-primary-700">
|
||||
<Download className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{activeTicket.status === 'pending' && (
|
||||
<div className="p-6 border-t border-gray-200">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="btn btn-primary flex-1"
|
||||
onClick={() => handleStatusUpdate(activeTicket.id, 'approved')}
|
||||
>
|
||||
Одобрить
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline text-red-600 border-red-300 hover:bg-red-50 flex-1"
|
||||
onClick={() => handleStatusUpdate(activeTicket.id, 'rejected')}
|
||||
>
|
||||
Отклонить
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label htmlFor="comment" className="label">Комментарий</label>
|
||||
<textarea
|
||||
id="comment"
|
||||
rows={3}
|
||||
className="input"
|
||||
placeholder="Добавьте комментарий к решению..."
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card p-6 text-center">
|
||||
<div className="text-gray-500">
|
||||
<Building2 className="w-12 h-12 mx-auto text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Выберите компанию</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Выберите компанию из списка для просмотра подробной информации</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CompanyValidation
|
217
project/src/pages/Dashboard.tsx
Normal file
217
project/src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Ticket,
|
||||
Building2,
|
||||
Users,
|
||||
TrendingUp,
|
||||
AlertTriangle
|
||||
} from 'lucide-react'
|
||||
import { dashboard } from '../api'
|
||||
import { DashboardData } from '../types/api'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData()
|
||||
}, [])
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
const response = await dashboard.getDashboardData()
|
||||
setData(response.data)
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard data:', error)
|
||||
toast.error('Ошибка при загрузке данных дашборда')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{ name: 'Тикеты на рассмотрении', value: data?.stats.pendingTickets.toString() || '0', icon: Ticket, color: 'bg-blue-500' },
|
||||
{ name: 'Компании на проверке', value: data?.stats.pendingCompanies.toString() || '0', icon: Building2, color: 'bg-yellow-500' },
|
||||
{ name: 'Пользователи на проверке', value: data?.stats.pendingUsers.toString() || '0', icon: Users, color: 'bg-purple-500' },
|
||||
{ name: 'Всего активных пользователей', value: data?.stats.activeUsers.toString() || '0', icon: TrendingUp, color: 'bg-green-500' },
|
||||
]
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return <span className="badge badge-success">Одобрен</span>
|
||||
case 'rejected':
|
||||
return <span className="badge badge-danger">Отклонен</span>
|
||||
case 'pending':
|
||||
return <span className="badge badge-warning">На рассмотрении</span>
|
||||
default:
|
||||
return <span className="badge badge-info">В обработке</span>
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Панель управления</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Обзор активности и задач, требующих вашего внимания
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.name} className="card p-5">
|
||||
<div className="flex items-center">
|
||||
<div className={`flex-shrink-0 rounded-md p-3 ${stat.color}`}>
|
||||
<stat.icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div className="ml-5">
|
||||
<p className="text-sm font-medium text-gray-500 truncate">{stat.name}</p>
|
||||
<p className="mt-1 text-xl font-semibold text-gray-900">{stat.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
{data && data.stats.pendingTickets > 0 && (
|
||||
<div className="mb-8">
|
||||
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-yellow-700">
|
||||
<span className="font-medium">Внимание!</span> У вас есть {data.stats.pendingTickets} тикетов, ожидающих рассмотрения.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Recent Tickets */}
|
||||
<div className="card">
|
||||
<div className="px-6 py-5 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">Последние тикеты</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
ID
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Пользователь
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Сумма
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Статус
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Дата
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{data?.recentTickets.map((ticket) => (
|
||||
<tr key={ticket.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-primary">
|
||||
{ticket.id}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{ticket.user}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{ticket.amount}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{getStatusBadge(ticket.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{ticket.date}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200">
|
||||
<a href="/tickets" className="text-sm font-medium text-primary hover:text-primary-600">
|
||||
Посмотреть все тикеты →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending Companies */}
|
||||
<div className="card">
|
||||
<div className="px-6 py-5 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">Компании на проверке</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
ID
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Название
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Тип
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Дата
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{data?.pendingCompanies.map((company) => (
|
||||
<tr key={company.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-primary">
|
||||
{company.id}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{company.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{company.type}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{company.date}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200">
|
||||
<a href="/companies" className="text-sm font-medium text-primary hover:text-primary-600">
|
||||
Посмотреть все компании →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
131
project/src/pages/Login.tsx
Normal file
131
project/src/pages/Login.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Lock, Mail } from 'lucide-react'
|
||||
import { auth } from '../api'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: () => void
|
||||
}
|
||||
|
||||
const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!email || !password) {
|
||||
toast.error('Пожалуйста, заполните все поля')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const { data } = await auth.login({ email, password })
|
||||
|
||||
if (data && typeof data === 'object' && 'ID' in data && 'Email' in data) {
|
||||
// Store user info in localStorage
|
||||
localStorage.setItem('user', JSON.stringify(data))
|
||||
localStorage.setItem('isAuthenticated', 'true')
|
||||
|
||||
// Update authentication state and redirect
|
||||
onLogin()
|
||||
navigate('/', { replace: true })
|
||||
|
||||
toast.success('Успешный вход')
|
||||
} else {
|
||||
console.error('Invalid response format:', data)
|
||||
throw new Error('Неверный формат ответа от сервера')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
|
||||
const errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: 'Произошла ошибка при входе в систему'
|
||||
|
||||
toast.error(errorMessage)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Molva Admin Panel
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Войдите в систему для доступа к панели администратора
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="email-address" className="sr-only">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="email-address"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 pl-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Пароль
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 pl-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Пароль"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Вход...' : 'Войти'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
420
project/src/pages/TicketBalances.tsx
Normal file
420
project/src/pages/TicketBalances.tsx
Normal file
@ -0,0 +1,420 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Filter, Search, ChevronDown, ChevronUp, Check, X, ExternalLink, FileText, Download, CreditCard } from 'lucide-react'
|
||||
import { userValidation } from '../api'
|
||||
import { Ticket, GetValidationTicketsRequest, UpdateValidationStatusByTicketIdRequest } from '../types/api'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const TicketBalances: React.FC = () => {
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
const [sortColumn, setSortColumn] = useState('date')
|
||||
const [sortDirection, setSortDirection] = useState('desc')
|
||||
const [selectedStatus, setSelectedStatus] = useState<string[]>([])
|
||||
const [ticketList, setTicketList] = useState<Ticket[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [activeTicket, setActiveTicket] = useState<string | null>(null)
|
||||
const [ticketDetails, setTicketDetails] = useState<Ticket | null>(null)
|
||||
const [comment, setComment] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchTickets()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTicket) {
|
||||
fetchTicketDetails(activeTicket)
|
||||
}
|
||||
}, [activeTicket])
|
||||
|
||||
const fetchTickets = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await userValidation.getValidationTickets()
|
||||
setTicketList(response.data.tickets)
|
||||
} catch (error) {
|
||||
console.error('Error fetching tickets:', error)
|
||||
toast.error('Ошибка при загрузке тикетов')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTicketDetails = async (id: string) => {
|
||||
try {
|
||||
const response = await userValidation.getValidationTicketById(id)
|
||||
setTicketDetails(response.data.ticket)
|
||||
} catch (error) {
|
||||
console.error('Error fetching ticket details:', error)
|
||||
toast.error('Ошибка при загрузке деталей тикета')
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusUpdate = async (id: string, newStatus: string) => {
|
||||
if (!ticketDetails) return
|
||||
|
||||
const updateData: UpdateValidationStatusByTicketIdRequest = {
|
||||
old_status: ticketDetails.status,
|
||||
new_status: newStatus,
|
||||
corrections: comment || undefined
|
||||
}
|
||||
|
||||
try {
|
||||
await userValidation.updateValidationStatus(id, updateData)
|
||||
toast.success(`Статус успешно обновлен: ${newStatus === 'approved' ? 'Одобрен' : 'Отклонен'}`)
|
||||
fetchTickets()
|
||||
setActiveTicket(null)
|
||||
setComment('')
|
||||
} catch (error) {
|
||||
console.error('Error updating status:', error)
|
||||
toast.error('Ошибка при обновлении статуса')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortColumn(column)
|
||||
setSortDirection('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStatus = (status: string) => {
|
||||
if (selectedStatus.includes(status)) {
|
||||
setSelectedStatus(selectedStatus.filter(s => s !== status))
|
||||
} else {
|
||||
setSelectedStatus([...selectedStatus, status])
|
||||
}
|
||||
}
|
||||
|
||||
const filteredTickets = ticketList.filter(ticket => {
|
||||
if (selectedStatus.length === 0) return true
|
||||
return selectedStatus.includes(ticket.status)
|
||||
})
|
||||
|
||||
const sortedTickets = [...filteredTickets].sort((a, b) => {
|
||||
if (sortColumn === 'last_update') {
|
||||
return sortDirection === 'asc'
|
||||
? new Date(a.last_update).getTime() - new Date(b.last_update).getTime()
|
||||
: new Date(b.last_update).getTime() - new Date(a.last_update).getTime()
|
||||
} else {
|
||||
const valueA = a[sortColumn as keyof typeof a]?.toString().toLowerCase() ?? ''
|
||||
const valueB = b[sortColumn as keyof typeof b]?.toString().toLowerCase() ?? ''
|
||||
return sortDirection === 'asc'
|
||||
? valueA.localeCompare(valueB)
|
||||
: valueB.localeCompare(valueA)
|
||||
}
|
||||
})
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return <span className="badge badge-success">Одобрен</span>
|
||||
case 'rejected':
|
||||
return <span className="badge badge-danger">Отклонен</span>
|
||||
case 'pending':
|
||||
return <span className="badge badge-warning">На рассмотрении</span>
|
||||
default:
|
||||
return <span className="badge badge-info">В обработке</span>
|
||||
}
|
||||
}
|
||||
|
||||
const getSortIcon = (column: string) => {
|
||||
if (sortColumn !== column) return null
|
||||
return sortDirection === 'asc' ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Тикеты на балансы</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Управление запросами на пополнение и вывод средств
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="card">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="relative w-full md:w-64">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<Search className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="input pl-10"
|
||||
placeholder="Поиск по ID, имени..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="btn btn-outline flex items-center gap-2"
|
||||
onClick={() => setFilterOpen(!filterOpen)}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
Фильтры
|
||||
{selectedStatus.length > 0 && (
|
||||
<span className="bg-primary text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{selectedStatus.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button className="btn btn-primary">
|
||||
Экспорт
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filterOpen && (
|
||||
<div className="mt-4 p-4 border border-gray-200 rounded-md bg-gray-50">
|
||||
<h4 className="font-medium text-gray-700 mb-2">Статус</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
className={`btn ${selectedStatus.includes('pending') ? 'bg-yellow-100 text-yellow-800 border-yellow-300' : 'btn-outline'}`}
|
||||
onClick={() => toggleStatus('pending')}
|
||||
>
|
||||
На рассмотрении
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${selectedStatus.includes('approved') ? 'bg-green-100 text-green-800 border-green-300' : 'btn-outline'}`}
|
||||
onClick={() => toggleStatus('approved')}
|
||||
>
|
||||
Одобрен
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${selectedStatus.includes('rejected') ? 'bg-red-100 text-red-800 border-red-300' : 'btn-outline'}`}
|
||||
onClick={() => toggleStatus('rejected')}
|
||||
>
|
||||
Отклонен
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => handleSort('ticket_id')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
ID {getSortIcon('ticket_id')}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Пользователь {getSortIcon('name')}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => handleSort('description')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Описание {getSortIcon('description')}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => handleSort('status')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Статус {getSortIcon('status')}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => handleSort('last_update')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Дата {getSortIcon('last_update')}
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Действия
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{sortedTickets.map((ticket) => (
|
||||
<tr
|
||||
key={ticket.ticket_id}
|
||||
className={`hover:bg-gray-50 cursor-pointer ${activeTicket === ticket.ticket_id ? 'bg-primary-50' : ''}`}
|
||||
onClick={() => setActiveTicket(ticket.ticket_id)}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-primary">
|
||||
{ticket.ticket_id}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<div>{ticket.name}</div>
|
||||
<div className="text-gray-500 text-xs">{ticket.email}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{ticket.description || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{getStatusBadge(ticket.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(ticket.last_update).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end gap-2">
|
||||
{ticket.status === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
className="p-1 text-green-600 hover:text-green-900"
|
||||
title="Одобрить"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleStatusUpdate(ticket.ticket_id, 'approved')
|
||||
}}
|
||||
>
|
||||
<Check className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 text-red-600 hover:text-red-900"
|
||||
title="Отклонить"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleStatusUpdate(ticket.ticket_id, 'rejected')
|
||||
}}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="p-1 text-primary hover:text-primary-700"
|
||||
title="Подробнее"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setActiveTicket(ticket.ticket_id)
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700">
|
||||
Показано <span className="font-medium">{sortedTickets.length}</span> из <span className="font-medium">{ticketList.length}</span> тикетов
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
{ticketDetails ? (
|
||||
<div className="card">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center">
|
||||
<div className="h-12 w-12 rounded-full bg-primary-100 flex items-center justify-center text-primary-600 font-medium text-lg">
|
||||
{ticketDetails.name.split(' ').map(n => n[0]).join('')}
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">{ticketDetails.name}</h3>
|
||||
<p className="text-sm text-gray-500">{ticketDetails.ticket_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Email</h4>
|
||||
<p className="mt-1 text-sm text-gray-900">{ticketDetails.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Телефон</h4>
|
||||
<p className="mt-1 text-sm text-gray-900">{ticketDetails.phone}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Статус</h4>
|
||||
<div className="mt-1">{getStatusBadge(ticketDetails.status)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Дата обновления</h4>
|
||||
<p className="mt-1 text-sm text-gray-900">
|
||||
{new Date(ticketDetails.last_update).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
{ticketDetails.description && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Описание</h4>
|
||||
<p className="mt-1 text-sm text-gray-900">{ticketDetails.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{ticketDetails.status === 'pending' && (
|
||||
<div className="p-6 border-t border-gray-200">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="btn btn-primary flex-1"
|
||||
onClick={() => handleStatusUpdate(ticketDetails.ticket_id, 'approved')}
|
||||
>
|
||||
Одобрить
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline text-red-600 border-red-300 hover:bg-red-50 flex-1"
|
||||
onClick={() => handleStatusUpdate(ticketDetails.ticket_id, 'rejected')}
|
||||
>
|
||||
Отклонить
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label htmlFor="comment" className="label">Комментарий</label>
|
||||
<textarea
|
||||
id="comment"
|
||||
rows={3}
|
||||
className="input"
|
||||
placeholder="Добавьте комментарий к решению..."
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card p-6 text-center">
|
||||
<div className="text-gray-500">
|
||||
<CreditCard className="w-12 h-12 mx-auto text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Выберите тикет</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Выберите тикет из списка для просмотра подробной информации</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TicketBalances
|
416
project/src/pages/UserValidation.tsx
Normal file
416
project/src/pages/UserValidation.tsx
Normal file
@ -0,0 +1,416 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Filter, Search, ChevronDown, ChevronUp, Check, X, ExternalLink, User, Download, FileText } from 'lucide-react'
|
||||
import { userValidation } from '../api'
|
||||
import { ValidationTicket } from '../types/api'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const UserValidation: React.FC = () => {
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
const [sortColumn, setSortColumn] = useState('date')
|
||||
const [sortDirection, setSortDirection] = useState('desc')
|
||||
const [selectedStatus, setSelectedStatus] = useState<string[]>([])
|
||||
const [activeUser, setActiveUser] = useState<string | null>(null)
|
||||
const [tickets, setTickets] = useState<ValidationTicket[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [activeTicket, setActiveTicket] = useState<ValidationTicket | null>(null)
|
||||
const [comment, setComment] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchTickets()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeUser) {
|
||||
fetchTicketDetails(activeUser)
|
||||
}
|
||||
}, [activeUser])
|
||||
|
||||
const fetchTickets = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await userValidation.getValidationTickets()
|
||||
setTickets(response.data)
|
||||
} catch (error) {
|
||||
console.error('Error fetching tickets:', error)
|
||||
toast.error('Ошибка при загрузке тикетов')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTicketDetails = async (id: string) => {
|
||||
try {
|
||||
const response = await userValidation.getValidationTicketById(id)
|
||||
setActiveTicket(response.data)
|
||||
} catch (error) {
|
||||
console.error('Error fetching ticket details:', error)
|
||||
toast.error('Ошибка при загрузке деталей тикета')
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusUpdate = async (id: string, status: 'approved' | 'rejected') => {
|
||||
try {
|
||||
await userValidation.updateValidationStatus(id, { status, comment })
|
||||
toast.success(`Статус успешно обновлен: ${status === 'approved' ? 'Одобрен' : 'Отклонен'}`)
|
||||
fetchTickets() // Refresh the list
|
||||
setActiveUser(null) // Reset active user
|
||||
setComment('') // Reset comment
|
||||
} catch (error) {
|
||||
console.error('Error updating status:', error)
|
||||
toast.error('Ошибка при обновлении статуса')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortColumn(column)
|
||||
setSortDirection('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStatus = (status: string) => {
|
||||
if (selectedStatus.includes(status)) {
|
||||
setSelectedStatus(selectedStatus.filter(s => s !== status))
|
||||
} else {
|
||||
setSelectedStatus([...selectedStatus, status])
|
||||
}
|
||||
}
|
||||
|
||||
const filteredUsers = tickets.filter(user => {
|
||||
if (selectedStatus.length === 0) return true
|
||||
return selectedStatus.includes(user.status)
|
||||
})
|
||||
|
||||
const sortedUsers = [...filteredUsers].sort((a, b) => {
|
||||
if (sortColumn === 'date') {
|
||||
return sortDirection === 'asc'
|
||||
? new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
: new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
} else {
|
||||
const valueA = a[sortColumn as keyof typeof a].toString().toLowerCase()
|
||||
const valueB = b[sortColumn as keyof typeof b].toString().toLowerCase()
|
||||
return sortDirection === 'asc'
|
||||
? valueA.localeCompare(valueB)
|
||||
: valueB.localeCompare(valueA)
|
||||
}
|
||||
})
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return <span className="badge badge-success">Подтвержден</span>
|
||||
case 'rejected':
|
||||
return <span className="badge badge-danger">Отклонен</span>
|
||||
case 'pending':
|
||||
return <span className="badge badge-warning">На проверке</span>
|
||||
default:
|
||||
return <span className="badge badge-info">В обработке</span>
|
||||
}
|
||||
}
|
||||
|
||||
const getSortIcon = (column: string) => {
|
||||
if (sortColumn !== column) return null
|
||||
return sortDirection === 'asc' ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Валидация пользователей</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Проверка и подтверждение личности пользователей
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="card">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="relative w-full md:w-64">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<Search className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="input pl-10"
|
||||
placeholder="Поиск по имени, email..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="btn btn-outline flex items-center gap-2"
|
||||
onClick={() => setFilterOpen(!filterOpen)}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
Фильтры
|
||||
{selectedStatus.length > 0 && (
|
||||
<span className="bg-primary text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{selectedStatus.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filterOpen && (
|
||||
<div className="mt-4 p-4 border border-gray-200 rounded-md bg-gray-50">
|
||||
<h4 className="font-medium text-gray-700 mb-2">Статус</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
className={`btn ${selectedStatus.includes('pending') ? 'bg-yellow-100 text-yellow-800 border-yellow-300' : 'btn-outline'}`}
|
||||
onClick={() => toggleStatus('pending')}
|
||||
>
|
||||
На проверке
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${selectedStatus.includes('approved') ? 'bg-green-100 text-green-800 border-green-300' : 'btn-outline'}`}
|
||||
onClick={() => toggleStatus('approved')}
|
||||
>
|
||||
Подтвержден
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${selectedStatus.includes('rejected') ? 'bg-red-100 text-red-800 border-red-300' : 'btn-outline'}`}
|
||||
onClick={() => toggleStatus('rejected')}
|
||||
>
|
||||
Отклонен
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => handleSort('id')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
ID {getSortIcon('id')}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Имя {getSortIcon('name')}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => handleSort('email')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Email {getSortIcon('email')}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => handleSort('status')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Статус {getSortIcon('status')}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => handleSort('date')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Дата {getSortIcon('date')}
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Действия
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{sortedUsers.map((user) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
className={`hover:bg-gray-50 cursor-pointer ${activeUser === user.id ? 'bg-primary-50' : ''}`}
|
||||
onClick={() => setActiveUser(user.id)}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-primary">
|
||||
{user.id}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{user.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{user.email}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{getStatusBadge(user.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{user.date}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end gap-2">
|
||||
{user.status === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
className="p-1 text-green-600 hover:text-green-900"
|
||||
title="Подтвердить"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStatusUpdate(user.id, 'approved');
|
||||
}}
|
||||
>
|
||||
<Check className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 text-red-600 hover:text-red-900"
|
||||
title="Отклонить"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStatusUpdate(user.id, 'rejected');
|
||||
}}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="p-1 text-primary hover:text-primary-700"
|
||||
title="Подробнее"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setActiveUser(user.id);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700">
|
||||
Показано <span className="font-medium">{sortedUsers.length}</span> из <span className="font-medium">{tickets.length}</span> пользователей
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
{activeTicket ? (
|
||||
<div className="card">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center">
|
||||
<div className="h-12 w-12 rounded-full bg-primary-100 flex items-center justify-center text-primary-600 font-medium text-lg">
|
||||
{activeTicket.name.split(' ').map(n => n[0]).join('')}
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">{activeTicket.name}</h3>
|
||||
<p className="text-sm text-gray-500">{activeTicket.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Email</h4>
|
||||
<p className="mt-1 text-sm text-gray-900">{activeTicket.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Телефон</h4>
|
||||
<p className="mt-1 text-sm text-gray-900">{activeTicket.phone}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Статус</h4>
|
||||
<div className="mt-1">{getStatusBadge(activeTicket.status)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Дата регистрации</h4>
|
||||
<p className="mt-1 text-sm text-gray-900">{activeTicket.date}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Документы</h4>
|
||||
<div className="mt-2 space-y-2">
|
||||
{activeTicket.documents.map((doc, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded-md">
|
||||
<div className="flex items-center">
|
||||
<FileText className="w-5 h-5 text-gray-500 mr-2" />
|
||||
<span className="text-sm text-gray-900">{doc}</span>
|
||||
</div>
|
||||
<button className="text-primary hover:text-primary-700">
|
||||
<Download className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{activeTicket.status === 'pending' && (
|
||||
<div className="p-6 border-t border-gray-200">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="btn btn-primary flex-1"
|
||||
onClick={() => handleStatusUpdate(activeTicket.id, 'approved')}
|
||||
>
|
||||
Подтвердить
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline text-red-600 border-red-300 hover:bg-red-50 flex-1"
|
||||
onClick={() => handleStatusUpdate(activeTicket.id, 'rejected')}
|
||||
>
|
||||
Отклонить
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label htmlFor="comment" className="label">Комментарий</label>
|
||||
<textarea
|
||||
id="comment"
|
||||
rows={3}
|
||||
className="input"
|
||||
placeholder="Добавьте комментарий к решению..."
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card p-6 text-center">
|
||||
<div className="text-gray-500">
|
||||
<User className="w-12 h-12 mx-auto text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Выберите пользователя</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Выберите пользователя из списка для просмотра подробной информации</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserValidation
|
97
project/src/types/api.ts
Normal file
97
project/src/types/api.ts
Normal file
@ -0,0 +1,97 @@
|
||||
// Auth types
|
||||
export interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
ID: string // Changed to match Go struct field
|
||||
Email: string // Changed to match Go struct field
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
ID: string
|
||||
Email: string
|
||||
}
|
||||
|
||||
// Validation types
|
||||
export interface Ticket {
|
||||
ticket_id: string
|
||||
client_id: string
|
||||
email: string
|
||||
phone: string
|
||||
name: string
|
||||
client_type?: string
|
||||
last_update: string // Will be converted from time.Time
|
||||
description?: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface TicketInfo {
|
||||
ticket_id: string
|
||||
client_id: string
|
||||
last_update: string // Will be converted from time.Time
|
||||
description?: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface GetValidationTicketsRequest {
|
||||
statusFilter?: string
|
||||
clientTypeFilter?: string
|
||||
sortFilter?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface GetValidationTicketsResponse {
|
||||
tickets: Ticket[]
|
||||
}
|
||||
|
||||
export interface GetValidationTicketByIdRequest {
|
||||
ticket_id: string
|
||||
}
|
||||
|
||||
export interface GetValidationTicketByIdResponse {
|
||||
ticket: Ticket
|
||||
}
|
||||
|
||||
export interface UpdateValidationStatusByTicketIdRequest {
|
||||
old_status: string
|
||||
new_status: string
|
||||
corrections?: string
|
||||
}
|
||||
|
||||
export interface UpdateValidationStatusByTicketIdResponse {
|
||||
ticket_info: TicketInfo
|
||||
}
|
||||
|
||||
// Company validation types
|
||||
export interface CompanyValidationTicket {
|
||||
id: string
|
||||
name: string
|
||||
owner: string
|
||||
email: string
|
||||
type: string
|
||||
status: string
|
||||
date: string
|
||||
documents: string[]
|
||||
}
|
||||
|
||||
// Dashboard types
|
||||
export interface DashboardStats {
|
||||
pendingTickets: number
|
||||
pendingCompanies: number
|
||||
pendingUsers: number
|
||||
activeUsers: number
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
stats: DashboardStats
|
||||
recentTickets: Ticket[]
|
||||
pendingCompanies: CompanyValidationTicket[]
|
||||
}
|
1
project/src/vite-env.d.ts
vendored
Normal file
1
project/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
55
project/tailwind.config.js
Normal file
55
project/tailwind.config.js
Normal file
@ -0,0 +1,55 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#4F46E5',
|
||||
50: '#EBEAFD',
|
||||
100: '#D7D5FB',
|
||||
200: '#AFABF8',
|
||||
300: '#8781F4',
|
||||
400: '#5F57F1',
|
||||
500: '#4F46E5',
|
||||
600: '#2A20D9',
|
||||
700: '#211AAB',
|
||||
800: '#18137D',
|
||||
900: '#0F0C4F',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: '#10B981',
|
||||
50: '#E6F6F0',
|
||||
100: '#CCEEE1',
|
||||
200: '#99DCC3',
|
||||
300: '#66CBA5',
|
||||
400: '#33B987',
|
||||
500: '#10B981',
|
||||
600: '#0D9367',
|
||||
700: '#0A6E4D',
|
||||
800: '#064A33',
|
||||
900: '#03251A',
|
||||
},
|
||||
gray: {
|
||||
50: '#F9FAFB',
|
||||
100: '#F3F4F6',
|
||||
200: '#E5E7EB',
|
||||
300: '#D1D5DB',
|
||||
400: '#9CA3AF',
|
||||
500: '#6B7280',
|
||||
600: '#4B5563',
|
||||
700: '#374151',
|
||||
800: '#1F2937',
|
||||
900: '#111827',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
24
project/tsconfig.app.json
Normal file
24
project/tsconfig.app.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
7
project/tsconfig.json
Normal file
7
project/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
22
project/tsconfig.node.json
Normal file
22
project/tsconfig.node.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
20
project/vite.config.ts
Normal file
20
project/vite.config.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
optimizeDeps: {
|
||||
exclude: ['lucide-react'],
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
// Прокси для всех запросов, начинающихся с /api
|
||||
'/api': {
|
||||
target: 'http://localhost:8080', // Адрес вашего бэкенда
|
||||
changeOrigin: true, // Меняет origin на целевой домен
|
||||
rewrite: (path) => path.replace(/^\/api/, ''), // Убирает /api из пути
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
Loading…
Reference in New Issue
Block a user