Compare commits
No commits in common. "4f539c7a97ba27e6ca2e7e924553bc248088326e" and "3d54b40990daa1682457b75286d1de6e95ffc2a0" have entirely different histories.
4f539c7a97
...
3d54b40990
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.
|
||||||
|
|
@ -1,42 +1,24 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { Routes, Route, Navigate, useNavigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { Toaster } from 'react-hot-toast'
|
import { Toaster } from 'react-hot-toast'
|
||||||
import Layout from './components/Layout'
|
import Layout from './components/Layout'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import TicketBalances from './pages/TicketBalances'
|
import TicketBalances from './pages/TicketBalances'
|
||||||
import CompanyValidation from './pages/CompanyValidation'
|
import CompanyValidation from './pages/CompanyValidation'
|
||||||
import UserValidation from './pages/UserValidation'
|
import UserValidation from './pages/UserValidation'
|
||||||
import VacancyModeration from './pages/VacancyModeration'
|
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if user is authenticated
|
// Check if user is authenticated
|
||||||
const authStatus = localStorage.getItem('isAuthenticated')
|
const authStatus = localStorage.getItem('isAuthenticated')
|
||||||
const user = localStorage.getItem('user')
|
const user = localStorage.getItem('user')
|
||||||
|
|
||||||
if (authStatus === 'true' && user) {
|
if (authStatus === 'true' && user) {
|
||||||
try {
|
|
||||||
// Validate user object
|
|
||||||
const userData = JSON.parse(user)
|
|
||||||
if (userData && userData.ID && userData.Email) {
|
|
||||||
setIsAuthenticated(true)
|
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 = () => {
|
const handleLogin = () => {
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
@ -46,7 +28,6 @@ function App() {
|
|||||||
localStorage.removeItem('user')
|
localStorage.removeItem('user')
|
||||||
localStorage.removeItem('isAuthenticated')
|
localStorage.removeItem('isAuthenticated')
|
||||||
setIsAuthenticated(false)
|
setIsAuthenticated(false)
|
||||||
navigate('/login', { replace: true })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
@ -66,8 +47,6 @@ function App() {
|
|||||||
<Route path="/tickets" element={<TicketBalances />} />
|
<Route path="/tickets" element={<TicketBalances />} />
|
||||||
<Route path="/companies" element={<CompanyValidation />} />
|
<Route path="/companies" element={<CompanyValidation />} />
|
||||||
<Route path="/users" element={<UserValidation />} />
|
<Route path="/users" element={<UserValidation />} />
|
||||||
<Route path="/vacancies" element={<VacancyModeration />} />
|
|
||||||
<Route path="/login" element={<Navigate to="/" replace />} />
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
@ -12,9 +12,7 @@ import type {
|
|||||||
UpdateValidationStatusByTicketIdRequest,
|
UpdateValidationStatusByTicketIdRequest,
|
||||||
UpdateValidationStatusByTicketIdResponse,
|
UpdateValidationStatusByTicketIdResponse,
|
||||||
CompanyValidationTicket,
|
CompanyValidationTicket,
|
||||||
DashboardData,
|
DashboardData
|
||||||
Vacancy,
|
|
||||||
UpdateVacancyRequest
|
|
||||||
} from '../types/api'
|
} from '../types/api'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080'
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080'
|
||||||
@ -26,24 +24,26 @@ const api = axios.create({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Add auth credentials to requests if they exist
|
// Add auth token to requests if it exists
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use((config) => {
|
||||||
try {
|
try {
|
||||||
const credentials = localStorage.getItem('credentials')
|
const userStr = localStorage.getItem('user')
|
||||||
if (credentials) {
|
if (userStr) {
|
||||||
config.headers.Authorization = `Basic ${credentials}`
|
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) {
|
} catch (error) {
|
||||||
console.error('Error setting auth header:', error)
|
console.error('Error setting auth header:', error)
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
}, (error) => {
|
|
||||||
return Promise.reject(error)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Add response interceptor for error handling
|
// Add response interceptor for error handling
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
|
// Ensure the response data is an object
|
||||||
if (response.data && typeof response.data === 'object') {
|
if (response.data && typeof response.data === 'object') {
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
@ -51,25 +51,14 @@ api.interceptors.response.use(
|
|||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
// Handle 401 Unauthorized errors
|
// Server responded with error status
|
||||||
if (error.response.status === 401) {
|
const message = error.response.data?.error || 'Ошибка сервера'
|
||||||
// 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)
|
throw new Error(message)
|
||||||
} else if (error.request) {
|
} else if (error.request) {
|
||||||
|
// Request made but no response
|
||||||
throw new Error('Нет ответа от сервера. Проверьте подключение.')
|
throw new Error('Нет ответа от сервера. Проверьте подключение.')
|
||||||
} else {
|
} else {
|
||||||
|
// Request setup error
|
||||||
throw new Error('Ошибка при выполнении запроса')
|
throw new Error('Ошибка при выполнении запроса')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -80,16 +69,8 @@ export const auth = {
|
|||||||
register: (data: RegisterRequest): Promise<{ data: RegisterResponse }> =>
|
register: (data: RegisterRequest): Promise<{ data: RegisterResponse }> =>
|
||||||
api.post('/api/v1/register', data),
|
api.post('/api/v1/register', data),
|
||||||
|
|
||||||
login: async (data: LoginRequest): Promise<{ data: LoginResponse }> => {
|
login: (data: LoginRequest): Promise<{ data: LoginResponse }> =>
|
||||||
try {
|
api.post('/api/v1/login', data),
|
||||||
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
|
// User validation endpoints
|
||||||
@ -127,26 +108,3 @@ export const dashboard = {
|
|||||||
getDashboardData: (): Promise<{ data: DashboardData }> =>
|
getDashboardData: (): Promise<{ data: DashboardData }> =>
|
||||||
api.get('/api/v1/dashboard'),
|
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 }),
|
|
||||||
}
|
|
@ -9,8 +9,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
LogOut,
|
LogOut,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X
|
||||||
Briefcase
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
@ -28,7 +27,6 @@ const Layout: React.FC<LayoutProps> = ({ children, onLogout }) => {
|
|||||||
{ name: 'Тикеты на балансы', href: '/tickets', icon: Ticket },
|
{ name: 'Тикеты на балансы', href: '/tickets', icon: Ticket },
|
||||||
{ name: 'Валидация компаний', href: '/companies', icon: Building2 },
|
{ name: 'Валидация компаний', href: '/companies', icon: Building2 },
|
||||||
{ name: 'Валидация пользователей', href: '/users', icon: Users },
|
{ name: 'Валидация пользователей', href: '/users', icon: Users },
|
||||||
{ name: 'Модерация вакансий', href: '/vacancies', icon: Briefcase },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
|
@ -25,14 +25,7 @@ const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
|||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Store credentials before making the request
|
const { data } = await auth.login({ email, password })
|
||||||
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) {
|
if (data && typeof data === 'object' && 'ID' in data && 'Email' in data) {
|
||||||
// Store user info in localStorage
|
// Store user info in localStorage
|
||||||
@ -45,8 +38,6 @@ const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
|||||||
|
|
||||||
toast.success('Успешный вход')
|
toast.success('Успешный вход')
|
||||||
} else {
|
} else {
|
||||||
// Clear credentials if response format is invalid
|
|
||||||
localStorage.removeItem('credentials')
|
|
||||||
console.error('Invalid response format:', data)
|
console.error('Invalid response format:', data)
|
||||||
throw new Error('Неверный формат ответа от сервера')
|
throw new Error('Неверный формат ответа от сервера')
|
||||||
}
|
}
|
||||||
@ -58,11 +49,6 @@ const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
|||||||
: 'Произошла ошибка при входе в систему'
|
: 'Произошла ошибка при входе в систему'
|
||||||
|
|
||||||
toast.error(errorMessage)
|
toast.error(errorMessage)
|
||||||
|
|
||||||
// Clear any stored data on error
|
|
||||||
localStorage.removeItem('user')
|
|
||||||
localStorage.removeItem('isAuthenticated')
|
|
||||||
localStorage.removeItem('credentials')
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
@ -1,546 +0,0 @@
|
|||||||
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
|
|
@ -5,8 +5,8 @@ export interface LoginRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
ID: string
|
ID: string // Changed to match Go struct field
|
||||||
Email: string
|
Email: string // Changed to match Go struct field
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterRequest {
|
export interface RegisterRequest {
|
||||||
@ -27,7 +27,7 @@ export interface Ticket {
|
|||||||
phone: string
|
phone: string
|
||||||
name: string
|
name: string
|
||||||
client_type?: string
|
client_type?: string
|
||||||
last_update: string
|
last_update: string // Will be converted from time.Time
|
||||||
description?: string
|
description?: string
|
||||||
status: string
|
status: string
|
||||||
}
|
}
|
||||||
@ -35,7 +35,7 @@ export interface Ticket {
|
|||||||
export interface TicketInfo {
|
export interface TicketInfo {
|
||||||
ticket_id: string
|
ticket_id: string
|
||||||
client_id: string
|
client_id: string
|
||||||
last_update: string
|
last_update: string // Will be converted from time.Time
|
||||||
description?: string
|
description?: string
|
||||||
status: string
|
status: string
|
||||||
}
|
}
|
||||||
@ -95,45 +95,3 @@ export interface DashboardData {
|
|||||||
recentTickets: Ticket[]
|
recentTickets: Ticket[]
|
||||||
pendingCompanies: CompanyValidationTicket[]
|
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
|
|
||||||
}
|
|
Loading…
Reference in New Issue
Block a user