init
This commit is contained in:
parent
6654eb15d0
commit
056f70a1ad
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.next
|
||||||
|
.claude
|
||||||
|
.idea
|
||||||
|
node_modules
|
16
app/globals.css
Normal file
16
app/globals.css
Normal 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
43
app/layout.tsx
Normal 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
193
app/page.tsx
Normal 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
298
app/vacancy/[id]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
36
components/QueryProvider.tsx
Normal file
36
components/QueryProvider.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
325
components/ResumeUploadForm.tsx
Normal file
325
components/ResumeUploadForm.tsx
Normal 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
44
hooks/useResume.ts
Normal 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
42
hooks/useSession.ts
Normal 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
22
hooks/useVacancy.ts
Normal 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
34
lib/ky-client.ts
Normal 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
5
next-env.d.ts
vendored
Normal 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
13
next.config.js
Normal 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
33
package.json
Normal 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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
51
services/resume.service.ts
Normal file
51
services/resume.service.ts
Normal 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[]>()
|
||||||
|
},
|
||||||
|
}
|
20
services/session.service.ts
Normal file
20
services/session.service.ts
Normal 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>()
|
||||||
|
},
|
||||||
|
}
|
23
services/vacancy.service.ts
Normal file
23
services/vacancy.service.ts
Normal 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
27
tailwind.config.js
Normal 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
28
tsconfig.json
Normal 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
122
types/api.ts
Normal 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
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user