add vacancy uploading

This commit is contained in:
Даниил Ивлев 2025-09-08 17:26:45 +05:00
parent f2fd566cc4
commit 57aca4b657
8 changed files with 271 additions and 34 deletions

View File

@ -85,25 +85,3 @@ yarn dev
- Полная информация о вакансии - Полная информация о вакансии
- Контактные данные - Контактные данные
- Форма для отклика прямо на странице - Форма для отклика прямо на странице
## Скрипты
- `yarn dev` - Запуск в режиме разработки
- `yarn build` - Сборка продакшн версии
- `yarn start` - Запуск продакшн версии
- `yarn lint` - Проверка кода с ESLint
## Стилизация
Проект использует Tailwind CSS для стилизации с кастомной темой:
- Основной цвет: Blue (primary-*)
- Адаптивный дизайн для всех устройств
- Современные компоненты с hover эффектами
## Будущие улучшения
- [ ] Пагинация для списка вакансий
- [ ] Расширенные фильтры (по зарплате, опыту, локации)
- [ ] Избранные вакансии
- [ ] История откликов
- [ ] Уведомления в реальном времени

View File

@ -4,10 +4,12 @@ import { useState } from 'react'
import { VacancyRead } from '@/types/api' import { VacancyRead } from '@/types/api'
import { useVacancies } from '@/hooks/useVacancy' import { useVacancies } from '@/hooks/useVacancy'
import Link from 'next/link' import Link from 'next/link'
import { Search, MapPin, Clock, Banknote } from 'lucide-react' import { Search, MapPin, Clock, Banknote, Plus } from 'lucide-react'
import VacancyUploadForm from '@/components/VacancyUploadForm'
export default function HomePage() { export default function HomePage() {
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [showCreateForm, setShowCreateForm] = useState(false)
const [searchParams, setSearchParams] = useState({ const [searchParams, setSearchParams] = useState({
active_only: true, active_only: true,
title: undefined as string | undefined, title: undefined as string | undefined,
@ -147,7 +149,7 @@ export default function HomePage() {
<div className="flex items-center text-gray-600 text-sm"> <div className="flex items-center text-gray-600 text-sm">
<MapPin className="h-4 w-4 mr-2" /> <MapPin className="h-4 w-4 mr-2" />
<span>{vacancy.area_name}</span> <span>{vacancy.area_name || 'Не указано'}</span>
</div> </div>
<div className="flex items-center text-gray-600 text-sm"> <div className="flex items-center text-gray-600 text-sm">
@ -157,7 +159,7 @@ export default function HomePage() {
</div> </div>
<div className="text-sm text-gray-700 mb-4"> <div className="text-sm text-gray-700 mb-4">
<p className="font-medium">{vacancy.company_name}</p> <p className="font-medium">{vacancy.company_name || 'Не указано'}</p>
<p className="line-clamp-2 mt-1">{vacancy.description}</p> <p className="line-clamp-2 mt-1">{vacancy.description}</p>
</div> </div>
@ -189,6 +191,34 @@ export default function HomePage() {
</p> </p>
</div> </div>
)} )}
{/* Create Vacancy Button */}
{!showCreateForm && (
<div className="flex justify-center pt-8">
<button
onClick={() => setShowCreateForm(true)}
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 shadow-lg hover:shadow-xl transition-all"
>
<Plus className="h-5 w-5 mr-2" />
Создать вакансию
</button>
</div>
)}
{/* Create Vacancy Form */}
{showCreateForm && (
<div className="space-y-4">
<div className="flex justify-center">
<button
onClick={() => setShowCreateForm(false)}
className="text-gray-500 hover:text-gray-700 text-sm"
>
Свернуть
</button>
</div>
<VacancyUploadForm />
</div>
)}
</div> </div>
) )
} }

View File

@ -131,7 +131,7 @@ export default function VacancyPage() {
<div className="flex items-center mb-6"> <div className="flex items-center mb-6">
<Building className="h-5 w-5 text-gray-400 mr-2" /> <Building className="h-5 w-5 text-gray-400 mr-2" />
<span className="text-lg font-medium text-gray-900"> <span className="text-lg font-medium text-gray-900">
{vacancy.company_name} {vacancy.company_name || 'Не указано'}
</span> </span>
</div> </div>
@ -143,7 +143,7 @@ export default function VacancyPage() {
<div className="flex items-center text-gray-600"> <div className="flex items-center text-gray-600">
<MapPin className="h-4 w-4 mr-2" /> <MapPin className="h-4 w-4 mr-2" />
<span>{vacancy.area_name}</span> <span>{vacancy.area_name || 'Не указано'}</span>
</div> </div>
<div className="flex items-center text-gray-600"> <div className="flex items-center text-gray-600">

View File

@ -279,7 +279,7 @@ function InterviewRoom({ resumeId, onEnd, sessionId }: InterviewSessionProps) {
</div> </div>
</div> </div>
<h1 className="text-2xl font-bold text-gray-900 mb-2"> <h1 className="text-2xl font-bold text-gray-900 mb-2">
HR Собеседование Собеседование со Стефани
</h1> </h1>
<p className={`text-sm ${getConnectionStatusColor()}`}> <p className={`text-sm ${getConnectionStatusColor()}`}>
{state.connectionState === 'connected' && 'Подключено'} {state.connectionState === 'connected' && 'Подключено'}

View File

@ -0,0 +1,175 @@
'use client'
import { useState } from 'react'
import { useParseVacancyFile } from '@/hooks/useVacancy'
import { Upload, X, FileText } from 'lucide-react'
export default function VacancyUploadForm() {
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [dragActive, setDragActive] = useState(false)
const parseVacancyFile = useParseVacancyFile()
const handleFileSelect = (file: File) => {
// Проверка типа файла
const allowedTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/rtf',
'text/plain'
]
if (!allowedTypes.includes(file.type)) {
alert('Поддерживаются только файлы: PDF, DOC, DOCX, RTF, TXT')
return
}
// Проверка размера файла (10MB)
if (file.size > 10 * 1024 * 1024) {
alert('Размер файла не должен превышать 10 МБ')
return
}
setSelectedFile(file)
}
const handleDrag = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true)
} else if (e.type === 'dragleave') {
setDragActive(false)
}
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragActive(false)
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFileSelect(e.dataTransfer.files[0])
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedFile) {
alert('Пожалуйста, выберите файл')
return
}
try {
await parseVacancyFile.mutateAsync({
file: selectedFile,
createVacancy: true
})
// Очистить форму после успешной отправки
setSelectedFile(null)
} catch (error) {
console.error('Ошибка при загрузке файла:', error)
alert('Произошла ошибка при загрузке файла')
}
}
return (
<div className="max-w-2xl mx-auto">
<div className="bg-white rounded-lg shadow-md border border-gray-200 p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4 text-center">
Создать вакансию из файла
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{/* File Upload Area */}
<div
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
dragActive
? 'border-primary-500 bg-primary-50'
: selectedFile
? 'border-green-300 bg-green-50'
: 'border-gray-300 hover:border-gray-400'
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
{selectedFile ? (
<div className="space-y-3">
<div className="flex items-center justify-center">
<FileText className="h-12 w-12 text-green-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-900">{selectedFile.name}</p>
<p className="text-sm text-gray-600">
{(selectedFile.size / 1024 / 1024).toFixed(2)} МБ
</p>
</div>
<button
type="button"
onClick={() => setSelectedFile(null)}
className="inline-flex items-center px-3 py-1 border border-gray-300 rounded-md text-sm text-gray-700 bg-white hover:bg-gray-50"
>
<X className="h-4 w-4 mr-1" />
Удалить
</button>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-center">
<Upload className="h-12 w-12 text-gray-400" />
</div>
<div>
<p className="text-lg font-medium text-gray-900">
Перетащите файл сюда или{' '}
<label className="text-primary-600 hover:text-primary-500 cursor-pointer underline">
выберите файл
<input
type="file"
className="hidden"
accept=".pdf,.doc,.docx,.rtf,.txt"
onChange={(e) => e.target.files?.[0] && handleFileSelect(e.target.files[0])}
/>
</label>
</p>
<p className="text-sm text-gray-600 mt-1">
PDF, DOC, DOCX, RTF, TXT до 10 МБ
</p>
</div>
</div>
)}
</div>
{/* Submit Button */}
<div className="flex justify-center">
<button
type="submit"
disabled={!selectedFile || parseVacancyFile.isPending}
className={`px-6 py-3 rounded-lg font-medium text-white transition-colors ${
selectedFile && !parseVacancyFile.isPending
? 'bg-primary-600 hover:bg-primary-700 focus:ring-2 focus:ring-primary-500 focus:ring-offset-2'
: 'bg-gray-400 cursor-not-allowed'
}`}
>
{parseVacancyFile.isPending ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Обработка...
</div>
) : (
'Создать вакансию'
)}
</button>
</div>
</form>
<p className="text-xs text-gray-500 text-center mt-4">
После загрузки файл будет обработан и вакансия появится в списке
</p>
</div>
</div>
)
}

