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 { 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -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">
|
||||||
|
@ -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' && 'Подключено'}
|
||||||
|
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 { 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({
|
||||||
@ -19,4 +20,42 @@ export const useVacancy = (id: number) => {
|
|||||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||||
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
|
||||||
}
|
}
|
@ -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()
|
||||||
|
},
|
||||||
}
|
}
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user