This commit is contained in:
Даниил Ивлев 2025-08-31 00:27:33 +05:00
parent 6654eb15d0
commit 056f70a1ad
23 changed files with 4526 additions and 0 deletions

3
.eslintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.next
.claude
.idea
node_modules

16
app/globals.css Normal file
View File

@ -0,0 +1,16 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
input, textarea, select {
@apply px-3 py-2 border-gray-300 rounded-md shadow-sm focus:outline-none focus:border-primary-500;
}

43
app/layout.tsx Normal file
View File

@ -0,0 +1,43 @@
import type { Metadata } from 'next'
import './globals.css'
import QueryProvider from '@/components/QueryProvider'
export const metadata: Metadata = {
title: 'HR AI - Поиск работы',
description: 'Платформа для поиска вакансий с искусственным интеллектом',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="ru">
<head>
<meta charSet="utf-8" />
</head>
<body className="min-h-screen bg-gray-50">
<QueryProvider>
<header className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
<h1 className="text-xl font-bold text-primary-600">HR AI</h1>
</div>
<nav className="flex space-x-4">
<a href="/" className="text-gray-600 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium">
Вакансии
</a>
</nav>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</main>
</QueryProvider>
</body>
</html>
)
}

193
app/page.tsx Normal file
View File

@ -0,0 +1,193 @@
'use client'
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'
export default function HomePage() {
const [searchTerm, setSearchTerm] = useState('')
const [searchParams, setSearchParams] = useState({
active_only: true,
title: undefined as string | undefined,
})
const { data: vacancies = [], isLoading, error, refetch } = useVacancies(searchParams)
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
setSearchParams({
active_only: true,
title: searchTerm || undefined,
})
}
const formatSalary = (vacancy: VacancyRead) => {
if (!vacancy.salary_from && !vacancy.salary_to) return 'Зарплата не указана'
const currency = vacancy.salary_currency === 'RUR' ? '₽' : vacancy.salary_currency
if (vacancy.salary_from && vacancy.salary_to) {
return `${vacancy.salary_from.toLocaleString()} - ${vacancy.salary_to.toLocaleString()} ${currency}`
}
if (vacancy.salary_from) {
return `от ${vacancy.salary_from.toLocaleString()} ${currency}`
}
if (vacancy.salary_to) {
return `до ${vacancy.salary_to.toLocaleString()} ${currency}`
}
}
const getExperienceText = (experience: string) => {
const mapping = {
noExperience: 'Без опыта',
between1And3: '1-3 года',
between3And6: '3-6 лет',
moreThan6: 'Более 6 лет'
}
return mapping[experience as keyof typeof mapping] || experience
}
const getEmploymentText = (employment: string) => {
const mapping = {
full: 'Полная занятость',
part: 'Частичная занятость',
project: 'Проектная работа',
volunteer: 'Волонтерство',
probation: 'Стажировка'
}
return mapping[employment as keyof typeof mapping] || employment
}
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
)
}
return (
<div className="space-y-8">
{/* Header */}
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
Найдите работу мечты
</h1>
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
Платформа с искусственным интеллектом для поиска идеальной вакансии
</p>
</div>
{/* Search */}
<div className="max-w-2xl mx-auto">
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-5 w-5" />
<input
type="text"
placeholder="Поиск по названию вакансии..."
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<button
type="submit"
className="px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
>
Найти
</button>
</form>
</div>
{/* Error */}
{error && (
<div className="text-center py-8">
<p className="text-red-600">Не удалось загрузить вакансии</p>
<button
onClick={() => refetch()}
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Попробовать снова
</button>
</div>
)}
{/* Vacancies Grid */}
{!error && vacancies.length > 0 && (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{vacancies.map((vacancy) => (
<Link
key={vacancy.id}
href={`/vacancy/${vacancy.id}`}
className="block bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow border border-gray-200"
>
<div className="p-6">
<div className="flex items-start justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 line-clamp-2">
{vacancy.title}
</h3>
{vacancy.premium && (
<span className="ml-2 px-2 py-1 bg-yellow-100 text-yellow-800 text-xs font-medium rounded">
Premium
</span>
)}
</div>
<div className="space-y-2 mb-4">
<div className="flex items-center text-gray-600 text-sm">
<Banknote className="h-4 w-4 mr-2" />
<span>{formatSalary(vacancy)}</span>
</div>
<div className="flex items-center text-gray-600 text-sm">
<MapPin className="h-4 w-4 mr-2" />
<span>{vacancy.area_name}</span>
</div>
<div className="flex items-center text-gray-600 text-sm">
<Clock className="h-4 w-4 mr-2" />
<span>{getExperienceText(vacancy.experience)}</span>
</div>
</div>
<div className="text-sm text-gray-700 mb-4">
<p className="font-medium">{vacancy.company_name}</p>
<p className="line-clamp-2 mt-1">{vacancy.description}</p>
</div>
<div className="flex items-center justify-between">
<span className="px-3 py-1 bg-primary-100 text-primary-800 text-xs font-medium rounded-full">
{getEmploymentText(vacancy.employment_type)}
</span>
<span className="text-xs text-gray-500">
{new Date(vacancy.published_at || vacancy.created_at).toLocaleDateString('ru-RU')}
</span>
</div>
</div>
</Link>
))}
</div>
)}
{/* Empty State */}
{!error && !isLoading && vacancies.length === 0 && (
<div className="text-center py-12">
<div className="text-gray-400 mb-4">
<Search className="h-16 w-16 mx-auto" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
Вакансии не найдены
</h3>
<p className="text-gray-600">
Попробуйте изменить параметры поиска или вернитесь позже
</p>
</div>
)}
</div>
)
}

