diff --git a/project/src/App.tsx b/project/src/App.tsx index 5566404..fce72b7 100644 --- a/project/src/App.tsx +++ b/project/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> } /> diff --git a/project/src/api/index.ts b/project/src/api/index.ts index c774fd1..a2e8aba 100644 --- a/project/src/api/index.ts +++ b/project/src/api/index.ts @@ -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 }), } \ No newline at end of file diff --git a/project/src/components/Layout.tsx b/project/src/components/Layout.tsx index fab0981..2a6a170 100644 --- a/project/src/components/Layout.tsx +++ b/project/src/components/Layout.tsx @@ -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 = ({ 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 = ({ children, onLogout }) => { } return ( -
- {/* Mobile menu */} -
-
setIsMobileMenuOpen(false)}>
-
-
-
+
+ {/* Mobile menu */} +
+
setIsMobileMenuOpen(false)}>
+
+
+
+ Molva Admin +
+ +
+
+ +
+
+
+ + {/* Desktop sidebar */} +
+
+
Molva Admin
-
+
+ + {/* Main content */} +
+
+ -
-
- -
-
-
- - {/* Desktop sidebar */} -
-
-
- Molva Admin -
-
- -
-
-
- - {/* Main content */} -
-
- -
-
-
-
- -
- -
-
-
- -
-
-
- АА +
+
+
+
+
- Админ +
- +
+ +
+
+
+ АА +
+ Админ +
+
+ +
-
-
- {children} -
+
+ {children} +
+
-
) } diff --git a/project/src/pages/Login.tsx b/project/src/pages/Login.tsx index ffda8f4..e6b7702 100644 --- a/project/src/pages/Login.tsx +++ b/project/src/pages/Login.tsx @@ -25,7 +25,14 @@ const Login: React.FC = ({ 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 = ({ 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 = ({ onLogin }) => { : 'Произошла ошибка при входе в систему' toast.error(errorMessage) + + // Clear any stored data on error + localStorage.removeItem('user') + localStorage.removeItem('isAuthenticated') + localStorage.removeItem('credentials') } finally { setIsLoading(false) } diff --git a/project/src/pages/VacancyModeration.tsx b/project/src/pages/VacancyModeration.tsx new file mode 100644 index 0000000..a269ad7 --- /dev/null +++ b/project/src/pages/VacancyModeration.tsx @@ -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([]) + const [vacancyList, setVacancyList] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [activeVacancy, setActiveVacancy] = useState(null) + const [vacancyDetails, setVacancyDetails] = useState(null) + const [isEditing, setIsEditing] = useState(false) + const [editedVacancy, setEditedVacancy] = useState(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 Опубликована + case 'rejected': + return Отклонена + case 'pending': + return На модерации + default: + return В обработке + } + } + + const getSortIcon = (column: string) => { + if (sortColumn !== column) return null + return sortDirection === 'asc' ? : + } + + if (isLoading) { + return ( +
+
+
+ ) + } + + return ( +
+
+

Модерация вакансий

+

+ Управление и проверка вакансий перед публикацией +

+
+ +
+
+
+
+
+
+
+ +
+ +
+
+ +
+
+ + {filterOpen && ( +
+

Статус

+
+ + + +
+
+ )} +
+ +
+ + + + + + + + + + + + + {sortedVacancies.map((vacancy) => ( + setActiveVacancy(vacancy.id)} + > + + + + + + + + ))} + +
handleSort('id')} + > +
+ ID {getSortIcon('id')} +
+
handleSort('title')} + > +
+ Название {getSortIcon('title')} +
+
handleSort('company')} + > +
+ Компания {getSortIcon('company')} +
+
handleSort('status')} + > +
+ Статус {getSortIcon('status')} +
+
handleSort('created_at')} + > +
+ Дата {getSortIcon('created_at')} +
+
+ Действия +
+ {vacancy.id} + + {vacancy.title} + + {vacancy.company} + + {getStatusBadge(vacancy.status)} + + {new Date(vacancy.created_at).toLocaleDateString()} + +
+ {vacancy.status === 'pending' && ( + <> + + + + )} + +
+
+
+ +
+
+ Показано {sortedVacancies.length} из {vacancyList.length} вакансий +
+
+
+
+ +
+ {vacancyDetails ? ( +
+
+
+

{vacancyDetails.title}

+ {vacancyDetails.status === 'pending' && ( + + )} +
+

{vacancyDetails.company}

+
+
+
+ {isEditing ? ( + // Edit mode + <> +
+ + setEditedVacancy({ + ...editedVacancy!, + title: e.target.value + })} + /> +
+
+ + +
+
+ )} +
+ ) : ( +
+
+ +

Выберите вакансию

+

Выберите вакансию из списка для просмотра подробной информации

+
+
+ )} +
+
+
+ ) +} + +export default VacancyModeration \ No newline at end of file diff --git a/project/src/types/api.ts b/project/src/types/api.ts index fc755f6..3982d5c 100644 --- a/project/src/types/api.ts +++ b/project/src/types/api.ts @@ -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 } \ No newline at end of file