diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..0e81f9b --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..13a90d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.next +.claude +.idea +node_modules \ No newline at end of file diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..5139484 --- /dev/null +++ b/app/globals.css @@ -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; +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..997794a --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + + + + +
+
+
+
+

HR AI

+
+ +
+
+
+
+ {children} +
+
+ + + ) +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..6a810bf --- /dev/null +++ b/app/page.tsx @@ -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 ( +
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+

+ Найдите работу мечты +

+

+ Платформа с искусственным интеллектом для поиска идеальной вакансии +

+
+ + {/* Search */} +
+
+
+ + setSearchTerm(e.target.value)} + /> +
+ +
+
+ + {/* Error */} + {error && ( +
+

Не удалось загрузить вакансии

+ +
+ )} + + {/* Vacancies Grid */} + {!error && vacancies.length > 0 && ( +
+ {vacancies.map((vacancy) => ( + +
+
+

+ {vacancy.title} +

+ {vacancy.premium && ( + + Premium + + )} +
+ +
+
+ + {formatSalary(vacancy)} +
+ +
+ + {vacancy.area_name} +
+ +
+ + {getExperienceText(vacancy.experience)} +
+
+ +
+

{vacancy.company_name}

+

{vacancy.description}

+
+ +
+ + {getEmploymentText(vacancy.employment_type)} + + + {new Date(vacancy.published_at || vacancy.created_at).toLocaleDateString('ru-RU')} + +
+
+ + ))} +
+ )} + + {/* Empty State */} + {!error && !isLoading && vacancies.length === 0 && ( +
+
+ +
+

+ Вакансии не найдены +

+

+ Попробуйте изменить параметры поиска или вернитесь позже +

+
+ )} +
+ ) +} \ No newline at end of file diff --git a/app/vacancy/[id]/page.tsx b/app/vacancy/[id]/page.tsx new file mode 100644 index 0000000..77ca1f1 --- /dev/null +++ b/app/vacancy/[id]/page.tsx @@ -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 ( +
+
+
+ ) + } + + if (error || !vacancy) { + return ( +
+
+

Не удалось загрузить информацию о вакансии

+
+ +
+ ) + } + + return ( +
+ {/* Header */} +
+ + + {vacancy.premium && ( + + Premium + + )} +
+ + {/* Main Content */} +
+ {/* Left Column - Vacancy Details */} +
+ {/* Title and Company */} +
+

+ {vacancy.title} +

+ +
+ + + {vacancy.company_name} + +
+ +
+
+ + {formatSalary(vacancy)} +
+ +
+ + {vacancy.area_name} +
+ +
+ + {getExperienceText(vacancy.experience)} +
+ +
+ + {getEmploymentText(vacancy.employment_type)} +
+ +
+ + {getScheduleText(vacancy.schedule)} +
+ + {vacancy.published_at && ( +
+ + Опубликовано {new Date(vacancy.published_at).toLocaleDateString('ru-RU')} +
+ )} +
+
+ + {/* Description */} +
+

+ Описание вакансии +

+
+

+ {vacancy.description} +

+
+
+ + {/* Key Skills */} + {vacancy.key_skills && ( +
+

+ Ключевые навыки +

+
+

{vacancy.key_skills}

+
+
+ )} + + {/* Company Description */} + {vacancy.company_description && ( +
+

+ О компании +

+
+

+ {vacancy.company_description} +

+
+
+ )} + + {/* Location Details */} + {(vacancy.address || vacancy.metro_stations) && ( +
+

+ Местоположение +

+
+ {vacancy.address && ( +
+ + {vacancy.address} +
+ )} + {vacancy.metro_stations && ( +
+ Метро: + {vacancy.metro_stations} +
+ )} +
+
+ )} +
+ + {/* Right Column - Application Form and Contact Info */} +
+ {/* Contact Information */} + {(vacancy.contacts_name || vacancy.contacts_email || vacancy.contacts_phone || vacancy.url) && ( +
+

+ Контактная информация +

+
+ {vacancy.contacts_name && ( +
+ + {vacancy.contacts_name} +
+ )} + {vacancy.contacts_email && ( + + )} + {vacancy.contacts_phone && ( + + )} + {vacancy.url && ( + + )} +
+
+ )} + + {/* Application Form */} + +
+
+
+ ) +} \ No newline at end of file diff --git a/components/QueryProvider.tsx b/components/QueryProvider.tsx new file mode 100644 index 0000000..1407790 --- /dev/null +++ b/components/QueryProvider.tsx @@ -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 ( + + {children} + + + ) +} \ No newline at end of file diff --git a/components/ResumeUploadForm.tsx b/components/ResumeUploadForm.tsx new file mode 100644 index 0000000..c69fbf7 --- /dev/null +++ b/components/ResumeUploadForm.tsx @@ -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(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) => { + const { name, value } = e.target + setFormData(prev => ({ ...prev, [name]: value })) + } + + const handleFileChange = (e: React.ChangeEvent) => { + 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 ( +
+
+
+ Проверяем ваши заявки... +
+
+ ) + } + + if (success || hasExistingResume) { + return ( +
+
+ +
+

+ {success ? 'Резюме успешно отправлено!' : 'Ваше резюме уже отправлено!'} +

+

+ Готовим для вас сессию для собеседования. Мы свяжемся с вами в ближайшее время. +

+ {hasExistingResume && existingResumes && ( +
+ {existingResumes.map((resume) => ( +
+ + + Отправлено: {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} + +
+ ))} +
+ )} +
+
+
+ ) + } + + return ( +
+
+

+ Откликнуться на вакансию +

+

+ {vacancyTitle} +

+
+ +
+ {/* Personal Information */} +
+
+ + +
+ +
+ + +
+
+ +
+ + + {() => ( + + )} + +
+ + {/* Cover Letter */} +
+ +