298
app/vacancy/[id]/page.tsx Normal file
View File

@ -0,0 +1,298 @@
'use client'
import { useParams, useRouter } from 'next/navigation'
import { useVacancy } from '@/hooks/useVacancy'
import { VacancyRead } from '@/types/api'
import {
ArrowLeft,
MapPin,
Clock,
Banknote,
Building,
Users,
Calendar,
Phone,
Mail,
Globe
} from 'lucide-react'
import ResumeUploadForm from '@/components/ResumeUploadForm'
export default function VacancyPage() {
const params = useParams()
const router = useRouter()
const vacancyId = parseInt(params.id as string)
const { data: vacancy, isLoading, error } = useVacancy(vacancyId)
const formatSalary = (vacancy: VacancyRead) => {
if (!vacancy.salary_from && !vacancy.salary_to) return 'Зарплата не указана'
const currency = vacancy.salary_currency === 'RUR' ? '₽' : vacancy.salary_currency
if (vacancy.salary_from && vacancy.salary_to) {
return `${vacancy.salary_from.toLocaleString()} - ${vacancy.salary_to.toLocaleString()} ${currency}`
}
if (vacancy.salary_from) {
return `от ${vacancy.salary_from.toLocaleString()} ${currency}`
}
if (vacancy.salary_to) {
return `до ${vacancy.salary_to.toLocaleString()} ${currency}`
}
}
const getExperienceText = (experience: string) => {
const mapping = {
noExperience: 'Без опыта',
between1And3: '1-3 года',
between3And6: '3-6 лет',
moreThan6: 'Более 6 лет'
}
return mapping[experience as keyof typeof mapping] || experience
}
const getEmploymentText = (employment: string) => {
const mapping = {
full: 'Полная занятость',
part: 'Частичная занятость',
project: 'Проектная работа',
volunteer: 'Волонтерство',
probation: 'Стажировка'
}
return mapping[employment as keyof typeof mapping] || employment
}
const getScheduleText = (schedule: string) => {
const mapping = {
fullDay: 'Полный день',
shift: 'Сменный график',
flexible: 'Гибкий график',
remote: 'Удаленная работа',
flyInFlyOut: 'Вахтовый метод'
}
return mapping[schedule as keyof typeof mapping] || schedule
}
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
)
}
if (error || !vacancy) {
return (
<div className="text-center py-12">
<div className="text-red-600 mb-4">
<p>Не удалось загрузить информацию о вакансии</p>
</div>
<button
onClick={() => router.back()}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Назад
</button>
</div>
)
}
return (
<div className="max-w-4xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<button
onClick={() => router.back()}
className="inline-flex items-center text-gray-600 hover:text-gray-900"
>
<ArrowLeft className="h-5 w-5 mr-2" />
Назад к вакансиям
</button>
{vacancy.premium && (
<span className="px-3 py-1 bg-yellow-100 text-yellow-800 text-sm font-medium rounded-full">
Premium
</span>
)}
</div>
{/* Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Column - Vacancy Details */}
<div className="lg:col-span-2 space-y-6">
{/* Title and Company */}
<div className="bg-white rounded-lg shadow-md p-6">
<h1 className="text-3xl font-bold text-gray-900 mb-4">
{vacancy.title}
</h1>
<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}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div className="flex items-center text-gray-600">
<Banknote className="h-4 w-4 mr-2" />
<span>{formatSalary(vacancy)}</span>
</div>
<div className="flex items-center text-gray-600">
<MapPin className="h-4 w-4 mr-2" />
<span>{vacancy.area_name}</span>
</div>
<div className="flex items-center text-gray-600">
<Clock className="h-4 w-4 mr-2" />
<span>{getExperienceText(vacancy.experience)}</span>
</div>
<div className="flex items-center text-gray-600">
<Users className="h-4 w-4 mr-2" />
<span>{getEmploymentText(vacancy.employment_type)}</span>
</div>
<div className="flex items-center text-gray-600">
<Calendar className="h-4 w-4 mr-2" />
<span>{getScheduleText(vacancy.schedule)}</span>
</div>
{vacancy.published_at && (
<div className="flex items-center text-gray-600">
<Clock className="h-4 w-4 mr-2" />
<span>Опубликовано {new Date(vacancy.published_at).toLocaleDateString('ru-RU')}</span>
</div>
)}
</div>
</div>
{/* Description */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">
Описание вакансии
</h2>
<div className="prose prose-gray max-w-none">
<p className="whitespace-pre-line text-gray-700 leading-relaxed">
{vacancy.description}
</p>
</div>
</div>
{/* Key Skills */}
{vacancy.key_skills && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">
Ключевые навыки
</h2>
<div className="prose prose-gray max-w-none">
<p className="text-gray-700">{vacancy.key_skills}</p>
</div>
</div>
)}
{/* Company Description */}
{vacancy.company_description && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">
О компании
</h2>
<div className="prose prose-gray max-w-none">
<p className="whitespace-pre-line text-gray-700 leading-relaxed">
{vacancy.company_description}
</p>
</div>
</div>
)}
{/* Location Details */}
{(vacancy.address || vacancy.metro_stations) && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">
Местоположение
</h2>
<div className="space-y-2 text-gray-700">
{vacancy.address && (
<div className="flex items-start">
<MapPin className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
<span>{vacancy.address}</span>
</div>
)}
{vacancy.metro_stations && (
<div className="flex items-start">
<span className="text-sm font-medium mr-2">Метро:</span>
<span className="text-sm">{vacancy.metro_stations}</span>
</div>
)}
</div>
</div>
)}
</div>
{/* Right Column - Application Form and Contact Info */}
<div className="space-y-6">
{/* Contact Information */}
{(vacancy.contacts_name || vacancy.contacts_email || vacancy.contacts_phone || vacancy.url) && (
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Контактная информация
</h3>
<div className="space-y-3 text-sm">
{vacancy.contacts_name && (
<div className="flex items-center text-gray-700">
<Users className="h-4 w-4 mr-2" />
<span>{vacancy.contacts_name}</span>
</div>
)}
{vacancy.contacts_email && (
<div className="flex items-center text-gray-700">
<Mail className="h-4 w-4 mr-2" />
<a
href={`mailto:${vacancy.contacts_email}`}
className="hover:text-primary-600"
>
{vacancy.contacts_email}
</a>
</div>
)}
{vacancy.contacts_phone && (
<div className="flex items-center text-gray-700">
<Phone className="h-4 w-4 mr-2" />
<a
href={`tel:${vacancy.contacts_phone}`}
className="hover:text-primary-600"
>
{vacancy.contacts_phone}
</a>
</div>
)}
{vacancy.url && (
<div className="flex items-center text-gray-700">
<Globe className="h-4 w-4 mr-2" />
<a
href={vacancy.url}
target="_blank"
rel="noopener noreferrer"
className="hover:text-primary-600"
>
Перейти к вакансии
</a>
</div>
)}
</div>
</div>
)}
{/* Application Form */}
<ResumeUploadForm
vacancyId={vacancy.id}
vacancyTitle={vacancy.title}
/>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,36 @@
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useState } from 'react'
export default function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
retry: (failureCount, error: any) => {
// Don't retry on 4xx errors
if (error?.status >= 400 && error?.status < 500) {
return false
}
return failureCount < 2
},
},
mutations: {
retry: 1,
},
},
})
)
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}

