add vacancy uploading
This commit is contained in:
parent
f2fd566cc4
commit
57aca4b657
22
README.md
22
README.md
@ -85,25 +85,3 @@ yarn dev
|
||||
- Полная информация о вакансии
|
||||
- Контактные данные
|
||||
- Форма для отклика прямо на странице
|
||||
|
||||
## Скрипты
|
||||
|
||||
- `yarn dev` - Запуск в режиме разработки
|
||||
- `yarn build` - Сборка продакшн версии
|
||||
- `yarn start` - Запуск продакшн версии
|
||||
- `yarn lint` - Проверка кода с ESLint
|
||||
|
||||
## Стилизация
|
||||
|
||||
Проект использует Tailwind CSS для стилизации с кастомной темой:
|
||||
- Основной цвет: Blue (primary-*)
|
||||
- Адаптивный дизайн для всех устройств
|
||||
- Современные компоненты с hover эффектами
|
||||
|
||||
## Будущие улучшения
|
||||
|
||||
- [ ] Пагинация для списка вакансий
|
||||
- [ ] Расширенные фильтры (по зарплате, опыту, локации)
|
||||
- [ ] Избранные вакансии
|
||||
- [ ] История откликов
|
||||
- [ ] Уведомления в реальном времени
|
36
app/page.tsx
36
app/page.tsx
@ -4,10 +4,12 @@ import { useState } from 'react'
|
||||
import { VacancyRead } from '@/types/api'
|
||||
import { useVacancies } from '@/hooks/useVacancy'
|
||||
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() {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||
const [searchParams, setSearchParams] = useState({
|
||||
active_only: true,
|
||||
title: undefined as string | undefined,
|
||||
@ -147,7 +149,7 @@ export default function HomePage() {
|
||||
|
||||
<div className="flex items-center text-gray-600 text-sm">
|
||||
<MapPin className="h-4 w-4 mr-2" />
|
||||
<span>{vacancy.area_name}</span>
|
||||
<span>{vacancy.area_name || 'Не указано'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-gray-600 text-sm">
|
||||
@ -157,7 +159,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -189,6 +191,34 @@ export default function HomePage() {
|
||||
</p>
|
||||
</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>
|
||||
)
|
||||
}
|
@ -131,7 +131,7 @@ export default function VacancyPage() {
|
||||
<div className="flex items-center mb-6">
|
||||
<Building className="h-5 w-5 text-gray-400 mr-2" />
|
||||
<span className="text-lg font-medium text-gray-900">
|
||||
{vacancy.company_name}
|
||||
{vacancy.company_name || 'Не указано'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -143,7 +143,7 @@ export default function VacancyPage() {
|
||||
|
||||
<div className="flex items-center text-gray-600">
|
||||
<MapPin className="h-4 w-4 mr-2" />
|
||||
<span>{vacancy.area_name}</span>
|
||||
<span>{vacancy.area_name || 'Не указано'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-gray-600">
|
||||
|
@ -279,7 +279,7 @@ function InterviewRoom({ resumeId, onEnd, sessionId }: InterviewSessionProps) {
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
HR Собеседование
|
||||
Собеседование со Стефани
|
||||
</h1>
|
||||
<p className={`text-sm ${getConnectionStatusColor()}`}>
|
||||
{state.connectionState === 'connected' && 'Подключено'}
|
||||
|
175
components/VacancyUploadForm.tsx
Normal file
175
components/VacancyUploadForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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 { GetVacanciesParams } from '@/types/api'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export const useVacancies = (params?: GetVacanciesParams) => {
|
||||
return useQuery({
|
||||
@ -19,4 +20,42 @@ export const useVacancy = (id: number) => {
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
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
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { kyClient } from '@/lib/ky-client'
|
||||
import { kyClient, kyFormClient } from '@/lib/ky-client'
|
||||
import { VacancyRead, GetVacanciesParams } from '@/types/api'
|
||||
|
||||
export const vacancyService = {
|
||||
@ -20,4 +20,19 @@ export const vacancyService = {
|
||||
async getVacancy(id: number): Promise<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()
|
||||
},
|
||||
}
|
@ -15,9 +15,9 @@ export interface VacancyRead {
|
||||
salary_to?: number
|
||||
salary_currency?: string
|
||||
gross_salary?: boolean
|
||||
company_name: string
|
||||
company_name?: string
|
||||
company_description?: string
|
||||
area_name: string
|
||||
area_name?: string
|
||||
metro_stations?: string
|
||||
address?: string
|
||||
professional_roles?: string
|
||||
@ -43,9 +43,9 @@ export interface VacancyCreate {
|
||||
salary_to?: number
|
||||
salary_currency?: string
|
||||
gross_salary?: boolean
|
||||
company_name: string
|
||||
company_name?: string
|
||||
company_description?: string
|
||||
area_name: string
|
||||
area_name?: string
|
||||
metro_stations?: string
|
||||
address?: string
|
||||
professional_roles?: string
|
||||
|
Loading…
Reference in New Issue
Block a user