added VacancyModeration.tsx

This commit is contained in:
Hermeska 2025-03-27 18:22:53 +03:00
parent 7c960ea1a3
commit 4f539c7a97
6 changed files with 802 additions and 135 deletions

View File

@ -1,24 +1,42 @@
import React, { useEffect, useState } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { Routes, Route, Navigate, useNavigate } 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 VacancyModeration from './pages/VacancyModeration'
import Login from './pages/Login'
function App() {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const navigate = useNavigate()
useEffect(() => {
// Check if user is authenticated
const authStatus = localStorage.getItem('isAuthenticated')
const user = localStorage.getItem('user')
if (authStatus === 'true' && user) {
setIsAuthenticated(true)
try {
// Validate user object
const userData = JSON.parse(user)
if (userData && userData.ID && userData.Email) {
setIsAuthenticated(true)
return
}
} catch (error) {
console.error('Invalid user data:', error)
}
}
}, [])
// If we get here, authentication is invalid
setIsAuthenticated(false)
localStorage.removeItem('user')
localStorage.removeItem('isAuthenticated')
navigate('/login', { replace: true })
}, [navigate])
const handleLogin = () => {
setIsAuthenticated(true)
@ -28,6 +46,7 @@ function App() {
localStorage.removeItem('user')
localStorage.removeItem('isAuthenticated')
setIsAuthenticated(false)
navigate('/login', { replace: true })
}
if (!isAuthenticated) {
@ -47,6 +66,8 @@ function App() {
<Route path="/tickets" element={<TicketBalances />} />
<Route path="/companies" element={<CompanyValidation />} />
<Route path="/users" element={<UserValidation />} />
<Route path="/vacancies" element={<VacancyModeration />} />
<Route path="/login" element={<Navigate to="/" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>

View File

@ -12,7 +12,9 @@ import type {
UpdateValidationStatusByTicketIdRequest,
UpdateValidationStatusByTicketIdResponse,
CompanyValidationTicket,
DashboardData
DashboardData,
Vacancy,
UpdateVacancyRequest
} from '../types/api'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080'
@ -24,26 +26,24 @@ const api = axios.create({
},
})
// Add auth token to requests if it exists
// Add auth credentials to requests if they exist
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}`
}
const credentials = localStorage.getItem('credentials')
if (credentials) {
config.headers.Authorization = `Basic ${credentials}`
}
} catch (error) {
console.error('Error setting auth header:', error)
}
return config
}, (error) => {
return Promise.reject(error)
})
// 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
}
@ -51,14 +51,25 @@ api.interceptors.response.use(
},
(error) => {
if (error.response) {
// Server responded with error status
const message = error.response.data?.error || 'Ошибка сервера'
// Handle 401 Unauthorized errors
if (error.response.status === 401) {
// Clear authentication state
localStorage.removeItem('credentials')
localStorage.removeItem('user')
localStorage.removeItem('isAuthenticated')
// Redirect to login page if not already there
if (!window.location.pathname.includes('/login')) {
window.location.href = '/login'
}
return Promise.reject(new Error('Необходима авторизация'))
}
// Handle other server errors
const message = error.response.data?.error || error.response.data || 'Ошибка сервера'
throw new Error(message)
} else if (error.request) {
// Request made but no response
throw new Error('Нет ответа от сервера. Проверьте подключение.')
} else {
// Request setup error
throw new Error('Ошибка при выполнении запроса')
}
}
@ -69,8 +80,16 @@ 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),
login: async (data: LoginRequest): Promise<{ data: LoginResponse }> => {
try {
const response = await api.post('/api/v1/login', data)
return response
} catch (error) {
// Clear any stored credentials on error
localStorage.removeItem('credentials')
throw error
}
},
}
// User validation endpoints
@ -107,4 +126,27 @@ export const companyValidation = {
export const dashboard = {
getDashboardData: (): Promise<{ data: DashboardData }> =>
api.get('/api/v1/dashboard'),
}
// Vacancy management endpoints
export const vacancies = {
// Get all vacancies pending moderation
getPendingVacancies: (): Promise<{ data: Vacancy[] }> =>
api.get('/api/v1/vacancies/pending'),
// Get a specific vacancy by ID
getVacancyById: (id: string): Promise<{ data: Vacancy }> =>
api.get(`/api/v1/vacancies/${id}`),
// Update a vacancy (including status changes and agent reward)
updateVacancy: (id: string, data: UpdateVacancyRequest): Promise<{ data: Vacancy }> =>
api.put(`/api/v1/vacancies/${id}`, data),
// Publish a vacancy
publishVacancy: (id: string): Promise<{ data: Vacancy }> =>
api.put(`/api/v1/vacancies/${id}/publish`),
// Reject a vacancy
rejectVacancy: (id: string, reason: string): Promise<{ data: Vacancy }> =>
api.put(`/api/v1/vacancies/${id}/reject`, { reason }),
}

View File

@ -1,15 +1,16 @@
import React from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import {
LayoutDashboard,
Ticket,
Building2,
Users,
Bell,
Search,
LogOut,
Menu,
X
import {
LayoutDashboard,
Ticket,
Building2,
Users,
Bell,
Search,
LogOut,
Menu,
X,
Briefcase
} from 'lucide-react'
interface LayoutProps {
@ -27,6 +28,7 @@ const Layout: React.FC<LayoutProps> = ({ children, onLogout }) => {
{ name: 'Тикеты на балансы', href: '/tickets', icon: Ticket },
{ name: 'Валидация компаний', href: '/companies', icon: Building2 },
{ name: 'Валидация пользователей', href: '/users', icon: Users },
{ name: 'Модерация вакансий', href: '/vacancies', icon: Briefcase },
]
const isActive = (path: string) => {
@ -40,118 +42,118 @@ const Layout: React.FC<LayoutProps> = ({ children, onLogout }) => {
}
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">
<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>
<button onClick={() => setIsMobileMenuOpen(false)}>
<X className="w-6 h-6 text-gray-500" />
<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>
<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 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>
<span className="ml-2 text-sm font-medium text-gray-700 hidden md:block">Админ</span>
<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>
<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 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>
</div>
<main className="p-4 sm:p-6 lg:p-8">
{children}
</main>
<main className="p-4 sm:p-6 lg:p-8">
{children}
</main>
</div>
</div>
</div>
)
}

View File

@ -25,7 +25,14 @@ const Login: React.FC<LoginProps> = ({ onLogin }) => {
setIsLoading(true)
try {
const { data } = await auth.login({ email, password })
// Store credentials before making the request
const credentials = btoa(`${email.trim()}:${password.trim()}`)
localStorage.setItem('credentials', credentials)
const { data } = await auth.login({
email: email.trim(),
password: password.trim()
})
if (data && typeof data === 'object' && 'ID' in data && 'Email' in data) {
// Store user info in localStorage
@ -38,6 +45,8 @@ const Login: React.FC<LoginProps> = ({ onLogin }) => {
toast.success('Успешный вход')
} else {
// Clear credentials if response format is invalid
localStorage.removeItem('credentials')
console.error('Invalid response format:', data)
throw new Error('Неверный формат ответа от сервера')
}
@ -49,6 +58,11 @@ const Login: React.FC<LoginProps> = ({ onLogin }) => {
: 'Произошла ошибка при входе в систему'
toast.error(errorMessage)
// Clear any stored data on error
localStorage.removeItem('user')
localStorage.removeItem('isAuthenticated')
localStorage.removeItem('credentials')
} finally {
setIsLoading(false)
}

View File

@ -0,0 +1,546 @@
import React, { useState, useEffect } from 'react'
import {
Filter,
Search,
ChevronDown,
ChevronUp,
Check,
X,
ExternalLink,
Briefcase,
Edit3,
Save
} from 'lucide-react'
import { vacancies } from '../api'
import { Vacancy, UpdateVacancyRequest } from '../types/api'
import toast from 'react-hot-toast'
const VacancyModeration: React.FC = () => {
const [filterOpen, setFilterOpen] = useState(false)
const [sortColumn, setSortColumn] = useState('created_at')
const [sortDirection, setSortDirection] = useState('desc')
const [selectedStatus, setSelectedStatus] = useState<string[]>([])
const [vacancyList, setVacancyList] = useState<Vacancy[]>([])
const [isLoading, setIsLoading] = useState(false)
const [activeVacancy, setActiveVacancy] = useState<string | null>(null)
const [vacancyDetails, setVacancyDetails] = useState<Vacancy | null>(null)
const [isEditing, setIsEditing] = useState(false)
const [editedVacancy, setEditedVacancy] = useState<UpdateVacancyRequest | null>(null)
const [rejectionReason, setRejectionReason] = useState('')
useEffect(() => {
fetchVacancies()
}, [])
useEffect(() => {
if (activeVacancy) {
fetchVacancyDetails(activeVacancy)
}
}, [activeVacancy])
const fetchVacancies = async () => {
setIsLoading(true)
try {
const response = await vacancies.getPendingVacancies()
setVacancyList(response.data)
} catch (error) {
console.error('Error fetching vacancies:', error)
toast.error('Ошибка при загрузке вакансий')
} finally {
setIsLoading(false)
}
}
const fetchVacancyDetails = async (id: string) => {
try {
const response = await vacancies.getVacancyById(id)
setVacancyDetails(response.data)
setEditedVacancy(response.data)
} catch (error) {
console.error('Error fetching vacancy details:', error)
toast.error('Ошибка при загрузке деталей вакансии')
}
}
const handlePublish = async (id: string) => {
try {
await vacancies.publishVacancy(id)
toast.success('Вакансия успешно опубликована')
fetchVacancies()
setActiveVacancy(null)
} catch (error) {
console.error('Error publishing vacancy:', error)
toast.error('Ошибка при публикации вакансии')
}
}
const handleReject = async (id: string) => {
if (!rejectionReason.trim()) {
toast.error('Укажите причину отклонения')
return
}
try {
await vacancies.rejectVacancy(id, rejectionReason)
toast.success('Вакансия отклонена')
fetchVacancies()
setActiveVacancy(null)
setRejectionReason('')
} catch (error) {
console.error('Error rejecting vacancy:', error)
toast.error('Ошибка при отклонении вакансии')
}
}
const handleSaveChanges = async () => {
if (!vacancyDetails || !editedVacancy) return
try {
await vacancies.updateVacancy(vacancyDetails.id, editedVacancy)
toast.success('Изменения сохранены')
fetchVacancyDetails(vacancyDetails.id)
setIsEditing(false)
} catch (error) {
console.error('Error updating vacancy:', 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 filteredVacancies = vacancyList.filter(vacancy => {
if (selectedStatus.length === 0) return true
return selectedStatus.includes(vacancy.status)
})
const sortedVacancies = [...filteredVacancies].sort((a, b) => {
if (sortColumn === 'created_at') {
return sortDirection === 'asc'
? new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
: new Date(b.created_at).getTime() - new Date(a.created_at).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 'published':
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 && (
<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('published') ? 'bg-green-100 text-green-800 border-green-300' : 'btn-outline'}`}
onClick={() => toggleStatus('published')}
>
Опубликована
</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('title')}
>
<div className="flex items-center gap-1">
Название {getSortIcon('title')}
</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('company')}
>
<div className="flex items-center gap-1">
Компания {getSortIcon('company')}
</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('created_at')}
>
<div className="flex items-center gap-1">
Дата {getSortIcon('created_at')}
</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">
{sortedVacancies.map((vacancy) => (
<tr
key={vacancy.id}
className={`hover:bg-gray-50 cursor-pointer ${activeVacancy === vacancy.id ? 'bg-primary-50' : ''}`}
onClick={() => setActiveVacancy(vacancy.id)}
>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-primary">
{vacancy.id}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{vacancy.title}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{vacancy.company}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
{getStatusBadge(vacancy.status)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(vacancy.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end gap-2">
{vacancy.status === 'pending' && (
<>
<button
className="p-1 text-green-600 hover:text-green-900"
title="Опубликовать"
onClick={(e) => {
e.stopPropagation()
handlePublish(vacancy.id)
}}
>
<Check className="w-5 h-5" />
</button>
<button
className="p-1 text-red-600 hover:text-red-900"
title="Отклонить"
onClick={(e) => {
e.stopPropagation()
handleReject(vacancy.id)
}}
>
<X className="w-5 h-5" />
</button>
</>
)}
<button
className="p-1 text-primary hover:text-primary-700"
title="Подробнее"
onClick={(e) => {
e.stopPropagation()
setActiveVacancy(vacancy.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">{sortedVacancies.length}</span> из <span className="font-medium">{vacancyList.length}</span> вакансий
</div>
</div>
</div>
</div>
<div className="lg:col-span-1">
{vacancyDetails ? (
<div className="card">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900">{vacancyDetails.title}</h3>
{vacancyDetails.status === 'pending' && (
<button
className="btn btn-outline"
onClick={() => setIsEditing(!isEditing)}
>
{isEditing ? (
<Save className="w-5 h-5" />
) : (
<Edit3 className="w-5 h-5" />
)}
</button>
)}
</div>
<p className="text-sm text-gray-500 mt-1">{vacancyDetails.company}</p>
</div>
<div className="p-6">
<div className="space-y-6">
{isEditing ? (
// Edit mode
<>
<div>
<label className="label">Название вакансии</label>
<input
type="text"
className="input"
value={editedVacancy?.title || ''}
onChange={(e) => setEditedVacancy({
...editedVacancy!,
title: e.target.value
})}
/>
</div>
<div>
<label className="label">Описание</label>
<textarea
className="input"
rows={4}
value={editedVacancy?.description || ''}
onChange={(e) => setEditedVacancy({
...editedVacancy!,
description: e.target.value
})}
/>
</div>
<div>
<label className="label">Вознаграждение для агента</label>
<input
type="number"
className="input"
value={editedVacancy?.agent_reward || 0}
onChange={(e) => setEditedVacancy({
...editedVacancy!,
agent_reward: Number(e.target.value)
})}
/>
</div>
<div className="flex gap-2">
<button
className="btn btn-primary flex-1"
onClick={handleSaveChanges}
>
Сохранить
</button>
<button
className="btn btn-outline flex-1"
onClick={() => {
setIsEditing(false)
setEditedVacancy(vacancyDetails)
}}
>
Отмена
</button>
</div>
</>
) : (
// View mode
<>
<div>
<h4 className="text-sm font-medium text-gray-500">Описание</h4>
<p className="mt-1 text-sm text-gray-900">{vacancyDetails.description}</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500">Требования</h4>
<ul className="mt-1 text-sm text-gray-900 list-disc list-inside">
{vacancyDetails.requirements.map((req, index) => (
<li key={index}>{req}</li>
))}
</ul>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500">Обязанности</h4>
<ul className="mt-1 text-sm text-gray-900 list-disc list-inside">
{vacancyDetails.responsibilities.map((resp, index) => (
<li key={index}>{resp}</li>
))}
</ul>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500">Локация</h4>
<p className="mt-1 text-sm text-gray-900">{vacancyDetails.location}</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500">Зарплата</h4>
<p className="mt-1 text-sm text-gray-900">
{vacancyDetails.salary_range.min.toLocaleString()} - {vacancyDetails.salary_range.max.toLocaleString()} {vacancyDetails.salary_range.currency}
</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500">Тип занятости</h4>
<p className="mt-1 text-sm text-gray-900">{vacancyDetails.employment_type}</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500">Требуемый опыт</h4>
<p className="mt-1 text-sm text-gray-900">{vacancyDetails.experience_level}</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500">Вознаграждение для агента</h4>
<p className="mt-1 text-sm text-gray-900">
{vacancyDetails.agent_reward?.toLocaleString() || 'Не указано'} {vacancyDetails.salary_range.currency}
</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500">Статус</h4>
<div className="mt-1">{getStatusBadge(vacancyDetails.status)}</div>
</div>
</>
)}
</div>
</div>
{vacancyDetails.status === 'pending' && !isEditing && (
<div className="p-6 border-t border-gray-200">
<div className="flex gap-2">
<button
className="btn btn-primary flex-1"
onClick={() => handlePublish(vacancyDetails.id)}
>
Опубликовать
</button>
<button
className="btn btn-outline text-red-600 border-red-300 hover:bg-red-50 flex-1"
onClick={() => handleReject(vacancyDetails.id)}
>
Отклонить
</button>
</div>
<div className="mt-4">
<label htmlFor="rejection-reason" className="label">Причина отклонения</label>
<textarea
id="rejection-reason"
rows={3}
className="input"
placeholder="Укажите причину отклонения вакансии..."
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
></textarea>
</div>
</div>
)}
</div>
) : (
<div className="card p-6 text-center">
<div className="text-gray-500">
<Briefcase 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 VacancyModeration

View File

@ -5,8 +5,8 @@ export interface LoginRequest {
}
export interface LoginResponse {
ID: string // Changed to match Go struct field
Email: string // Changed to match Go struct field
ID: string
Email: string
}
export interface RegisterRequest {
@ -27,7 +27,7 @@ export interface Ticket {
phone: string
name: string
client_type?: string
last_update: string // Will be converted from time.Time
last_update: string
description?: string
status: string
}
@ -35,7 +35,7 @@ export interface Ticket {
export interface TicketInfo {
ticket_id: string
client_id: string
last_update: string // Will be converted from time.Time
last_update: string
description?: string
status: string
}
@ -94,4 +94,46 @@ export interface DashboardData {
stats: DashboardStats
recentTickets: Ticket[]
pendingCompanies: CompanyValidationTicket[]
}
// Vacancy types
export interface Vacancy {
id: string
title: string
company: string
description: string
requirements: string[]
responsibilities: string[]
location: string
salary_range: {
min: number
max: number
currency: string
}
employment_type: string
experience_level: string
agent_reward?: number
status: 'pending' | 'published' | 'rejected'
created_at: string
updated_at: string
rejection_reason?: string
distributor_id: string
}
export interface UpdateVacancyRequest {
title?: string
description?: string
requirements?: string[]
responsibilities?: string[]
location?: string
salary_range?: {
min: number
max: number
currency: string
}
employment_type?: string
experience_level?: string
agent_reward?: number
status?: 'pending' | 'published' | 'rejected'
rejection_reason?: string
}