View File

@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { vacancyService } from '@/services/vacancy.service' import { vacancyService } from '@/services/vacancy.service'
import { GetVacanciesParams } from '@/types/api' import { GetVacanciesParams } from '@/types/api'
import { useEffect, useRef } from 'react'
export const useVacancies = (params?: GetVacanciesParams) => { export const useVacancies = (params?: GetVacanciesParams) => {
return useQuery({ return useQuery({
@ -20,3 +21,41 @@ export const useVacancy = (id: number) => {
retry: 2, retry: 2,
}) })
} }
export const useParseVacancyFile = () => {
const queryClient = useQueryClient()
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null)
const mutation = useMutation({
mutationFn: ({ file, createVacancy }: { file: File; createVacancy?: boolean }) =>
vacancyService.parseFileAsync(file, createVacancy),
onSuccess: (data) => {
// Показать уведомление об успешном запуске парсинга
alert('Задача парсинга запущена! Скоро вакансия появится в списке.')
// Начать опрос списка вакансий каждые 5 секунд
pollIntervalRef.current = setInterval(() => {
queryClient.invalidateQueries({ queryKey: ['vacancies'] })
}, 5000)
// Остановить опрос через 2 минуты
setTimeout(() => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current)
pollIntervalRef.current = null
}
}, 120000)
},
})
// Очистить интервал при размонтировании компонента
useEffect(() => {
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current)
}
}
}, [])
return mutation
}