View File

@ -0,0 +1,325 @@
'use client'
import { useState } from 'react'
import InputMask from 'react-input-mask'
import { ResumeCreate } from '@/types/api'
import { useCreateResume, useResumesByVacancy } from '@/hooks/useResume'
import { Upload, FileText, X, CheckCircle, Clock } from 'lucide-react'
interface ResumeUploadFormProps {
vacancyId: number
vacancyTitle: string
onSuccess?: () => void
}
export default function ResumeUploadForm({ vacancyId, vacancyTitle, onSuccess }: ResumeUploadFormProps) {
const [formData, setFormData] = useState({
applicant_name: '',
applicant_email: '',
applicant_phone: '',
cover_letter: '',
})
const [file, setFile] = useState<File | null>(null)
const [success, setSuccess] = useState(false)
const createResumeMutation = useCreateResume()
const { data: existingResumes, isLoading: isLoadingResumes } = useResumesByVacancy(vacancyId)
// Проверяем есть ли уже резюме для этой вакансии в текущей сессии
const hasExistingResume = existingResumes && existingResumes.length > 0
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]
if (selectedFile) {
// Check file size (max 10MB)
if (selectedFile.size > 10 * 1024 * 1024) {
return
}
// Check file type
const allowedTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain'
]
if (!allowedTypes.includes(selectedFile.type)) {
return
}
setFile(selectedFile)
}
}
const removeFile = () => {
setFile(null)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log('Submit data check:', {
file,
formData,
vacancyId
})
if (!file) {
console.log('No file selected')
return
}
if (!formData.applicant_name || !formData.applicant_email) {
console.log('Missing required fields:', {
applicant_name: formData.applicant_name,
applicant_email: formData.applicant_email
})
return
}
const resumeData: ResumeCreate = {
vacancy_id: vacancyId,
applicant_name: formData.applicant_name,
applicant_email: formData.applicant_email,
applicant_phone: formData.applicant_phone || undefined,
cover_letter: formData.cover_letter || undefined,
resume_file: file,
}
console.log('Sending resume data:', resumeData)
createResumeMutation.mutate(resumeData, {
onSuccess: () => {
setSuccess(true)
// Reset form
setFormData({
applicant_name: '',
applicant_email: '',
applicant_phone: '',
cover_letter: '',
})
setFile(null)
if (onSuccess) {
onSuccess()
}
}
})
}
if (isLoadingResumes) {
return (
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mr-3"></div>
<span className="text-gray-600">Проверяем ваши заявки...</span>
</div>
</div>
)
}
if (success || hasExistingResume) {
return (
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
<div className="flex items-center">
<CheckCircle className="h-6 w-6 text-green-600 mr-3" />
<div>
<h3 className="text-lg font-medium text-green-800">
{success ? 'Резюме успешно отправлено!' : 'Ваше резюме уже отправлено!'}
</h3>
<p className="mt-2 text-green-700">
Готовим для вас сессию для собеседования. Мы свяжемся с вами в ближайшее время.
</p>
{hasExistingResume && existingResumes && (
<div className="mt-4 space-y-2">
{existingResumes.map((resume) => (
<div key={resume.id} className="flex items-center text-sm text-green-600">
<Clock className="h-4 w-4 mr-2" />
<span>
Отправлено: {new Date(resume.created_at).toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
hour: '2-digit',
minute: '2-digit'
})} Статус: {resume.status === 'pending' ? 'На рассмотрении' :
resume.status === 'under_review' ? 'На проверке' :
resume.status === 'interview_scheduled' ? 'Собеседование назначено' :
resume.status === 'interviewed' ? 'Проведено собеседование' :
resume.status === 'accepted' ? 'Принят' :
resume.status === 'rejected' ? 'Отклонен' : resume.status}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}
return (
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="mb-6">
<h3 className="text-lg font-medium text-gray-900">
Откликнуться на вакансию
</h3>
<p className="mt-1 text-sm text-gray-600">
{vacancyTitle}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Personal Information */}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label htmlFor="applicant_name" className="block text-sm font-medium text-gray-700">
Имя *
</label>
<input
type="text"
id="applicant_name"
name="applicant_name"
required
className="mt-1 block w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:outline-none focus:border-primary-500 sm:text-sm"
value={formData.applicant_name}
onChange={handleInputChange}
placeholder="Ваше полное имя"
/>
</div>
<div>
<label htmlFor="applicant_email" className="block text-sm font-medium text-gray-700">
Email *
</label>
<input
type="email"
id="applicant_email"
name="applicant_email"
required
className="mt-1 block w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:outline-none focus:border-primary-500 sm:text-sm"
value={formData.applicant_email}
onChange={handleInputChange}
placeholder="your@email.com"
/>
</div>
</div>
<div>
<label htmlFor="applicant_phone" className="block text-sm font-medium text-gray-700">
Телефон
</label>
<InputMask
mask="+7 (999) 999-99-99"
maskChar="_"
value={formData.applicant_phone}
onChange={handleInputChange}
>
{() => (
<input
type="tel"
id="applicant_phone"
name="applicant_phone"
className="mt-1 block w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:outline-none focus:border-primary-500 sm:text-sm"
placeholder="+7 (999) 999-99-99"
/>
)}
</InputMask>
</div>
{/* Cover Letter */}
<div>
<label htmlFor="cover_letter" className="block text-sm font-medium text-gray-700">
Сопроводительное письмо
</label>
<textarea
id="cover_letter"
name="cover_letter"
rows={4}
className="mt-1 block w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:outline-none focus:border-primary-500 sm:text-sm"
value={formData.cover_letter}
onChange={handleInputChange}
placeholder="Расскажите о себе и почему вас интересует эта позиция..."
/>
</div>
{/* File Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Резюме *
</label>
{!file ? (
<div className="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md hover:border-primary-400 transition-colors">
<div className="space-y-1 text-center">
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<div className="flex text-sm text-gray-600">
<label
htmlFor="resume_file"
className="relative cursor-pointer bg-white rounded-md font-medium text-primary-600 hover:text-primary-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-primary-500"
>
<span>Загрузить резюме</span>
<input
id="resume_file"
name="resume_file"
type="file"
className="sr-only"
accept=".pdf,.doc,.docx,.txt"
onChange={handleFileChange}
/>
</label>
<p className="pl-1">или перетащите сюда</p>
</div>
<p className="text-xs text-gray-500">
PDF, DOC, DOCX, TXT до 10 МБ
</p>
</div>
</div>
) : (
<div className="mt-1 flex items-center justify-between p-3 border border-gray-300 rounded-md">
<div className="flex items-center">
<FileText className="h-5 w-5 text-gray-400 mr-2" />
<span className="text-sm text-gray-900">{file.name}</span>
<span className="ml-2 text-xs text-gray-500">
({(file.size / 1024 / 1024).toFixed(2)} МБ)
</span>
</div>
<button
type="button"
onClick={removeFile}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-5 w-5" />
</button>
</div>
)}
</div>
{/* Error Message */}
{createResumeMutation.error && (
<div className="text-red-600 text-sm bg-red-50 border border-red-200 rounded-md p-3">
Произошла ошибка при отправке резюме
</div>
)}
{/* Submit Button */}
<div className="flex justify-end">
<button
type="submit"
disabled={createResumeMutation.isPending}
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{createResumeMutation.isPending ? 'Отправляем...' : 'Отправить резюме'}
</button>
</div>
</form>
</div>
)
}

44
hooks/useResume.ts Normal file
View File

@ -0,0 +1,44 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { resumeService } from '@/services/resume.service'
import { ResumeCreate, GetResumesParams } from '@/types/api'
export const useCreateResume = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: ResumeCreate) => resumeService.createResume(data),
onSuccess: () => {
// Invalidate and refetch resumes
queryClient.invalidateQueries({ queryKey: ['resumes'] })
},
})
}
export const useResume = (id: number) => {
return useQuery({
queryKey: ['resume', id],
queryFn: () => resumeService.getResume(id),
enabled: !!id,
staleTime: 10 * 60 * 1000, // 10 minutes
retry: 2,
})
}
export const useResumes = (params?: GetResumesParams) => {
return useQuery({
queryKey: ['resumes', params],
queryFn: () => resumeService.getResumes(params),
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 2,
})
}
export const useResumesByVacancy = (vacancyId: number) => {
return useQuery({
queryKey: ['resumes', 'by-vacancy', vacancyId],
queryFn: () => resumeService.getResumes({ vacancy_id: vacancyId }),
enabled: !!vacancyId,
staleTime: 2 * 60 * 1000, // 2 minutes
retry: 2,
})
}