View File

@ -1,4 +1,4 @@
import { kyClient } from '@/lib/ky-client' import { kyClient, kyFormClient } from '@/lib/ky-client'
import { VacancyRead, GetVacanciesParams } from '@/types/api' import { VacancyRead, GetVacanciesParams } from '@/types/api'
export const vacancyService = { export const vacancyService = {
@ -20,4 +20,19 @@ export const vacancyService = {
async getVacancy(id: number): Promise<VacancyRead> { async getVacancy(id: number): Promise<VacancyRead> {
return kyClient.get(`v1/vacancies/${id}`).json<VacancyRead>() return kyClient.get(`v1/vacancies/${id}`).json<VacancyRead>()
}, },
async parseFileAsync(file: File, createVacancy: boolean = true): Promise<{
message: string
task_id: string
status: string
check_status_url: string
}> {
const formData = new FormData()
formData.append('file', file)
formData.append('create_vacancy', createVacancy.toString())
return kyFormClient.post('v1/vacancies/parse-file-async', {
body: formData
}).json()
},
} }

View File

@ -15,9 +15,9 @@ export interface VacancyRead {
salary_to?: number salary_to?: number
salary_currency?: string salary_currency?: string
gross_salary?: boolean gross_salary?: boolean
company_name: string company_name?: string
company_description?: string company_description?: string
area_name: string area_name?: string
metro_stations?: string metro_stations?: string
address?: string address?: string
professional_roles?: string professional_roles?: string
@ -43,9 +43,9 @@ export interface VacancyCreate {
salary_to?: number salary_to?: number
salary_currency?: string salary_currency?: string
gross_salary?: boolean gross_salary?: boolean
company_name: string company_name?: string
company_description?: string company_description?: string
area_name: string area_name?: string
metro_stations?: string metro_stations?: string
address?: string address?: string
professional_roles?: string professional_roles?: string