42
hooks/useSession.ts Normal file
View File

@ -0,0 +1,42 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { sessionService } from '@/services/session.service'
export const useCurrentSession = () => {
return useQuery({
queryKey: ['session', 'current'],
queryFn: () => sessionService.getCurrentSession(),
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 2,
})
}
export const useRefreshSession = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => sessionService.refreshSession(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['session'] })
},
})
}
export const useLogout = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => sessionService.logout(),
onSuccess: () => {
queryClient.clear()
},
})
}
export const useSessionHealth = () => {
return useQuery({
queryKey: ['session', 'health'],
queryFn: () => sessionService.healthCheck(),
staleTime: 2 * 60 * 1000, // 2 minutes
retry: 2,
})
}

22
hooks/useVacancy.ts Normal file
View File

@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query'
import { vacancyService } from '@/services/vacancy.service'
import { GetVacanciesParams } from '@/types/api'
export const useVacancies = (params?: GetVacanciesParams) => {
return useQuery({
queryKey: ['vacancies', params],
queryFn: () => vacancyService.getVacancies(params),
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 2,
})
}
export const useVacancy = (id: number) => {
return useQuery({
queryKey: ['vacancy', id],
queryFn: () => vacancyService.getVacancy(id),
enabled: !!id,
staleTime: 10 * 60 * 1000, // 10 minutes
retry: 2,
})
}

34
lib/ky-client.ts Normal file
View File

@ -0,0 +1,34 @@
import ky from 'ky'
// Используем прокси Next.js для избежания CORS проблем
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'
// Базовый клиент без Content-Type заголовка
const baseKyClient = ky.create({
prefixUrl: API_BASE_URL,
credentials: 'include',
hooks: {
beforeError: [
(error) => {
const { response } = error
if (response && response.body) {
error.name = 'APIError'
error.message = `${response.status} ${response.statusText}`
}
return error
},
],
},
})
// JSON клиент
export const kyClient = baseKyClient.extend({
headers: {
'Content-Type': 'application/json',
},
})
// FormData клиент (без Content-Type заголовка)
export const kyFormClient = baseKyClient
export default kyClient

5
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

13
next.config.js Normal file
View File

@ -0,0 +1,13 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
{
source: '/api/v1/:path*',
destination: 'http://localhost:8000/api/v1/:path*',
},
]
},
}
module.exports = nextConfig

33
package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "hr-ai-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@tanstack/react-query": "^5.85.6",
"@tanstack/react-query-devtools": "^5.85.6",
"ky": "^1.9.1",
"lucide-react": "^0.294.0",
"next": "14.0.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-input-mask": "^2.0.4"
},
"devDependencies": {
"@types/node": "^20.10.5",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"autoprefixer": "^10.4.16",
"eslint": "^8.56.0",
"eslint-config-next": "14.0.4",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,51 @@
import { kyClient, kyFormClient } from '@/lib/ky-client'
import { ResumeRead, ResumeCreate, GetResumesParams } from '@/types/api'
export const resumeService = {
async createResume(data: ResumeCreate): Promise<ResumeRead> {
const formData = new FormData()
formData.append('vacancy_id', data.vacancy_id.toString())
formData.append('applicant_name', data.applicant_name)
formData.append('applicant_email', data.applicant_email)
if (data.applicant_phone) {
formData.append('applicant_phone', data.applicant_phone)
}
if (data.cover_letter) {
formData.append('cover_letter', data.cover_letter)
}
formData.append('resume_file', data.resume_file)
// Логируем данные для отладки
console.log('FormData entries:')
for (const [key, value] of formData.entries()) {
console.log(key, value)
}
return kyFormClient.post('api/v1/resumes/', {
body: formData,
}).json<ResumeRead>()
},
async getResume(id: number): Promise<ResumeRead> {
return kyClient.get(`api/v1/resumes/${id}`).json<ResumeRead>()
},
async getResumes(params?: GetResumesParams): Promise<ResumeRead[]> {
const searchParams = new URLSearchParams()
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, value.toString())
}
})
}
const endpoint = `api/v1/resumes/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`
return kyClient.get(endpoint).json<ResumeRead[]>()
},
}

View File

@ -0,0 +1,20 @@
import { kyClient } from '@/lib/ky-client'
import { SessionRead } from '@/types/api'
export const sessionService = {
async getCurrentSession(): Promise<SessionRead> {
return kyClient.get('api/v1/sessions/current').json<SessionRead>()
},
async refreshSession(): Promise<void> {
return kyClient.post('api/v1/sessions/refresh').json<void>()
},
async logout(): Promise<void> {
return kyClient.post('api/v1/sessions/logout').json<void>()
},
async healthCheck(): Promise<void> {
return kyClient.get('api/v1/sessions/health').json<void>()
},
}

View File

@ -0,0 +1,23 @@
import { kyClient } from '@/lib/ky-client'
import { VacancyRead, GetVacanciesParams } from '@/types/api'
export const vacancyService = {
async getVacancies(params?: GetVacanciesParams): Promise<VacancyRead[]> {
const searchParams = new URLSearchParams()
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, value.toString())
}
})
}
const endpoint = `api/v1/vacancies/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`
return kyClient.get(endpoint).json<VacancyRead[]>()
},
async getVacancy(id: number): Promise<VacancyRead> {
return kyClient.get(`api/v1/vacancies/${id}`).json<VacancyRead>()
},
}

27
tailwind.config.js Normal file
View File

@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
},
},
},
},
plugins: [],
}

28
tsconfig.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "es6"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

122
types/api.ts Normal file
View File

@ -0,0 +1,122 @@
export type EmploymentType = 'full' | 'part' | 'project' | 'volunteer' | 'probation'
export type Experience = 'noExperience' | 'between1And3' | 'between3And6' | 'moreThan6'
export type Schedule = 'fullDay' | 'shift' | 'flexible' | 'remote' | 'flyInFlyOut'
export type ResumeStatus = 'pending' | 'under_review' | 'interview_scheduled' | 'interviewed' | 'rejected' | 'accepted'
export interface VacancyRead {
id: number
title: string
description: string
key_skills?: string
employment_type: EmploymentType
experience: Experience
schedule: Schedule
salary_from?: number
salary_to?: number
salary_currency?: string
gross_salary?: boolean
company_name: string
company_description?: string
area_name: string
metro_stations?: string
address?: string
professional_roles?: string
contacts_name?: string
contacts_email?: string
contacts_phone?: string
is_archived: boolean
premium: boolean
published_at?: string
url?: string
created_at: string
updated_at: string
}
export interface VacancyCreate {
title: string
description: string
key_skills?: string
employment_type: EmploymentType
experience: Experience
schedule: Schedule
salary_from?: number
salary_to?: number
salary_currency?: string
gross_salary?: boolean
company_name: string
company_description?: string
area_name: string
metro_stations?: string
address?: string
professional_roles?: string
contacts_name?: string
contacts_email?: string
contacts_phone?: string
is_archived?: boolean
premium?: boolean
published_at?: string
url?: string
}
export interface ResumeRead {
id: number
vacancy_id: number
session_id: number
applicant_name: string
applicant_email: string
applicant_phone?: string
resume_file_url: string
cover_letter?: string
status: ResumeStatus
interview_report_url?: string
notes?: string
created_at: string
updated_at: string
}
export interface ResumeCreate {
vacancy_id: number
applicant_name: string
applicant_email: string
applicant_phone?: string
cover_letter?: string
resume_file: File
}
export interface SessionRead {
id: number
session_id: string
user_agent?: string
ip_address?: string
is_active: boolean
expires_at: string
last_activity: string
created_at: string
updated_at: string
}
export interface ValidationError {
loc: (string | number)[]
msg: string
type: string
}
export interface HTTPValidationError {
detail?: ValidationError[]
}
export interface GetVacanciesParams {
skip?: number
limit?: number
active_only?: boolean
title?: string
company_name?: string
area_name?: string
}
export interface GetResumesParams {
skip?: number
limit?: number
vacancy_id?: number
status?: ResumeStatus
}

3138
yarn.lock Normal file

File diff suppressed because it is too large Load Diff