Compare commits
	
		
			No commits in common. "206527fa0dcf0500c0b021b626829e1b3a9f66e6" and "056f70a1adc11dd01fb9693165040d254bdba080" have entirely different histories.
		
	
	
		
			206527fa0d
			...
			056f70a1ad
		
	
		
| @ -1,16 +0,0 @@ | |||||||
| Dockerfile |  | ||||||
| .dockerignore |  | ||||||
| node_modules |  | ||||||
| npm-debug.log* |  | ||||||
| .next |  | ||||||
| .git |  | ||||||
| .gitignore |  | ||||||
| README.md |  | ||||||
| .env |  | ||||||
| .env.local |  | ||||||
| .env.production.local |  | ||||||
| .env.development.local |  | ||||||
| coverage |  | ||||||
| .nyc_output |  | ||||||
| .DS_Store |  | ||||||
| *.tsbuildinfo |  | ||||||
| @ -1,2 +0,0 @@ | |||||||
| NEXT_PUBLIC_API_BASE_URL=https://hr.aiquity.xyz:8000/api |  | ||||||
| NEXT_PUBLIC_LIVEKIT_URL=wss://hackaton-eizc9zqk.livekit.cloud |  | ||||||
							
								
								
									
										37
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										37
									
								
								Dockerfile
									
									
									
									
									
								
							| @ -1,37 +0,0 @@ | |||||||
| FROM --platform=linux/amd64 node:18-alpine AS base |  | ||||||
| 
 |  | ||||||
| WORKDIR /app |  | ||||||
| 
 |  | ||||||
| FROM base AS deps |  | ||||||
| RUN apk add --no-cache libc6-compat |  | ||||||
| COPY package.json yarn.lock* ./ |  | ||||||
| RUN yarn |  | ||||||
| 
 |  | ||||||
| FROM base AS builder |  | ||||||
| COPY --from=deps /app/node_modules ./node_modules |  | ||||||
| COPY . . |  | ||||||
| 
 |  | ||||||
| RUN yarn build |  | ||||||
| 
 |  | ||||||
| FROM base AS runner |  | ||||||
| WORKDIR /app |  | ||||||
| 
 |  | ||||||
| ENV NODE_ENV production |  | ||||||
| 
 |  | ||||||
| RUN addgroup --system --gid 1001 nodejs |  | ||||||
| RUN adduser --system --uid 1001 nextjs |  | ||||||
| 
 |  | ||||||
| RUN mkdir .next |  | ||||||
| RUN chown nextjs:nodejs .next |  | ||||||
| 
 |  | ||||||
| COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ |  | ||||||
| COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static |  | ||||||
| 
 |  | ||||||
| USER nextjs |  | ||||||
| 
 |  | ||||||
| EXPOSE 3000 |  | ||||||
| 
 |  | ||||||
| ENV PORT 3000 |  | ||||||
| ENV HOSTNAME "0.0.0.0" |  | ||||||
| 
 |  | ||||||
| CMD ["node", "server.js"] |  | ||||||
							
								
								
									
										131
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										131
									
								
								README.md
									
									
									
									
									
								
							| @ -1,130 +1 @@ | |||||||
| # HR AI Frontend | # hr-ai | ||||||
| 
 |  | ||||||
| Современная платформа для поиска работы с искусственным интеллектом, построенная на Next.js и TypeScript. |  | ||||||
| 
 |  | ||||||
| ## Возможности |  | ||||||
| 
 |  | ||||||
| - 📋 Просмотр списка вакансий с поиском и фильтрацией |  | ||||||
| - 🔍 Детальная информация о каждой вакансии |  | ||||||
| - 📄 Загрузка резюме с уведомлением о подготовке сессии собеседования |  | ||||||
| - 🎤 Проведение AI-собеседований с проверкой микрофона |  | ||||||
| - 📊 Создание вакансий из файлов (PDF, DOC, DOCX, RTF, TXT) |  | ||||||
| - 📈 Просмотр отчетов по кандидатам для каждой вакансии |  | ||||||
| - 🔐 Авторизация через cookie-сессии (без JWT) |  | ||||||
| - 🎨 Современный и адаптивный дизайн |  | ||||||
| - ⚡ Быстрая загрузка и отзывчивый интерфейс |  | ||||||
| 
 |  | ||||||
| ## Технологический стек |  | ||||||
| 
 |  | ||||||
| - **Framework**: Next.js 14 (App Router) |  | ||||||
| - **Language**: TypeScript |  | ||||||
| - **Styling**: Tailwind CSS |  | ||||||
| - **Icons**: Lucide React |  | ||||||
| - **HTTP Client**: Fetch API с cookie-авторизацией |  | ||||||
| 
 |  | ||||||
| ## Быстрый старт |  | ||||||
| 
 |  | ||||||
| 1. Установите зависимости: |  | ||||||
| ```bash |  | ||||||
| yarn install |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| 2. Создайте файл `.env.local` (уже создан): |  | ||||||
| ```env |  | ||||||
| NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| 3. Запустите development сервер: |  | ||||||
| ```bash |  | ||||||
| yarn dev |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| 4. Откройте [http://localhost:3000](http://localhost:3000) в браузере |  | ||||||
| 
 |  | ||||||
| ## Структура проекта |  | ||||||
| 
 |  | ||||||
| ``` |  | ||||||
| ├── app/                    # Next.js App Router |  | ||||||
| │   ├── globals.css        # Глобальные стили |  | ||||||
| │   ├── layout.tsx         # Корневой layout |  | ||||||
| │   ├── page.tsx          # Главная страница (список вакансий) |  | ||||||
| │   ├── vacancy/[id]/     # Страница детальной информации о вакансии |  | ||||||
| │   │   └── report/[id]/  # Страница отчетов по вакансии |  | ||||||
| │   └── interview/[id]/   # Страница AI-собеседования |  | ||||||
| ├── components/           # React компоненты |  | ||||||
| │   ├── ResumeUploadForm.tsx    # Форма загрузки резюме |  | ||||||
| │   ├── VacancyUploadForm.tsx   # Форма создания вакансии |  | ||||||
| │   ├── VacancyReports.tsx      # Отчеты по вакансии |  | ||||||
| │   └── InterviewSession.tsx    # AI-собеседование |  | ||||||
| ├── hooks/               # React Query хуки |  | ||||||
| │   ├── useVacancy.ts |  | ||||||
| │   ├── useResume.ts |  | ||||||
| │   └── useReports.ts |  | ||||||
| ├── services/           # API сервисы |  | ||||||
| │   ├── vacancy.service.ts |  | ||||||
| │   ├── resume.service.ts |  | ||||||
| │   └── reports.service.ts |  | ||||||
| ├── lib/                # Утилиты и API клиент |  | ||||||
| │   └── api-client.ts |  | ||||||
| ├── types/             # TypeScript типы |  | ||||||
| │   └── api.ts |  | ||||||
| └── public/           # Статические файлы |  | ||||||
| ``` |  | ||||||
| 
 |  | ||||||
| ## API Integration |  | ||||||
| 
 |  | ||||||
| Приложение интегрируется с HR AI Backend API: |  | ||||||
| 
 |  | ||||||
| ### Основные эндпоинты: |  | ||||||
| - `GET /api/v1/vacancies/` - Получение списка вакансий |  | ||||||
| - `GET /api/v1/vacancies/{id}` - Получение вакансии по ID |  | ||||||
| - `POST /api/v1/vacancies/parse-file-async` - Создание вакансии из файла |  | ||||||
| - `POST /api/v1/resumes/` - Загрузка резюме |  | ||||||
| - `GET /api/v1/resumes/by-vacancy/{vacancy_id}` - Получение резюме по вакансии |  | ||||||
| - `GET /api/v1/sessions/current` - Получение информации о сессии |  | ||||||
| - `GET /api/v1/reports/vacancy/{vacancy_id}` - Получение отчетов по вакансии |  | ||||||
| 
 |  | ||||||
| ### Авторизация: |  | ||||||
| Все запросы выполняются с `credentials: 'include'` для работы с cookie-сессиями. |  | ||||||
| 
 |  | ||||||
| ## Особенности реализации |  | ||||||
| 
 |  | ||||||
| ### Создание вакансий |  | ||||||
| - Загрузка из файлов: PDF, DOC, DOCX, RTF, TXT |  | ||||||
| - Drag & Drop интерфейс |  | ||||||
| - Максимальный размер файла: 10 МБ |  | ||||||
| - Toast уведомления об успешной обработке |  | ||||||
| - Автоматическое обновление списка каждые 5 секунд |  | ||||||
| 
 |  | ||||||
| ### Загрузка резюме |  | ||||||
| - Поддержка файлов: PDF, DOCX |  | ||||||
| - Drag & Drop интерфейс  |  | ||||||
| - Максимальный размер файла: 10 МБ |  | ||||||
| - Валидация формы перед отправкой |  | ||||||
| - Автоматическая проверка микрофона перед собеседованием |  | ||||||
| - Статусы обработки: обрабатывается → обработано → готово к собеседованию |  | ||||||
| 
 |  | ||||||
| ### AI-собеседования |  | ||||||
| - Проверка доступа к микрофону |  | ||||||
| - WebRTC соединение с сервером |  | ||||||
| - Реальное время голосового взаимодействия |  | ||||||
| - Обработка ошибок подключения |  | ||||||
| 
 |  | ||||||
| ### Поиск вакансий |  | ||||||
| - Поиск по названию вакансии |  | ||||||
| - Фильтрация активных вакансий |  | ||||||
| - Заглушки для незаполненных рядов в сетке 3x3 |  | ||||||
| - Обработка null значений ("Не указано") |  | ||||||
| 
 |  | ||||||
| ### Детальная страница вакансии |  | ||||||
| - Полная информация о вакансии |  | ||||||
| - Контактные данные |  | ||||||
| - Форма для отклика прямо на странице |  | ||||||
| - Переход к отчетам по кандидатам |  | ||||||
| 
 |  | ||||||
| ### Отчеты по вакансии |  | ||||||
| - Список всех кандидатов |  | ||||||
| - Информация о собеседованиях |  | ||||||
| - Статусы кандидатов |  | ||||||
| - Ссылки на PDF отчеты |  | ||||||
| - Заметки интервьюера и следующие шаги |  | ||||||
| @ -1,105 +0,0 @@ | |||||||
| 'use client' |  | ||||||
| 
 |  | ||||||
| import { useParams, useRouter } from 'next/navigation' |  | ||||||
| import InterviewSession from '@/components/InterviewSession' |  | ||||||
| import { useValidateInterview } from '@/hooks/useResume' |  | ||||||
| import { ArrowLeft, AlertCircle, Loader } from 'lucide-react' |  | ||||||
| 
 |  | ||||||
| export default function InterviewPage() { |  | ||||||
|   const params = useParams() |  | ||||||
|   const router = useRouter() |  | ||||||
|   const resumeId = parseInt(params.id as string) |  | ||||||
| 
 |  | ||||||
|   const { data: validationData, isLoading, error } = useValidateInterview(resumeId) |  | ||||||
| 
 |  | ||||||
|   const handleInterviewEnd = () => { |  | ||||||
|     // Перенаправляем обратно к вакансии или на главную страницу
 |  | ||||||
|     router.back() |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   if (isLoading) { |  | ||||||
|     return ( |  | ||||||
|       <div className="flex flex-col items-center justify-center min-h-screen bg-gray-50"> |  | ||||||
|         <Loader className="h-12 w-12 text-blue-600 animate-spin mb-4" /> |  | ||||||
|         <h2 className="text-xl font-semibold text-gray-900 mb-2"> |  | ||||||
|           Проверяем готовность к собеседованию |  | ||||||
|         </h2> |  | ||||||
|         <p className="text-gray-600 text-center max-w-md"> |  | ||||||
|           Пожалуйста, подождите... |  | ||||||
|         </p> |  | ||||||
|       </div> |  | ||||||
|     ) |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|   if (error || !validationData?.can_interview) { |  | ||||||
|     let errorMessage = '' |  | ||||||
|     if (error instanceof Response) { |  | ||||||
|       if (error.status === 404) errorMessage = "Резюме не найдено"; |  | ||||||
|       else if (error.status === 400) errorMessage = "Резюме еще не готово к собеседованию" ; |  | ||||||
|       else errorMessage = validationData?.message || 'Собеседование недоступно' ; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|     return ( |  | ||||||
|       <div className="flex flex-col items-center justify-center min-h-screen bg-gray-50"> |  | ||||||
|         <AlertCircle className="h-12 w-12 text-red-600 mb-4" /> |  | ||||||
|         <h2 className="text-xl font-semibold text-gray-900 mb-2"> |  | ||||||
|           Собеседование недоступно |  | ||||||
|         </h2> |  | ||||||
|         <p className="text-gray-600 text-center max-w-md mb-6"> |  | ||||||
|           {errorMessage} |  | ||||||
|         </p> |  | ||||||
|         <div className="space-x-4"> |  | ||||||
|           <button |  | ||||||
|             onClick={() => router.back()} |  | ||||||
|             className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50" |  | ||||||
|           > |  | ||||||
|             <ArrowLeft className="h-4 w-4 mr-2" /> |  | ||||||
|             Назад |  | ||||||
|           </button> |  | ||||||
|           <button |  | ||||||
|             onClick={() => router.push('/')} |  | ||||||
|             className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700" |  | ||||||
|           > |  | ||||||
|             На главную |  | ||||||
|           </button> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     ) |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <div> |  | ||||||
|       {/* Navigation Header */} |  | ||||||
|       <div className="bg-white border-b border-gray-200 px-4 py-3"> |  | ||||||
|         <div className="max-w-4xl mx-auto 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> |  | ||||||
| 
 |  | ||||||
|           <div className="text-center"> |  | ||||||
|             <h1 className="text-lg font-semibold text-gray-900"> |  | ||||||
|               HR Собеседование |  | ||||||
|             </h1> |  | ||||||
|             <p className="text-sm text-gray-500"> |  | ||||||
|               Резюме #{resumeId} |  | ||||||
|             </p> |  | ||||||
|           </div> |  | ||||||
| 
 |  | ||||||
|           <div className="w-24"></div> {/* Spacer for centering */} |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
| 
 |  | ||||||
|       {/* Interview Session */} |  | ||||||
|       <InterviewSession |  | ||||||
|         resumeId={ resumeId } |  | ||||||
|         onEnd={ handleInterviewEnd } |  | ||||||
|       /> |  | ||||||
|     </div> |  | ||||||
|   ) |  | ||||||
| } |  | ||||||
							
								
								
									
										93
									
								
								app/page.tsx
									
									
									
									
									
								
							
							
						
						
									
										93
									
								
								app/page.tsx
									
									
									
									
									
								
							| @ -4,12 +4,10 @@ import { useState } from 'react' | |||||||
| import { VacancyRead } from '@/types/api' | import { VacancyRead } from '@/types/api' | ||||||
| import { useVacancies } from '@/hooks/useVacancy' | import { useVacancies } from '@/hooks/useVacancy' | ||||||
| import Link from 'next/link' | import Link from 'next/link' | ||||||
| import { Search, MapPin, Clock, Banknote, Plus } from 'lucide-react' | import { Search, MapPin, Clock, Banknote } from 'lucide-react' | ||||||
| import VacancyUploadForm from '@/components/VacancyUploadForm' |  | ||||||
| 
 | 
 | ||||||
| export default function HomePage() { | export default function HomePage() { | ||||||
|   const [searchTerm, setSearchTerm] = useState('') |   const [searchTerm, setSearchTerm] = useState('') | ||||||
|   const [showCreateForm, setShowCreateForm] = useState(false) |  | ||||||
|   const [searchParams, setSearchParams] = useState({ |   const [searchParams, setSearchParams] = useState({ | ||||||
|     active_only: true, |     active_only: true, | ||||||
|     title: undefined as string | undefined, |     title: undefined as string | undefined, | ||||||
| @ -64,45 +62,6 @@ export default function HomePage() { | |||||||
|     return mapping[employment as keyof typeof mapping] || employment |     return mapping[employment as keyof typeof mapping] || employment | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   const formatNullableField = (value: string | null | undefined) => { |  | ||||||
|     if (!value || value === 'null') return 'Не указано' |  | ||||||
|     return value |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   const VacancyPlaceholder = () => ( |  | ||||||
|     <div className="bg-white rounded-lg shadow-md border border-gray-200 p-6"> |  | ||||||
|       <div className="space-y-4"> |  | ||||||
|         <div className="h-6 bg-gray-200 rounded animate-pulse"></div> |  | ||||||
|         <div className="space-y-2"> |  | ||||||
|           <div className="h-4 bg-gray-200 rounded animate-pulse w-3/4"></div> |  | ||||||
|           <div className="h-4 bg-gray-200 rounded animate-pulse w-2/3"></div> |  | ||||||
|           <div className="h-4 bg-gray-200 rounded animate-pulse w-1/2"></div> |  | ||||||
|         </div> |  | ||||||
|         <div className="space-y-2"> |  | ||||||
|           <div className="h-4 bg-gray-200 rounded animate-pulse"></div> |  | ||||||
|           <div className="h-4 bg-gray-200 rounded animate-pulse w-4/5"></div> |  | ||||||
|         </div> |  | ||||||
|         <div className="flex justify-between items-center"> |  | ||||||
|           <div className="h-6 bg-gray-200 rounded-full animate-pulse w-24"></div> |  | ||||||
|           <div className="h-4 bg-gray-200 rounded animate-pulse w-16"></div> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|   ) |  | ||||||
| 
 |  | ||||||
|   const getVacanciesWithPlaceholders = (vacancies: VacancyRead[]) => { |  | ||||||
|     const itemsPerRow = 3 |  | ||||||
|     const remainder = vacancies.length % itemsPerRow |  | ||||||
|     const placeholdersNeeded = remainder === 0 ? 0 : itemsPerRow - remainder |  | ||||||
|      |  | ||||||
|     const placeholders = Array(placeholdersNeeded).fill(null).map((_, index) => ({ |  | ||||||
|       id: `placeholder-${index}`, |  | ||||||
|       isPlaceholder: true |  | ||||||
|     })) |  | ||||||
|      |  | ||||||
|     return [...vacancies, ...placeholders] |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   if (isLoading) { |   if (isLoading) { | ||||||
|     return ( |     return ( | ||||||
|       <div className="flex justify-center items-center min-h-[400px]"> |       <div className="flex justify-center items-center min-h-[400px]"> | ||||||
| @ -118,9 +77,8 @@ export default function HomePage() { | |||||||
|         <h1 className="text-4xl font-bold text-gray-900 mb-4"> |         <h1 className="text-4xl font-bold text-gray-900 mb-4"> | ||||||
|           Найдите работу мечты |           Найдите работу мечты | ||||||
|         </h1> |         </h1> | ||||||
|         <p className="text-lg text-gray-600 max-w-3xl mx-auto"> |         <p className="text-lg text-gray-600 max-w-2xl mx-auto"> | ||||||
|           Выберите понравившуюся вам вакансию, заполните форму и прикрепите резюме.<br/> |           Платформа с искусственным интеллектом для поиска идеальной вакансии | ||||||
|           После недолговременной обработки вашего документа мы предоставим вам возможность подключится к сессии для собеседования, если ваше резюме удовлетворит вакансию. |  | ||||||
|         </p> |         </p> | ||||||
|       </div> |       </div> | ||||||
| 
 | 
 | ||||||
| @ -162,13 +120,7 @@ export default function HomePage() { | |||||||
|       {/* Vacancies Grid */} |       {/* Vacancies Grid */} | ||||||
|       {!error && vacancies.length > 0 && ( |       {!error && vacancies.length > 0 && ( | ||||||
|         <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> |         <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> | ||||||
|           {getVacanciesWithPlaceholders(vacancies).map((item, index) => { |           {vacancies.map((vacancy) => ( | ||||||
|             if ('isPlaceholder' in item) { |  | ||||||
|               return <VacancyPlaceholder key={item.id} /> |  | ||||||
|             } |  | ||||||
|              |  | ||||||
|             const vacancy = item as VacancyRead |  | ||||||
|             return ( |  | ||||||
|             <Link |             <Link | ||||||
|               key={vacancy.id} |               key={vacancy.id} | ||||||
|               href={`/vacancy/${vacancy.id}`} |               href={`/vacancy/${vacancy.id}`} | ||||||
| @ -194,7 +146,7 @@ export default function HomePage() { | |||||||
|                    |                    | ||||||
|                   <div className="flex items-center text-gray-600 text-sm"> |                   <div className="flex items-center text-gray-600 text-sm"> | ||||||
|                     <MapPin className="h-4 w-4 mr-2" /> |                     <MapPin className="h-4 w-4 mr-2" /> | ||||||
|                       <span>{formatNullableField(vacancy.area_name)}</span> |                     <span>{vacancy.area_name}</span> | ||||||
|                   </div> |                   </div> | ||||||
|                    |                    | ||||||
|                   <div className="flex items-center text-gray-600 text-sm"> |                   <div className="flex items-center text-gray-600 text-sm"> | ||||||
| @ -204,8 +156,8 @@ export default function HomePage() { | |||||||
|                 </div> |                 </div> | ||||||
| 
 | 
 | ||||||
|                 <div className="text-sm text-gray-700 mb-4"> |                 <div className="text-sm text-gray-700 mb-4"> | ||||||
|                     <p className="font-medium">{formatNullableField(vacancy.company_name)}</p> |                   <p className="font-medium">{vacancy.company_name}</p> | ||||||
|                     <p className="line-clamp-2 mt-1">{formatNullableField(vacancy.description)}</p> |                   <p className="line-clamp-2 mt-1">{vacancy.description}</p> | ||||||
|                 </div> |                 </div> | ||||||
| 
 | 
 | ||||||
|                 <div className="flex items-center justify-between"> |                 <div className="flex items-center justify-between"> | ||||||
| @ -218,8 +170,7 @@ export default function HomePage() { | |||||||
|                 </div> |                 </div> | ||||||
|               </div> |               </div> | ||||||
|             </Link> |             </Link> | ||||||
|             ) |           ))} | ||||||
|           })} |  | ||||||
|         </div> |         </div> | ||||||
|       )} |       )} | ||||||
| 
 | 
 | ||||||
| @ -237,34 +188,6 @@ export default function HomePage() { | |||||||
|           </p> |           </p> | ||||||
|         </div> |         </div> | ||||||
|       )} |       )} | ||||||
| 
 |  | ||||||
|       {/* Create Vacancy Button */} |  | ||||||
|       {!showCreateForm && ( |  | ||||||
|         <div className="flex justify-center pt-8"> |  | ||||||
|           <button |  | ||||||
|             onClick={() => setShowCreateForm(true)} |  | ||||||
|             className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 shadow-lg hover:shadow-xl transition-all" |  | ||||||
|           > |  | ||||||
|             <Plus className="h-5 w-5 mr-2" /> |  | ||||||
|             Создать вакансию |  | ||||||
|           </button> |  | ||||||
|         </div> |  | ||||||
|       )} |  | ||||||
| 
 |  | ||||||
|       {/* Create Vacancy Form */} |  | ||||||
|       {showCreateForm && ( |  | ||||||
|         <div className="space-y-4"> |  | ||||||
|           <div className="flex justify-center"> |  | ||||||
|             <button |  | ||||||
|               onClick={() => setShowCreateForm(false)} |  | ||||||
|               className="text-gray-500 hover:text-gray-700 text-sm" |  | ||||||
|             > |  | ||||||
|               ← Свернуть |  | ||||||
|             </button> |  | ||||||
|           </div> |  | ||||||
|           <VacancyUploadForm /> |  | ||||||
|         </div> |  | ||||||
|       )} |  | ||||||
|     </div> |     </div> | ||||||
|   ) |   ) | ||||||
| } | } | ||||||
| @ -13,8 +13,7 @@ import { | |||||||
|   Calendar, |   Calendar, | ||||||
|   Phone, |   Phone, | ||||||
|   Mail, |   Mail, | ||||||
|   Globe, |   Globe | ||||||
|   FileText |  | ||||||
| } from 'lucide-react' | } from 'lucide-react' | ||||||
| import ResumeUploadForm from '@/components/ResumeUploadForm' | import ResumeUploadForm from '@/components/ResumeUploadForm' | ||||||
| 
 | 
 | ||||||
| @ -75,11 +74,6 @@ export default function VacancyPage() { | |||||||
|     return mapping[schedule as keyof typeof mapping] || schedule |     return mapping[schedule as keyof typeof mapping] || schedule | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   const formatNullableField = (value: string | null | undefined) => { |  | ||||||
|     if (!value || value === 'null') return 'Не указано' |  | ||||||
|     return value |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   if (isLoading) { |   if (isLoading) { | ||||||
|     return ( |     return ( | ||||||
|       <div className="flex justify-center items-center min-h-[400px]"> |       <div className="flex justify-center items-center min-h-[400px]"> | ||||||
| @ -137,7 +131,7 @@ export default function VacancyPage() { | |||||||
|             <div className="flex items-center mb-6"> |             <div className="flex items-center mb-6"> | ||||||
|               <Building className="h-5 w-5 text-gray-400 mr-2" /> |               <Building className="h-5 w-5 text-gray-400 mr-2" /> | ||||||
|               <span className="text-lg font-medium text-gray-900"> |               <span className="text-lg font-medium text-gray-900"> | ||||||
|                 {formatNullableField(vacancy.company_name)} |                 {vacancy.company_name} | ||||||
|               </span> |               </span> | ||||||
|             </div> |             </div> | ||||||
| 
 | 
 | ||||||
| @ -149,7 +143,7 @@ export default function VacancyPage() { | |||||||
|                |                | ||||||
|               <div className="flex items-center text-gray-600"> |               <div className="flex items-center text-gray-600"> | ||||||
|                 <MapPin className="h-4 w-4 mr-2" /> |                 <MapPin className="h-4 w-4 mr-2" /> | ||||||
|                 <span>{formatNullableField(vacancy.area_name)}</span> |                 <span>{vacancy.area_name}</span> | ||||||
|               </div> |               </div> | ||||||
|                |                | ||||||
|               <div className="flex items-center text-gray-600"> |               <div className="flex items-center text-gray-600"> | ||||||
| @ -164,7 +158,7 @@ export default function VacancyPage() { | |||||||
|                |                | ||||||
|               <div className="flex items-center text-gray-600"> |               <div className="flex items-center text-gray-600"> | ||||||
|                 <Calendar className="h-4 w-4 mr-2" /> |                 <Calendar className="h-4 w-4 mr-2" /> | ||||||
|                 <span>{ formatNullableField(getScheduleText(vacancy.schedule)) }</span> |                 <span>{getScheduleText(vacancy.schedule)}</span> | ||||||
|               </div> |               </div> | ||||||
|                |                | ||||||
|               {vacancy.published_at && ( |               {vacancy.published_at && ( | ||||||
| @ -183,7 +177,7 @@ export default function VacancyPage() { | |||||||
|             </h2> |             </h2> | ||||||
|             <div className="prose prose-gray max-w-none"> |             <div className="prose prose-gray max-w-none"> | ||||||
|               <p className="whitespace-pre-line text-gray-700 leading-relaxed"> |               <p className="whitespace-pre-line text-gray-700 leading-relaxed"> | ||||||
|                 { formatNullableField(vacancy.description) } |                 {vacancy.description} | ||||||
|               </p> |               </p> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
| @ -195,7 +189,7 @@ export default function VacancyPage() { | |||||||
|                 Ключевые навыки |                 Ключевые навыки | ||||||
|               </h2> |               </h2> | ||||||
|               <div className="prose prose-gray max-w-none"> |               <div className="prose prose-gray max-w-none"> | ||||||
|                 <p className="text-gray-700">{ formatNullableField(vacancy.key_skills) }</p> |                 <p className="text-gray-700">{vacancy.key_skills}</p> | ||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|           )} |           )} | ||||||
| @ -208,7 +202,7 @@ export default function VacancyPage() { | |||||||
|               </h2> |               </h2> | ||||||
|               <div className="prose prose-gray max-w-none"> |               <div className="prose prose-gray max-w-none"> | ||||||
|                 <p className="whitespace-pre-line text-gray-700 leading-relaxed"> |                 <p className="whitespace-pre-line text-gray-700 leading-relaxed"> | ||||||
|                   { formatNullableField(vacancy.company_description) } |                   {vacancy.company_description} | ||||||
|                 </p> |                 </p> | ||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
| @ -224,13 +218,13 @@ export default function VacancyPage() { | |||||||
|                 {vacancy.address && ( |                 {vacancy.address && ( | ||||||
|                   <div className="flex items-start"> |                   <div className="flex items-start"> | ||||||
|                     <MapPin className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" /> |                     <MapPin className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" /> | ||||||
|                     <span>{ formatNullableField(vacancy.address) }</span> |                     <span>{vacancy.address}</span> | ||||||
|                   </div> |                   </div> | ||||||
|                 )} |                 )} | ||||||
|                 {vacancy.metro_stations && ( |                 {vacancy.metro_stations && ( | ||||||
|                   <div className="flex items-start"> |                   <div className="flex items-start"> | ||||||
|                     <span className="text-sm font-medium mr-2">Метро:</span> |                     <span className="text-sm font-medium mr-2">Метро:</span> | ||||||
|                     <span className="text-sm">{ formatNullableField(vacancy.metro_stations) }</span> |                     <span className="text-sm">{vacancy.metro_stations}</span> | ||||||
|                   </div> |                   </div> | ||||||
|                 )} |                 )} | ||||||
|               </div> |               </div> | ||||||
| @ -250,7 +244,7 @@ export default function VacancyPage() { | |||||||
|                 {vacancy.contacts_name && ( |                 {vacancy.contacts_name && ( | ||||||
|                   <div className="flex items-center text-gray-700"> |                   <div className="flex items-center text-gray-700"> | ||||||
|                     <Users className="h-4 w-4 mr-2" /> |                     <Users className="h-4 w-4 mr-2" /> | ||||||
|                     <span>{ formatNullableField(vacancy.contacts_name) }</span> |                     <span>{vacancy.contacts_name}</span> | ||||||
|                   </div> |                   </div> | ||||||
|                 )} |                 )} | ||||||
|                 {vacancy.contacts_email && ( |                 {vacancy.contacts_email && ( | ||||||
| @ -260,7 +254,7 @@ export default function VacancyPage() { | |||||||
|                       href={`mailto:${vacancy.contacts_email}`} |                       href={`mailto:${vacancy.contacts_email}`} | ||||||
|                       className="hover:text-primary-600" |                       className="hover:text-primary-600" | ||||||
|                     > |                     > | ||||||
|                       { formatNullableField(vacancy.contacts_email) } |                       {vacancy.contacts_email} | ||||||
|                     </a> |                     </a> | ||||||
|                   </div> |                   </div> | ||||||
|                 )} |                 )} | ||||||
| @ -271,7 +265,7 @@ export default function VacancyPage() { | |||||||
|                       href={`tel:${vacancy.contacts_phone}`} |                       href={`tel:${vacancy.contacts_phone}`} | ||||||
|                       className="hover:text-primary-600" |                       className="hover:text-primary-600" | ||||||
|                     > |                     > | ||||||
|                       { formatNullableField(vacancy.contacts_phone) } |                       {vacancy.contacts_phone} | ||||||
|                     </a> |                     </a> | ||||||
|                   </div> |                   </div> | ||||||
|                 )} |                 )} | ||||||
| @ -291,15 +285,6 @@ export default function VacancyPage() { | |||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|           )} |           )} | ||||||
|           <div> |  | ||||||
|             <a |  | ||||||
|               href={`/vacancy/report/${vacancyId}`} |  | ||||||
|               rel="noopener noreferrer" |  | ||||||
|               className="w-full flex items-center justify-center px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg shadow hover:bg-indigo-700 transition" |  | ||||||
|             > |  | ||||||
|               Открыть отчеты |  | ||||||
|             </a> |  | ||||||
|           </div> |  | ||||||
| 
 | 
 | ||||||
|           {/* Application Form */} |           {/* Application Form */} | ||||||
|           <ResumeUploadForm  |           <ResumeUploadForm  | ||||||
|  | |||||||
| @ -1,58 +0,0 @@ | |||||||
| 'use client' |  | ||||||
| 
 |  | ||||||
| import { useParams, useRouter } from 'next/navigation' |  | ||||||
| import { |  | ||||||
|   ArrowLeft, |  | ||||||
| } from 'lucide-react' |  | ||||||
| import VacancyReports from "@/components/VacancyReports"; |  | ||||||
| import { useInterviewReports } from "@/hooks/useReports"; |  | ||||||
| import React from "react"; |  | ||||||
| 
 |  | ||||||
| export default function VacancyPage() { |  | ||||||
|   const params = useParams() |  | ||||||
|   const router = useRouter() |  | ||||||
|   const vacancyId = parseInt(params.id as string) |  | ||||||
| 
 |  | ||||||
|   const { data: reports, isLoading, error } = useInterviewReports(vacancyId) |  | ||||||
| 
 |  | ||||||
|   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 || !reports) { |  | ||||||
|     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"> |  | ||||||
|       <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> |  | ||||||
|       </div> |  | ||||||
| 
 |  | ||||||
|       <VacancyReports reports={ reports ? reports : [] }/> |  | ||||||
|     </div> |  | ||||||
|   ) |  | ||||||
| } |  | ||||||
| @ -1,431 +0,0 @@ | |||||||
| 'use client' |  | ||||||
| 
 |  | ||||||
| import { useState, useEffect, useRef } from 'react' |  | ||||||
| import { Room, RoomEvent, Track, RemoteTrack, LocalTrack } from 'livekit-client' |  | ||||||
| import { useTracks, RoomAudioRenderer, LiveKitRoom, useRoomContext } from '@livekit/components-react' |  | ||||||
| import { useInterviewToken } from '@/hooks/useResume' |  | ||||||
| import { useForceEndInterview } from '@/hooks/useSession' |  | ||||||
| import { |  | ||||||
|   Mic, |  | ||||||
|   MicOff, |  | ||||||
|   Phone, |  | ||||||
|   PhoneOff, |  | ||||||
|   Volume2, |  | ||||||
|   VolumeX, |  | ||||||
|   Loader, |  | ||||||
|   CheckCircle, |  | ||||||
|   AlertCircle |  | ||||||
| } from 'lucide-react' |  | ||||||
| 
 |  | ||||||
| interface InterviewSessionProps { |  | ||||||
|   resumeId: number |  | ||||||
|   sessionId?: number |  | ||||||
|   onEnd?: () => void |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| interface InterviewState { |  | ||||||
|   isConnected: boolean |  | ||||||
|   isRecording: boolean |  | ||||||
|   isMuted: boolean |  | ||||||
|   isSpeaking: boolean |  | ||||||
|   connectionState: 'connecting' | 'connected' | 'disconnected' | 'failed' |  | ||||||
|   error?: string |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export default function InterviewSession({ resumeId, onEnd }: InterviewSessionProps) { |  | ||||||
|   const { data: tokenData, isLoading, error } = useInterviewToken(resumeId, true) |  | ||||||
|   const [connectionError, setConnectionError] = useState<string | null>(null) |  | ||||||
|   const [isRetrying, setIsRetrying] = useState(false) |  | ||||||
| 
 |  | ||||||
|   const getServerUrl = () => { |  | ||||||
|     // Приоритет: данные от API -> fallback URLs
 |  | ||||||
|     if (tokenData?.serverUrl) { |  | ||||||
|       return tokenData.serverUrl |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     // Fallback URLs для разных окружений
 |  | ||||||
|     const fallbackUrls = [ |  | ||||||
|       'wss://hackaton-eizc9zqk.livekit.cloud', |  | ||||||
|     ] |  | ||||||
|      |  | ||||||
|     return fallbackUrls[0] |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   const handleConnectionError = (error: Error) => { |  | ||||||
|     console.error('LiveKit connection error:', error) |  | ||||||
|     setConnectionError(`Ошибка подключения: ${error.message}`) |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   const retryConnection = () => { |  | ||||||
|     setIsRetrying(true) |  | ||||||
|     setConnectionError(null) |  | ||||||
|     // Перезагрузка компонента через 1 секунду
 |  | ||||||
|     setTimeout(() => { |  | ||||||
|       setIsRetrying(false) |  | ||||||
|       window.location.reload() |  | ||||||
|     }, 1000) |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   if (isLoading) { |  | ||||||
|     return ( |  | ||||||
|       <div className="flex flex-col items-center justify-center min-h-screen bg-gray-50"> |  | ||||||
|         <Loader className="h-12 w-12 text-blue-600 animate-spin mb-4" /> |  | ||||||
|         <h2 className="text-xl font-semibold text-gray-900 mb-2"> |  | ||||||
|           Подготавливаем собеседование |  | ||||||
|         </h2> |  | ||||||
|         <p className="text-gray-600 text-center max-w-md"> |  | ||||||
|           Пожалуйста, подождите, мы подготавливаем для вас сессию |  | ||||||
|         </p> |  | ||||||
|       </div> |  | ||||||
|     ) |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   if (error || !tokenData?.token) { |  | ||||||
|     return ( |  | ||||||
|       <div className="flex flex-col items-center justify-center min-h-screen bg-gray-50"> |  | ||||||
|         <AlertCircle className="h-12 w-12 text-red-600 mb-4" /> |  | ||||||
|         <h2 className="text-xl font-semibold text-gray-900 mb-2"> |  | ||||||
|           Ошибка получения токена |  | ||||||
|         </h2> |  | ||||||
|         <p className="text-gray-600 text-center max-w-md mb-6"> |  | ||||||
|           Не удалось получить токен доступа к сессии собеседования |  | ||||||
|         </p> |  | ||||||
|         <div className="flex gap-4"> |  | ||||||
|           <button |  | ||||||
|             onClick={() => window.location.reload()} |  | ||||||
|             className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700" |  | ||||||
|           > |  | ||||||
|             Попробовать снова |  | ||||||
|           </button> |  | ||||||
|           <button |  | ||||||
|             onClick={() => window.history.back()} |  | ||||||
|             className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50" |  | ||||||
|           > |  | ||||||
|             Вернуться назад |  | ||||||
|           </button> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     ) |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   if (connectionError) { |  | ||||||
|     return ( |  | ||||||
|       <div className="flex flex-col items-center justify-center min-h-screen bg-gray-50"> |  | ||||||
|         <AlertCircle className="h-12 w-12 text-red-600 mb-4" /> |  | ||||||
|         <h2 className="text-xl font-semibold text-gray-900 mb-2"> |  | ||||||
|           Ошибка подключения к LiveKit |  | ||||||
|         </h2> |  | ||||||
|         <p className="text-gray-600 text-center max-w-md mb-4"> |  | ||||||
|           {connectionError} |  | ||||||
|         </p> |  | ||||||
|         <p className="text-sm text-gray-500 text-center max-w-md mb-6"> |  | ||||||
|           Сервер: {getServerUrl()} |  | ||||||
|         </p> |  | ||||||
|         {isRetrying ? ( |  | ||||||
|           <div className="flex items-center"> |  | ||||||
|             <Loader className="h-5 w-5 text-blue-600 animate-spin mr-2" /> |  | ||||||
|             <span className="text-blue-600">Переподключение...</span> |  | ||||||
|           </div> |  | ||||||
|         ) : ( |  | ||||||
|           <div className="flex gap-4"> |  | ||||||
|             <button |  | ||||||
|               onClick={retryConnection} |  | ||||||
|               className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700" |  | ||||||
|             > |  | ||||||
|               Попробовать снова |  | ||||||
|             </button> |  | ||||||
|             <button |  | ||||||
|               onClick={() => window.history.back()} |  | ||||||
|               className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50" |  | ||||||
|             > |  | ||||||
|               Вернуться назад |  | ||||||
|             </button> |  | ||||||
|           </div> |  | ||||||
|         )} |  | ||||||
|       </div> |  | ||||||
|     ) |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <div className="min-h-screen bg-gray-50"> |  | ||||||
|       <LiveKitRoom |  | ||||||
|         token={tokenData.token} |  | ||||||
|         serverUrl={getServerUrl()} |  | ||||||
|         audio={true} |  | ||||||
|         video={false} |  | ||||||
|         connectOptions={{ |  | ||||||
|           // Дополнительные опции подключения
 |  | ||||||
|           autoSubscribe: true, |  | ||||||
|           maxRetries: 3, |  | ||||||
|         }} |  | ||||||
|         onConnected={() => { |  | ||||||
|           console.log('Connected to LiveKit successfully') |  | ||||||
|           setConnectionError(null) |  | ||||||
|         }} |  | ||||||
|         onDisconnected={(reason) => { |  | ||||||
|           console.log('Disconnected from LiveKit:', reason) |  | ||||||
|         }} |  | ||||||
|         onError={(error) => { |  | ||||||
|           handleConnectionError(error) |  | ||||||
|         }} |  | ||||||
|       > |  | ||||||
|         <InterviewRoom resumeId={resumeId} onEnd={onEnd} sessionId={tokenData.session_id} /> |  | ||||||
|       </LiveKitRoom> |  | ||||||
|     </div> |  | ||||||
|   ) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| function InterviewRoom({ resumeId, onEnd, sessionId }: InterviewSessionProps) { |  | ||||||
|   const room = useRoomContext() |  | ||||||
|   const tracks = useTracks([Track.Source.Microphone, Track.Source.ScreenShare], { |  | ||||||
|     onlySubscribed: false, |  | ||||||
|   }) |  | ||||||
| 
 |  | ||||||
|   const forceEndMutation = useForceEndInterview() |  | ||||||
| 
 |  | ||||||
|   const [state, setState] = useState<InterviewState>({ |  | ||||||
|     isConnected: false, |  | ||||||
|     isRecording: false, |  | ||||||
|     isMuted: false, |  | ||||||
|     isSpeaking: false, |  | ||||||
|     connectionState: 'connecting' |  | ||||||
|   }) |  | ||||||
| 
 |  | ||||||
|   const [interviewStarted, setInterviewStarted] = useState(false) |  | ||||||
|   const [currentQuestion, setCurrentQuestion] = useState('') |  | ||||||
|   const [aiSpeaking, setAiSpeaking] = useState(false) |  | ||||||
| 
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     if (!room) return |  | ||||||
| 
 |  | ||||||
|     const handleConnected = () => { |  | ||||||
|       setState(prev => ({ |  | ||||||
|         ...prev, |  | ||||||
|         isConnected: true, |  | ||||||
|         connectionState: 'connected' |  | ||||||
|       })) |  | ||||||
|       // Начинаем собеседование
 |  | ||||||
|       startInterview() |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     const handleDisconnected = () => { |  | ||||||
|       setState(prev => ({ |  | ||||||
|         ...prev, |  | ||||||
|         isConnected: false, |  | ||||||
|         connectionState: 'disconnected' |  | ||||||
|       })) |  | ||||||
|       if (onEnd) { |  | ||||||
|         onEnd() |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     const handleDataReceived = (payload: Uint8Array, participant: any) => { |  | ||||||
|       try { |  | ||||||
|         const message = JSON.parse(new TextDecoder().decode(payload)) |  | ||||||
|         handleServerMessage(message) |  | ||||||
|       } catch (error) { |  | ||||||
|         console.error('Error parsing server message:', error) |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     room.on(RoomEvent.Connected, handleConnected) |  | ||||||
|     room.on(RoomEvent.DataReceived, handleDataReceived) |  | ||||||
|     room.on(RoomEvent.Disconnected, handleDisconnected) |  | ||||||
| 
 |  | ||||||
|     return () => { |  | ||||||
|       room.off(RoomEvent.Connected, handleConnected) |  | ||||||
|       room.off(RoomEvent.DataReceived, handleDataReceived) |  | ||||||
|       room.off(RoomEvent.Disconnected, handleDisconnected) |  | ||||||
|     } |  | ||||||
|   }, [room]) |  | ||||||
| 
 |  | ||||||
|   const startInterview = async () => { |  | ||||||
|     if (!room) return |  | ||||||
| 
 |  | ||||||
|     try { |  | ||||||
|       // Отправляем сигнал серверу о начале собеседования
 |  | ||||||
|       await room.localParticipant.publishData( |  | ||||||
|         new TextEncoder().encode(JSON.stringify({ |  | ||||||
|           type: 'start_interview', |  | ||||||
|           resumeId |  | ||||||
|         })), |  | ||||||
|         { reliable: true } |  | ||||||
|       ) |  | ||||||
|       setInterviewStarted(true) |  | ||||||
|     } catch (error) { |  | ||||||
|       console.error('Error starting interview:', error) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   const handleServerMessage = (message: any) => { |  | ||||||
|     switch (message.type) { |  | ||||||
|       case 'question': |  | ||||||
|         setCurrentQuestion(message.text) |  | ||||||
|         setAiSpeaking(true) |  | ||||||
|         break |  | ||||||
|       case 'ai_speaking_start': |  | ||||||
|         setAiSpeaking(true) |  | ||||||
|         break |  | ||||||
|       case 'ai_speaking_end': |  | ||||||
|         setAiSpeaking(false) |  | ||||||
|         break |  | ||||||
|       case 'interview_started': |  | ||||||
|         break |  | ||||||
|       case 'interview_complete': |  | ||||||
|         // Собеседование завершено
 |  | ||||||
|         if (onEnd) onEnd() |  | ||||||
|         break |  | ||||||
|       default: |  | ||||||
|         console.log('Unknown message type:', message.type) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   const toggleMute = async () => { |  | ||||||
|     if (!room) return |  | ||||||
| 
 |  | ||||||
|     try { |  | ||||||
|       const audioTrack = room.localParticipant.getTrackPublication(Track.Source.Microphone) |  | ||||||
|       if (audioTrack) { |  | ||||||
|         if (state.isMuted) { |  | ||||||
|           await audioTrack.unmute() |  | ||||||
|         } else { |  | ||||||
|           await audioTrack.mute() |  | ||||||
|         } |  | ||||||
|         setState(prev => ({ ...prev, isMuted: !prev.isMuted })) |  | ||||||
|       } |  | ||||||
|     } catch (error) { |  | ||||||
|       console.error('Error toggling mute:', error) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   const endInterview = async () => { |  | ||||||
|     if (!room) return |  | ||||||
| 
 |  | ||||||
|     try { |  | ||||||
|       // Если есть sessionId, используем force-end API
 |  | ||||||
|       if (sessionId) { |  | ||||||
|         console.log('Starting force-end mutation for sessionId:', sessionId) |  | ||||||
|         await forceEndMutation.mutateAsync(sessionId) |  | ||||||
|         console.log('Force-end mutation completed successfully') |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       // Отправляем сигнал серверу о завершении собеседования
 |  | ||||||
|       await room.localParticipant.publishData( |  | ||||||
|         new TextEncoder().encode(JSON.stringify({ |  | ||||||
|           type: 'end_interview', |  | ||||||
|           resumeId |  | ||||||
|         })), |  | ||||||
|         { reliable: true } |  | ||||||
|       ) |  | ||||||
| 
 |  | ||||||
|       setState(prev => ({ |  | ||||||
|         ...prev, |  | ||||||
|         isConnected: false, |  | ||||||
|         connectionState: 'disconnected' |  | ||||||
|       })) |  | ||||||
| 
 |  | ||||||
|       // Отключение происходит только после успешного выполнения всех операций
 |  | ||||||
|       room.disconnect() |  | ||||||
|       console.log('About to call onEnd - this will cause redirect') |  | ||||||
|       // Временно отключаем редирект для проверки логов
 |  | ||||||
|       if (onEnd) onEnd() |  | ||||||
|     } catch (error) { |  | ||||||
|       console.error('Error ending interview:', error) |  | ||||||
|       // В случае ошибки всё равно отключаемся
 |  | ||||||
|       setState(prev => ({ |  | ||||||
|         ...prev, |  | ||||||
|         isConnected: false, |  | ||||||
|         connectionState: 'disconnected' |  | ||||||
|       })) |  | ||||||
|       room.disconnect() |  | ||||||
|       if (onEnd) onEnd() |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   const getConnectionStatusColor = () => { |  | ||||||
|     switch (state.connectionState) { |  | ||||||
|       case 'connected': |  | ||||||
|         return 'text-green-600' |  | ||||||
|       case 'connecting': |  | ||||||
|         return 'text-yellow-600' |  | ||||||
|       case 'failed': |  | ||||||
|         return 'text-red-600' |  | ||||||
|       default: |  | ||||||
|         return 'text-gray-600' |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <div className="flex flex-col items-center justify-center min-h-screen p-6"> |  | ||||||
|       <RoomAudioRenderer /> |  | ||||||
| 
 |  | ||||||
|       <div className="bg-white rounded-2xl shadow-xl p-8 max-w-2xl w-full"> |  | ||||||
|         {/* Header */} |  | ||||||
|         <div className="text-center mb-8"> |  | ||||||
|           <div className="flex items-center justify-center mb-4"> |  | ||||||
|             <div className="h-20 w-20 bg-blue-100 rounded-full flex items-center justify-center"> |  | ||||||
|               <Volume2 className="h-10 w-10 text-blue-600" /> |  | ||||||
|             </div> |  | ||||||
|           </div> |  | ||||||
|           <h1 className="text-2xl font-bold text-gray-900 mb-2"> |  | ||||||
|             Собеседование со Стефани |  | ||||||
|           </h1> |  | ||||||
|           <p className={`text-sm ${getConnectionStatusColor()}`}> |  | ||||||
|             {state.connectionState === 'connected' && 'Подключено'} |  | ||||||
|             {state.connectionState === 'connecting' && 'Подключение...'} |  | ||||||
|             {state.connectionState === 'disconnected' && 'Отключено'} |  | ||||||
|             {state.connectionState === 'failed' && 'Ошибка подключения'} |  | ||||||
|           </p> |  | ||||||
|         </div> |  | ||||||
| 
 |  | ||||||
|         {/* Current Question */} |  | ||||||
|         {currentQuestion && ( |  | ||||||
|           <div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6"> |  | ||||||
|             <div className="flex items-center mb-2"> |  | ||||||
|               {aiSpeaking && <Loader className="h-4 w-4 text-blue-600 animate-spin mr-2" />} |  | ||||||
|               <span className="text-sm font-medium text-blue-800"> |  | ||||||
|                 {aiSpeaking ? 'HR Агент говорит...' : 'Текущий вопрос:'} |  | ||||||
|               </span> |  | ||||||
|             </div> |  | ||||||
|             <p className="text-blue-900">{currentQuestion}</p> |  | ||||||
|           </div> |  | ||||||
|         )} |  | ||||||
| 
 |  | ||||||
|         {!interviewStarted && state.isConnected && ( |  | ||||||
|           <div className="text-center text-gray-600 mb-6"> |  | ||||||
|             Ожидаем начала собеседования... |  | ||||||
|           </div> |  | ||||||
|         )} |  | ||||||
| 
 |  | ||||||
|         {/* Controls */} |  | ||||||
|         <div className="flex items-center justify-center space-x-6 mb-6"> |  | ||||||
|           <button |  | ||||||
|             onClick={toggleMute} |  | ||||||
|             className={`p-4 rounded-full transition-colors ${ |  | ||||||
|               state.isMuted |  | ||||||
|                 ? 'bg-red-100 text-red-600 hover:bg-red-200' |  | ||||||
|                 : 'bg-green-100 text-green-600 hover:bg-green-200' |  | ||||||
|             }`}
 |  | ||||||
|           > |  | ||||||
|             {state.isMuted ? <MicOff className="h-6 w-6" /> : <Mic className="h-6 w-6" />} |  | ||||||
|           </button> |  | ||||||
| 
 |  | ||||||
|           <button |  | ||||||
|             onClick={endInterview} |  | ||||||
|             className="p-4 rounded-full bg-red-100 text-red-600 hover:bg-red-200 transition-colors" |  | ||||||
|           > |  | ||||||
|             <PhoneOff className="h-6 w-6" /> |  | ||||||
|           </button> |  | ||||||
|         </div> |  | ||||||
| 
 |  | ||||||
|         {/* Instructions */} |  | ||||||
|         <div className="text-center text-sm text-gray-500 space-y-1"> |  | ||||||
|           <p>Для начала диалога поприветствуйте интервьюера</p> |  | ||||||
|           <p>В процессе говорите четко и ждите, пока агент закончит свой вопрос</p> |  | ||||||
|           <p>Собеседование завершится автоматически</p> |  | ||||||
|           <p>Экстренно завершить собеседование можно, нажав красную кнопку</p> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|   ) |  | ||||||
| } |  | ||||||
| @ -1,11 +1,10 @@ | |||||||
| 'use client' | 'use client' | ||||||
| 
 | 
 | ||||||
| import { useState, useEffect } from 'react' | import { useState } from 'react' | ||||||
| // @ts-ignore
 |  | ||||||
| import InputMask from 'react-input-mask' | import InputMask from 'react-input-mask' | ||||||
| import { ResumeCreate } from '@/types/api' | import { ResumeCreate } from '@/types/api' | ||||||
| import { useCreateResume, useResumesByVacancy } from '@/hooks/useResume' | import { useCreateResume, useResumesByVacancy } from '@/hooks/useResume' | ||||||
| import { Upload, FileText, X, CheckCircle, Clock, Loader, AlertCircle, Mic } from 'lucide-react' | import { Upload, FileText, X, CheckCircle, Clock } from 'lucide-react' | ||||||
| 
 | 
 | ||||||
| interface ResumeUploadFormProps { | interface ResumeUploadFormProps { | ||||||
|   vacancyId: number |   vacancyId: number | ||||||
| @ -23,87 +22,42 @@ export default function ResumeUploadForm({ vacancyId, vacancyTitle, onSuccess }: | |||||||
|    |    | ||||||
|   const [file, setFile] = useState<File | null>(null) |   const [file, setFile] = useState<File | null>(null) | ||||||
|   const [success, setSuccess] = useState(false) |   const [success, setSuccess] = useState(false) | ||||||
|   const [micError, setMicError] = useState<string | null>(null) |  | ||||||
|   const [isCheckingMic, setIsCheckingMic] = useState(false) |  | ||||||
|   const [dragActive, setDragActive] = useState(false) |  | ||||||
| 
 | 
 | ||||||
|   const createResumeMutation = useCreateResume() |   const createResumeMutation = useCreateResume() | ||||||
|   const { data: existingResumes, isLoading: isLoadingResumes, refetch } = useResumesByVacancy(vacancyId) |   const { data: existingResumes, isLoading: isLoadingResumes } = useResumesByVacancy(vacancyId) | ||||||
| 
 | 
 | ||||||
|   // Проверяем есть ли уже резюме для этой вакансии в текущей сессии
 |   // Проверяем есть ли уже резюме для этой вакансии в текущей сессии
 | ||||||
|   const hasExistingResume = existingResumes && existingResumes.length > 0 |   const hasExistingResume = existingResumes && existingResumes.length > 0 | ||||||
| 
 | 
 | ||||||
|   // Находим непарсенные резюме
 |  | ||||||
|   const pendingResumes = existingResumes?.filter(resume =>  |  | ||||||
|     resume.status === 'pending' || resume.status === 'parsing' |  | ||||||
|   ) || [] |  | ||||||
|    |  | ||||||
|   const hasPendingResumes = pendingResumes.length > 0 |  | ||||||
| 
 |  | ||||||
|   // Автообновление для непарсенных резюме
 |  | ||||||
|   useEffect(() => { |  | ||||||
|     if (hasPendingResumes) { |  | ||||||
|       const interval = setInterval(() => { |  | ||||||
|         refetch() |  | ||||||
|       }, 3000) // 3 секунды
 |  | ||||||
| 
 |  | ||||||
|       return () => clearInterval(interval) |  | ||||||
|     } |  | ||||||
|   }, [hasPendingResumes, refetch]) |  | ||||||
| 
 |  | ||||||
|   const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { |   const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { | ||||||
|     const { name, value } = e.target |     const { name, value } = e.target | ||||||
|     setFormData(prev => ({ ...prev, [name]: value })) |     setFormData(prev => ({ ...prev, [name]: value })) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   const validateFile = (file: File) => { |   const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||||||
|  |     const selectedFile = e.target.files?.[0] | ||||||
|  |     if (selectedFile) { | ||||||
|       // Check file size (max 10MB)
 |       // Check file size (max 10MB)
 | ||||||
|     if (file.size > 10 * 1024 * 1024) { |       if (selectedFile.size > 10 * 1024 * 1024) { | ||||||
|       return false |         return | ||||||
|       } |       } | ||||||
|        |        | ||||||
|       // Check file type
 |       // Check file type
 | ||||||
|       const allowedTypes = [ |       const allowedTypes = [ | ||||||
|         'application/pdf', |         'application/pdf', | ||||||
|       'application/vnd.openxmlformats-officedocument.wordprocessingml.document' |         'application/msword', | ||||||
|  |         'application/vnd.openxmlformats-officedocument.wordprocessingml.document', | ||||||
|  |         'text/plain' | ||||||
|       ] |       ] | ||||||
|        |        | ||||||
|     return allowedTypes.includes(file.type) |       if (!allowedTypes.includes(selectedFile.type)) { | ||||||
|  |         return | ||||||
|       } |       } | ||||||
|        |        | ||||||
|   const handleFileSelect = (selectedFile: File) => { |  | ||||||
|     if (validateFile(selectedFile)) { |  | ||||||
|       setFile(selectedFile) |       setFile(selectedFile) | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { |  | ||||||
|     const selectedFile = e.target.files?.[0] |  | ||||||
|     if (selectedFile) { |  | ||||||
|       handleFileSelect(selectedFile) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   const handleDrag = (e: React.DragEvent) => { |  | ||||||
|     e.preventDefault() |  | ||||||
|     e.stopPropagation() |  | ||||||
|     if (e.type === 'dragenter' || e.type === 'dragover') { |  | ||||||
|       setDragActive(true) |  | ||||||
|     } else if (e.type === 'dragleave') { |  | ||||||
|       setDragActive(false) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   const handleDrop = (e: React.DragEvent) => { |  | ||||||
|     e.preventDefault() |  | ||||||
|     e.stopPropagation() |  | ||||||
|     setDragActive(false) |  | ||||||
|      |  | ||||||
|     if (e.dataTransfer.files && e.dataTransfer.files[0]) { |  | ||||||
|       handleFileSelect(e.dataTransfer.files[0]) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   const removeFile = () => { |   const removeFile = () => { | ||||||
|     setFile(null) |     setFile(null) | ||||||
|   } |   } | ||||||
| @ -171,263 +125,43 @@ export default function ResumeUploadForm({ vacancyId, vacancyTitle, onSuccess }: | |||||||
|     ) |     ) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   const getStatusDisplay = (status: string) => { |  | ||||||
|     switch (status) { |  | ||||||
|       case 'pending': |  | ||||||
|         return 'Обрабатывается' |  | ||||||
|       case 'parsing': |  | ||||||
|         return 'Обрабатывается' |  | ||||||
|       case 'parse_failed': |  | ||||||
|         return 'Ошибка обработки' |  | ||||||
|       case 'parsed': |  | ||||||
|         return 'Обработано' |  | ||||||
|       case 'under_review': |  | ||||||
|         return 'На проверке' |  | ||||||
|       case 'interview_scheduled': |  | ||||||
|         return 'Собеседование назначено' |  | ||||||
|       case 'interviewed': |  | ||||||
|         return 'Проведено собеседование' |  | ||||||
|       case 'accepted': |  | ||||||
|         return 'Принят' |  | ||||||
|       case 'rejected': |  | ||||||
|         return 'Отклонено' |  | ||||||
|       default: |  | ||||||
|         return status |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   // Обработка ошибок парсинга
 |  | ||||||
|   const hasParseFailedResumes = existingResumes?.some(resume => resume.status === 'parse_failed') || false |  | ||||||
| 
 |  | ||||||
|   if (hasParseFailedResumes) { |  | ||||||
|     return ( |  | ||||||
|       <div className="bg-red-50 border border-red-200 rounded-lg p-6"> |  | ||||||
|         <div className="flex items-center"> |  | ||||||
|           <AlertCircle className="h-6 w-6 text-red-600 mr-3" /> |  | ||||||
|           <div> |  | ||||||
|             <h3 className="text-lg font-medium text-red-800"> |  | ||||||
|               Ошибка обработки резюме |  | ||||||
|             </h3> |  | ||||||
|             <p className="mt-2 text-red-700"> |  | ||||||
|               Не удалось обработать ваше резюме. Попробуйте загрузить файл в другом формате (PDF, DOCX)  |  | ||||||
|               или обратитесь к нам за помощью. |  | ||||||
|             </p> |  | ||||||
|             <div className="mt-4 space-y-2"> |  | ||||||
|               {existingResumes?.map((resume) => ( |  | ||||||
|                 <div key={resume.id} className="flex items-center text-sm"> |  | ||||||
|                   <span className="text-red-600"> |  | ||||||
|                     Отправлено: {new Date(resume.created_at).toLocaleDateString('ru-RU', { |  | ||||||
|                       day: 'numeric', |  | ||||||
|                       month: 'long', |  | ||||||
|                       hour: '2-digit', |  | ||||||
|                       minute: '2-digit' |  | ||||||
|                     })} • Статус: {getStatusDisplay(resume.status)} |  | ||||||
|                   </span> |  | ||||||
|                 </div> |  | ||||||
|               ))} |  | ||||||
|             </div> |  | ||||||
|             <button |  | ||||||
|               onClick={() => window.location.reload()} |  | ||||||
|               className="mt-4 inline-flex items-center px-3 py-2 border border-red-300 shadow-sm text-sm leading-4 font-medium rounded-md text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" |  | ||||||
|             > |  | ||||||
|               Попробовать снова |  | ||||||
|             </button> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     ) |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   // Показываем крутилку для статусов pending/parsing
 |  | ||||||
|   if (hasPendingResumes) { |  | ||||||
|     return ( |  | ||||||
|       <div className="bg-blue-50 border border-blue-200 rounded-lg p-8 text-center"> |  | ||||||
|         <Loader className="h-12 w-12 text-blue-600 mx-auto mb-4 animate-spin" /> |  | ||||||
|         <div className="flex items-center justify-center mb-4"> |  | ||||||
|           <h3 className="text-2xl font-bold text-blue-800 mr-3"> |  | ||||||
|             Обрабатываем ваше резюме... |  | ||||||
|           </h3> |  | ||||||
|         </div> |  | ||||||
|         <p className="text-blue-700 mb-6 max-w-md mx-auto"> |  | ||||||
|           Пожалуйста, подождите. Мы анализируем ваше резюме и готовим персональные вопросы для собеседования. |  | ||||||
|         </p> |  | ||||||
|         <div className="space-y-2"> |  | ||||||
|           {existingResumes?.map((resume) => ( |  | ||||||
|             <div key={resume.id} className="flex items-center justify-center text-sm"> |  | ||||||
|               <span className="text-blue-600"> |  | ||||||
|                 Отправлено: {new Date(resume.created_at).toLocaleDateString('ru-RU', { |  | ||||||
|                   day: 'numeric', |  | ||||||
|                   month: 'long', |  | ||||||
|                   hour: '2-digit', |  | ||||||
|                   minute: '2-digit' |  | ||||||
|                 })} |  | ||||||
|                 <br />Статус: {getStatusDisplay(resume.status)} |  | ||||||
|               </span> |  | ||||||
|             </div> |  | ||||||
|           ))} |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|     ) |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   // Обычное успешное состояние для parsed и других завершенных статусов
 |  | ||||||
|   if (success || hasExistingResume) { |   if (success || hasExistingResume) { | ||||||
|     return ( |     return ( | ||||||
|       <div className="bg-white border border-gray-200 rounded-lg shadow-sm"> |       <div className="bg-green-50 border border-green-200 rounded-lg p-6"> | ||||||
|         {hasExistingResume && existingResumes && existingResumes.map((resume) => ( |         <div className="flex items-center"> | ||||||
|           <div key={resume.id} className="p-6 border-b border-gray-100 last:border-b-0"> |           <CheckCircle className="h-6 w-6 text-green-600 mr-3" /> | ||||||
|             {/* Status and Date Row */} |  | ||||||
|             <div className="flex items-center justify-between mb-4"> |  | ||||||
|               <div className="flex items-center space-x-3"> |  | ||||||
|                 <div className="h-10 w-10 rounded-full bg-green-100 flex items-center justify-center"> |  | ||||||
|                   <CheckCircle className="h-5 w-5 text-green-600" /> |  | ||||||
|                 </div> |  | ||||||
|           <div> |           <div> | ||||||
|                   <p className="text-sm font-medium text-gray-900"> |             <h3 className="text-lg font-medium text-green-800"> | ||||||
|                     Резюме |               {success ? 'Резюме успешно отправлено!' : 'Ваше резюме уже отправлено!'} | ||||||
|  |             </h3> | ||||||
|  |             <p className="mt-2 text-green-700"> | ||||||
|  |               Готовим для вас сессию для собеседования. Мы свяжемся с вами в ближайшее время. | ||||||
|             </p> |             </p> | ||||||
|                   <p className="text-xs text-gray-500"> |             {hasExistingResume && existingResumes && ( | ||||||
|                     {new Date(resume.created_at).toLocaleDateString('ru-RU', { |               <div className="mt-4 space-y-2"> | ||||||
|                       day: '2-digit', |                 {existingResumes.map((resume) => ( | ||||||
|                       month: '2-digit',  |                   <div key={resume.id} className="flex items-center text-sm text-green-600"> | ||||||
|                       year: 'numeric', |                     <Clock className="h-4 w-4 mr-2" /> | ||||||
|  |                     <span> | ||||||
|  |                       Отправлено: {new Date(resume.created_at).toLocaleDateString('ru-RU', { | ||||||
|  |                         day: 'numeric', | ||||||
|  |                         month: 'long', | ||||||
|                         hour: '2-digit', |                         hour: '2-digit', | ||||||
|                         minute: '2-digit' |                         minute: '2-digit' | ||||||
|                     })} |                       })} • Статус: {resume.status === 'pending' ? 'На рассмотрении' :  | ||||||
|                   </p> |                         resume.status === 'under_review' ? 'На проверке' : | ||||||
|                 </div> |                         resume.status === 'interview_scheduled' ? 'Собеседование назначено' : | ||||||
|               </div> |                         resume.status === 'interviewed' ? 'Проведено собеседование' : | ||||||
|                |                         resume.status === 'accepted' ? 'Принят' : | ||||||
|               <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${ |                         resume.status === 'rejected' ? 'Отклонен' : resume.status} | ||||||
|                 resume.status === 'parsed'  |  | ||||||
|                   ? 'bg-green-100 text-green-800' |  | ||||||
|                   : resume.status === 'parsing' || resume.status === 'pending' |  | ||||||
|                   ? 'bg-yellow-100 text-yellow-800'  |  | ||||||
|                   : resume.status === 'parse_failed' || resume.status === 'rejected' |  | ||||||
|                   ? 'bg-red-100 text-red-800' |  | ||||||
|                   : 'bg-gray-100 text-gray-800' |  | ||||||
|               }`}>
 |  | ||||||
|                 {getStatusDisplay(resume.status)} |  | ||||||
|                     </span> |                     </span> | ||||||
|                   </div> |                   </div> | ||||||
| 
 |  | ||||||
|             {/* Content based on status */} |  | ||||||
|             {resume.status === 'parsed' && ( |  | ||||||
|               <div className="bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-lg p-4"> |  | ||||||
|                 <div className="text-center"> |  | ||||||
|                   <h4 className="text-lg font-semibold text-green-900 mb-2"> |  | ||||||
|                     Мы готовы! |  | ||||||
|                   </h4> |  | ||||||
|                   <p className="text-sm text-green-700 mb-4"> |  | ||||||
|                     Ваше резюме успешно обработано. Можете приступать к собеседованию с HR-агентом. |  | ||||||
|                     <br /> |  | ||||||
|                     <br /> |  | ||||||
|                     * Вы можете пройти собеседование сегодня до 20:00 МСК |  | ||||||
|                   </p> |  | ||||||
|                   <div className="space-y-3"> |  | ||||||
|                     {micError && ( |  | ||||||
|                       <div className="bg-red-50 border border-red-200 rounded-lg p-3"> |  | ||||||
|                         <div className="flex items-start"> |  | ||||||
|                           <AlertCircle className="h-5 w-5 text-red-600 mt-0.5 mr-2 flex-shrink-0" /> |  | ||||||
|                           <p className="text-sm text-red-700">{micError}</p> |  | ||||||
|                         </div> |  | ||||||
|                       </div> |  | ||||||
|                     )} |  | ||||||
|                     <button |  | ||||||
|                       onClick={async () => { |  | ||||||
|                         setIsCheckingMic(true) |  | ||||||
|                         setMicError(null) |  | ||||||
|                          |  | ||||||
|                         try { |  | ||||||
|                           await navigator.mediaDevices.getUserMedia({ audio: true }) |  | ||||||
|                           // Если разрешение получено, переходим к интервью
 |  | ||||||
|                           window.location.href = `/interview/${resume.id}` |  | ||||||
|                         } catch (err) { |  | ||||||
|                           console.error('Microphone permission denied', err) |  | ||||||
|                           setMicError('Нужно разрешить доступ к микрофону в настройках браузера для проведения интервью') |  | ||||||
|                         } finally { |  | ||||||
|                           setIsCheckingMic(false) |  | ||||||
|                         } |  | ||||||
|                       }} |  | ||||||
|                       disabled={isCheckingMic} |  | ||||||
|                       className="inline-flex items-center px-6 py-3 bg-green-600 border border-transparent rounded-lg font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200 transform hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none" |  | ||||||
|                     > |  | ||||||
|                       {isCheckingMic ? ( |  | ||||||
|                         <> |  | ||||||
|                           <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> |  | ||||||
|                           Проверяем микрофон... |  | ||||||
|                         </> |  | ||||||
|                       ) : ( |  | ||||||
|                         <> |  | ||||||
|                           Начать собеседование |  | ||||||
|                         </> |  | ||||||
|                       )} |  | ||||||
|                     </button> |  | ||||||
|                   </div> |  | ||||||
|                 </div> |  | ||||||
|               </div> |  | ||||||
|             )} |  | ||||||
| 
 |  | ||||||
|             {(resume.status === 'parsing' || resume.status === 'pending') && ( |  | ||||||
|               <div className="bg-gradient-to-r from-yellow-50 to-amber-50 border border-yellow-200 rounded-lg p-4"> |  | ||||||
|                 <div className="flex items-center"> |  | ||||||
|                   <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-yellow-600 mr-3"></div> |  | ||||||
|                   <div> |  | ||||||
|                     <h4 className="text-sm font-medium text-yellow-900"> |  | ||||||
|                       Обрабатываем ваше резюме |  | ||||||
|                     </h4> |  | ||||||
|                     <p className="text-xs text-yellow-700 mt-1"> |  | ||||||
|                       Анализируем опыт и готовим персональные вопросы |  | ||||||
|                     </p> |  | ||||||
|                   </div> |  | ||||||
|                 </div> |  | ||||||
|               </div> |  | ||||||
|             )} |  | ||||||
| 
 |  | ||||||
|             {resume.status === 'parse_failed' && ( |  | ||||||
|               <div className="bg-gradient-to-r from-red-50 to-pink-50 border border-red-200 rounded-lg p-4"> |  | ||||||
|                 <div className="flex items-center"> |  | ||||||
|                   <div className="h-5 w-5 text-red-600 mr-3">⚠️</div> |  | ||||||
|                   <div> |  | ||||||
|                     <h4 className="text-sm font-medium text-red-900"> |  | ||||||
|                       Ошибка обработки |  | ||||||
|                     </h4> |  | ||||||
|                     <p className="text-xs text-red-700 mt-1"> |  | ||||||
|                       Попробуйте загрузить файл в другом формате |  | ||||||
|                     </p> |  | ||||||
|                   </div> |  | ||||||
|                 </div> |  | ||||||
|               </div> |  | ||||||
|             )} |  | ||||||
| 
 |  | ||||||
|             {resume.status === 'rejected' && ( |  | ||||||
|               <div className="bg-gradient-to-r from-red-50 to-pink-50 border border-red-200 rounded-lg p-4"> |  | ||||||
|                 <div className="text-center"> |  | ||||||
|                   <h4 className="text-sm font-medium text-red-900 mb-1"> |  | ||||||
|                     Резюме не соответствует вакансии |  | ||||||
|                   </h4> |  | ||||||
|                   <p className="text-xs text-red-700"> |  | ||||||
|                     К сожалению, ваш опыт не подходит для данной позиции |  | ||||||
|                   </p> |  | ||||||
|                 </div> |  | ||||||
|               </div> |  | ||||||
|             )} |  | ||||||
| 
 |  | ||||||
|             {!['parsed', 'parsing', 'pending', 'parse_failed', 'rejected'].includes(resume.status) && ( |  | ||||||
|               <div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg p-4"> |  | ||||||
|                 <div className="text-center"> |  | ||||||
|                   <h4 className="text-sm font-medium text-blue-900 mb-1"> |  | ||||||
|                     {getStatusDisplay(resume.status)} |  | ||||||
|                   </h4> |  | ||||||
|                   <p className="text-xs text-blue-700"> |  | ||||||
|                     Мы свяжемся с вами для следующих шагов |  | ||||||
|                   </p> |  | ||||||
|                 </div> |  | ||||||
|               </div> |  | ||||||
|             )} |  | ||||||
|           </div> |  | ||||||
|                 ))} |                 ))} | ||||||
|               </div> |               </div> | ||||||
|  |             )} | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|     ) |     ) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -435,7 +169,7 @@ export default function ResumeUploadForm({ vacancyId, vacancyTitle, onSuccess }: | |||||||
|     <div className="bg-white border border-gray-200 rounded-lg p-6"> |     <div className="bg-white border border-gray-200 rounded-lg p-6"> | ||||||
|       <div className="mb-6"> |       <div className="mb-6"> | ||||||
|         <h3 className="text-lg font-medium text-gray-900"> |         <h3 className="text-lg font-medium text-gray-900"> | ||||||
|           Откликнуться |           Откликнуться на вакансию | ||||||
|         </h3> |         </h3> | ||||||
|         <p className="mt-1 text-sm text-gray-600"> |         <p className="mt-1 text-sm text-gray-600"> | ||||||
|           {vacancyTitle} |           {vacancyTitle} | ||||||
| @ -523,17 +257,7 @@ export default function ResumeUploadForm({ vacancyId, vacancyTitle, onSuccess }: | |||||||
|           </label> |           </label> | ||||||
|            |            | ||||||
|           {!file ? ( |           {!file ? ( | ||||||
|             <div  |             <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"> | ||||||
|               className={`mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-dashed rounded-md transition-colors ${ |  | ||||||
|                 dragActive |  | ||||||
|                   ? 'border-primary-500 bg-primary-50' |  | ||||||
|                   : 'border-gray-300 hover:border-primary-400' |  | ||||||
|               }`}
 |  | ||||||
|               onDragEnter={handleDrag} |  | ||||||
|               onDragLeave={handleDrag} |  | ||||||
|               onDragOver={handleDrag} |  | ||||||
|               onDrop={handleDrop} |  | ||||||
|             > |  | ||||||
|               <div className="space-y-1 text-center"> |               <div className="space-y-1 text-center"> | ||||||
|                 <Upload className="mx-auto h-12 w-12 text-gray-400" /> |                 <Upload className="mx-auto h-12 w-12 text-gray-400" /> | ||||||
|                 <div className="flex text-sm text-gray-600"> |                 <div className="flex text-sm text-gray-600"> | ||||||
| @ -547,14 +271,14 @@ export default function ResumeUploadForm({ vacancyId, vacancyTitle, onSuccess }: | |||||||
|                       name="resume_file" |                       name="resume_file" | ||||||
|                       type="file" |                       type="file" | ||||||
|                       className="sr-only" |                       className="sr-only" | ||||||
|                       accept=".pdf,.docx" |                       accept=".pdf,.doc,.docx,.txt" | ||||||
|                       onChange={handleFileChange} |                       onChange={handleFileChange} | ||||||
|                     /> |                     /> | ||||||
|                   </label> |                   </label> | ||||||
|                   <p className="pl-1">или перетащите сюда</p> |                   <p className="pl-1">или перетащите сюда</p> | ||||||
|                 </div> |                 </div> | ||||||
|                 <p className="text-xs text-gray-500"> |                 <p className="text-xs text-gray-500"> | ||||||
|                   PDF, DOCX до 10 МБ |                   PDF, DOC, DOCX, TXT до 10 МБ | ||||||
|                 </p> |                 </p> | ||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|  | |||||||
| @ -1,171 +0,0 @@ | |||||||
| import React from "react"; |  | ||||||
| import { FileText, Award, AlertTriangle, CheckCircle2 } from "lucide-react"; |  | ||||||
| 
 |  | ||||||
| export interface InterviewReport { |  | ||||||
|   id: number; |  | ||||||
|   interview_session_id: number; |  | ||||||
|   technical_skills_score: number; |  | ||||||
|   experience_relevance_score: number; |  | ||||||
|   communication_score: number; |  | ||||||
|   problem_solving_score: number; |  | ||||||
|   cultural_fit_score: number; |  | ||||||
|   overall_score: number; |  | ||||||
|   recommendation: "strongly_recommend" | "recommend" | "consider" | "reject"; |  | ||||||
|   strengths: Record<string, any>; |  | ||||||
|   weaknesses: Record<string, any>; |  | ||||||
|   red_flags: Record<string, any>; |  | ||||||
|   next_steps: string | null; |  | ||||||
|   interviewer_notes: string | null; |  | ||||||
|   pdf_report_url: string | null; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| interface VacancyReportsProps { |  | ||||||
|   reports: InterviewReport[]; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| const recommendationLabels: Record< |  | ||||||
|   InterviewReport["recommendation"], |  | ||||||
|   string |  | ||||||
| > = { |  | ||||||
|   strongly_recommend: "сильно рекомендую", |  | ||||||
|   recommend: "рекомендую", |  | ||||||
|   consider: "рассмотреть", |  | ||||||
|   reject: "не рекомендую", |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const recommendationColors: Record< |  | ||||||
|   InterviewReport["recommendation"], |  | ||||||
|   string |  | ||||||
| > = { |  | ||||||
|   strongly_recommend: "text-green-700 bg-green-100", |  | ||||||
|   recommend: "text-blue-700 bg-blue-100", |  | ||||||
|   consider: "text-yellow-700 bg-yellow-100", |  | ||||||
|   reject: "text-red-700 bg-red-100", |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const scoringLabels: Record<string, string> = { |  | ||||||
|   technical_skills_score: "Технические навыки", |  | ||||||
|   experience_relevance_score: "Релевантность опыта", |  | ||||||
|   communication_score: "Коммуникация", |  | ||||||
|   problem_solving_score: "Решение задач", |  | ||||||
|   cultural_fit_score: "Культурная совместимость", |  | ||||||
|   overall_score: "Общая оценка", |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const renderJsonAsList = (data: Record<string, any>) => { |  | ||||||
|   return ( |  | ||||||
|     <ul className="list-disc list-inside text-xs bg-gray-50 p-2 rounded"> |  | ||||||
|       {Object.entries(data).map(([key, value]) => ( |  | ||||||
|         <li key={key}> |  | ||||||
|           <span className="font-medium">{key}:</span> {String(value)} |  | ||||||
|         </li> |  | ||||||
|       ))} |  | ||||||
|     </ul> |  | ||||||
|   ); |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| export default function VacancyReports({ reports }: VacancyReportsProps) { |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <div className="space-y-6"> |  | ||||||
|       <h1 className="text-xl font-bold text-gray-900"> |  | ||||||
|         Отчёты по собеседованиям |  | ||||||
|       </h1> |  | ||||||
| 
 |  | ||||||
|       {reports.length === 0 ? ( |  | ||||||
|         <p className="text-gray-600">Пока нет отчётов по этой вакансии.</p> |  | ||||||
|       ) : ( |  | ||||||
|         <div className="flex flex-col gap-6"> |  | ||||||
|           {reports.map((report) => ( |  | ||||||
|             <div |  | ||||||
|               key={report.id} |  | ||||||
|               className="bg-white rounded-lg shadow-md p-6 flex flex-col justify-between" |  | ||||||
|             > |  | ||||||
|               <div> |  | ||||||
|                 <h2 className="text-lg font-semibold text-gray-900 mb-2 flex items-center"> |  | ||||||
|                   <Award className="h-5 w-5 mr-2 text-indigo-500" /> |  | ||||||
|                   Отчёт #{report.id} |  | ||||||
|                 </h2> |  | ||||||
| 
 |  | ||||||
|                 <div |  | ||||||
|                   className={`inline-block px-3 py-1 rounded-full text-sm font-medium mb-4 ${ |  | ||||||
|                     recommendationColors[report.recommendation] |  | ||||||
|                   }`}
 |  | ||||||
|                 > |  | ||||||
|                   {recommendationLabels[report.recommendation]} |  | ||||||
|                 </div> |  | ||||||
| 
 |  | ||||||
|                 <div className="space-y-2 text-gray-700 text-sm"> |  | ||||||
|                   {Object.entries(scoringLabels).map(([key, label]) => { |  | ||||||
|                     const score = report[key as keyof InterviewReport] as number; |  | ||||||
|                     return ( |  | ||||||
|                       <p key={key}> |  | ||||||
|                         <span className="font-medium">{label}:</span> {score}/100 |  | ||||||
|                       </p> |  | ||||||
|                     ); |  | ||||||
|                   })} |  | ||||||
|                 </div> |  | ||||||
| 
 |  | ||||||
|                 {report.strengths && |  | ||||||
|                   Object.keys(report.strengths).length > 0 && ( |  | ||||||
|                     <div className="mt-4"> |  | ||||||
|                       <h3 className="text-sm font-medium text-gray-900 mb-1 flex items-center"> |  | ||||||
|                         <CheckCircle2 className="h-4 w-4 mr-1 text-green-500" /> |  | ||||||
|                         Сильные стороны |  | ||||||
|                       </h3> |  | ||||||
|                       {renderJsonAsList(report.strengths)} |  | ||||||
|                     </div> |  | ||||||
|                   )} |  | ||||||
| 
 |  | ||||||
|                 {report.weaknesses && |  | ||||||
|                   Object.keys(report.weaknesses).length > 0 && ( |  | ||||||
|                     <div className="mt-4"> |  | ||||||
|                       <h3 className="text-sm font-medium text-gray-900 mb-1 flex items-center"> |  | ||||||
|                         <AlertTriangle className="h-4 w-4 mr-1 text-yellow-500" /> |  | ||||||
|                         Слабые стороны |  | ||||||
|                       </h3> |  | ||||||
|                       {renderJsonAsList(report.weaknesses)} |  | ||||||
|                     </div> |  | ||||||
|                   )} |  | ||||||
| 
 |  | ||||||
|                 {report.red_flags && Object.keys(report.red_flags).length > 0 && ( |  | ||||||
|                   <div className="mt-4"> |  | ||||||
|                     <h3 className="text-sm font-medium mb-1 flex items-center text-red-600"> |  | ||||||
|                       🚩 Red flags |  | ||||||
|                     </h3> |  | ||||||
|                     {renderJsonAsList(report.red_flags)} |  | ||||||
|                   </div> |  | ||||||
|                 )} |  | ||||||
| 
 |  | ||||||
|                 {report.interviewer_notes && ( |  | ||||||
|                   <div className="mt-4"> |  | ||||||
|                     <h3 className="text-sm font-medium text-gray-900 mb-1"> |  | ||||||
|                       Заметки интервьюера |  | ||||||
|                     </h3> |  | ||||||
|                     <p className="text-sm text-gray-700"> |  | ||||||
|                       {report.interviewer_notes} |  | ||||||
|                     </p> |  | ||||||
|                   </div> |  | ||||||
|                 )} |  | ||||||
|               </div> |  | ||||||
| 
 |  | ||||||
|               {report.pdf_report_url && ( |  | ||||||
|                 <div className="mt-6"> |  | ||||||
|                   <a |  | ||||||
|                     href={report.pdf_report_url} |  | ||||||
|                     target="_blank" |  | ||||||
|                     rel="noopener noreferrer" |  | ||||||
|                     className="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg shadow hover:bg-indigo-700 transition" |  | ||||||
|                   > |  | ||||||
|                     <FileText className="h-4 w-4 mr-2" /> |  | ||||||
|                     Открыть PDF |  | ||||||
|                   </a> |  | ||||||
|                 </div> |  | ||||||
|               )} |  | ||||||
|             </div> |  | ||||||
|           ))} |  | ||||||
|         </div> |  | ||||||
|       )} |  | ||||||
|     </div> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
| @ -1,173 +0,0 @@ | |||||||
| 'use client' |  | ||||||
| 
 |  | ||||||
| import { useState } from 'react' |  | ||||||
| import { useParseVacancyFile } from '@/hooks/useVacancy' |  | ||||||
| import { Upload, X, FileText } from 'lucide-react' |  | ||||||
| 
 |  | ||||||
| export default function VacancyUploadForm() { |  | ||||||
|   const [selectedFile, setSelectedFile] = useState<File | null>(null) |  | ||||||
|   const [dragActive, setDragActive] = useState(false) |  | ||||||
|   const parseVacancyFile = useParseVacancyFile() |  | ||||||
| 
 |  | ||||||
|   const handleFileSelect = (file: File) => { |  | ||||||
|     // Проверка типа файла
 |  | ||||||
|     const allowedTypes = [ |  | ||||||
|       'application/pdf', |  | ||||||
|       'application/msword', |  | ||||||
|       'application/vnd.openxmlformats-officedocument.wordprocessingml.document', |  | ||||||
|       'application/rtf', |  | ||||||
|       'text/plain' |  | ||||||
|     ] |  | ||||||
|      |  | ||||||
|     if (!allowedTypes.includes(file.type)) { |  | ||||||
|       alert('Поддерживаются только файлы: PDF, DOC, DOCX, RTF, TXT') |  | ||||||
|       return |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     // Проверка размера файла (10MB)
 |  | ||||||
|     if (file.size > 10 * 1024 * 1024) { |  | ||||||
|       alert('Размер файла не должен превышать 10 МБ') |  | ||||||
|       return |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     setSelectedFile(file) |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   const handleDrag = (e: React.DragEvent) => { |  | ||||||
|     e.preventDefault() |  | ||||||
|     e.stopPropagation() |  | ||||||
|     if (e.type === 'dragenter' || e.type === 'dragover') { |  | ||||||
|       setDragActive(true) |  | ||||||
|     } else if (e.type === 'dragleave') { |  | ||||||
|       setDragActive(false) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   const handleDrop = (e: React.DragEvent) => { |  | ||||||
|     e.preventDefault() |  | ||||||
|     e.stopPropagation() |  | ||||||
|     setDragActive(false) |  | ||||||
|      |  | ||||||
|     if (e.dataTransfer.files && e.dataTransfer.files[0]) { |  | ||||||
|       handleFileSelect(e.dataTransfer.files[0]) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   const handleSubmit = async (e: React.FormEvent) => { |  | ||||||
|     e.preventDefault() |  | ||||||
|      |  | ||||||
|     if (!selectedFile) { |  | ||||||
|       return |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     try { |  | ||||||
|       await parseVacancyFile.mutateAsync({ |  | ||||||
|         file: selectedFile, |  | ||||||
|         createVacancy: true |  | ||||||
|       }) |  | ||||||
|        |  | ||||||
|       // Очистить форму после успешной отправки
 |  | ||||||
|       setSelectedFile(null) |  | ||||||
|     } catch (error) { |  | ||||||
|       console.error('Ошибка при загрузке файла:', error) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return ( |  | ||||||
|     <div className="max-w-2xl mx-auto"> |  | ||||||
|       <div className="bg-white rounded-lg shadow-md border border-gray-200 p-6"> |  | ||||||
|         <h2 className="text-xl font-semibold text-gray-900 mb-4 text-center"> |  | ||||||
|           Создать вакансию из файла |  | ||||||
|         </h2> |  | ||||||
|          |  | ||||||
|         <form onSubmit={handleSubmit} className="space-y-4"> |  | ||||||
|           {/* File Upload Area */} |  | ||||||
|           <div |  | ||||||
|             className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${ |  | ||||||
|               dragActive |  | ||||||
|                 ? 'border-primary-500 bg-primary-50' |  | ||||||
|                 : selectedFile |  | ||||||
|                 ? 'border-green-300 bg-green-50' |  | ||||||
|                 : 'border-gray-300 hover:border-gray-400' |  | ||||||
|             }`}
 |  | ||||||
|             onDragEnter={handleDrag} |  | ||||||
|             onDragLeave={handleDrag} |  | ||||||
|             onDragOver={handleDrag} |  | ||||||
|             onDrop={handleDrop} |  | ||||||
|           > |  | ||||||
|             {selectedFile ? ( |  | ||||||
|               <div className="space-y-3"> |  | ||||||
|                 <div className="flex items-center justify-center"> |  | ||||||
|                   <FileText className="h-12 w-12 text-green-600" /> |  | ||||||
|                 </div> |  | ||||||
|                 <div> |  | ||||||
|                   <p className="text-sm font-medium text-gray-900">{selectedFile.name}</p> |  | ||||||
|                   <p className="text-sm text-gray-600"> |  | ||||||
|                     {(selectedFile.size / 1024 / 1024).toFixed(2)} МБ |  | ||||||
|                   </p> |  | ||||||
|                 </div> |  | ||||||
|                 <button |  | ||||||
|                   type="button" |  | ||||||
|                   onClick={() => setSelectedFile(null)} |  | ||||||
|                   className="inline-flex items-center px-3 py-1 border border-gray-300 rounded-md text-sm text-gray-700 bg-white hover:bg-gray-50" |  | ||||||
|                 > |  | ||||||
|                   <X className="h-4 w-4 mr-1" /> |  | ||||||
|                   Удалить |  | ||||||
|                 </button> |  | ||||||
|               </div> |  | ||||||
|             ) : ( |  | ||||||
|               <div className="space-y-3"> |  | ||||||
|                 <div className="flex items-center justify-center"> |  | ||||||
|                   <Upload className="h-12 w-12 text-gray-400" /> |  | ||||||
|                 </div> |  | ||||||
|                 <div> |  | ||||||
|                   <p className="text-lg font-medium text-gray-900"> |  | ||||||
|                     Перетащите файл сюда или{' '} |  | ||||||
|                     <label className="text-primary-600 hover:text-primary-500 cursor-pointer underline"> |  | ||||||
|                       выберите файл |  | ||||||
|                       <input |  | ||||||
|                         type="file" |  | ||||||
|                         className="hidden" |  | ||||||
|                         accept=".pdf,.doc,.docx,.rtf,.txt" |  | ||||||
|                         onChange={(e) => e.target.files?.[0] && handleFileSelect(e.target.files[0])} |  | ||||||
|                       /> |  | ||||||
|                     </label> |  | ||||||
|                   </p> |  | ||||||
|                   <p className="text-sm text-gray-600 mt-1"> |  | ||||||
|                     PDF, DOC, DOCX, RTF, TXT до 10 МБ |  | ||||||
|                   </p> |  | ||||||
|                 </div> |  | ||||||
|               </div> |  | ||||||
|             )} |  | ||||||
|           </div> |  | ||||||
| 
 |  | ||||||
|           {/* Submit Button */} |  | ||||||
|           <div className="flex justify-center"> |  | ||||||
|             <button |  | ||||||
|               type="submit" |  | ||||||
|               disabled={!selectedFile || parseVacancyFile.isPending} |  | ||||||
|               className={`px-6 py-3 rounded-lg font-medium text-white transition-colors ${ |  | ||||||
|                 selectedFile && !parseVacancyFile.isPending |  | ||||||
|                   ? 'bg-primary-600 hover:bg-primary-700 focus:ring-2 focus:ring-primary-500 focus:ring-offset-2' |  | ||||||
|                   : 'bg-gray-400 cursor-not-allowed' |  | ||||||
|               }`}
 |  | ||||||
|             > |  | ||||||
|               {parseVacancyFile.isPending ? ( |  | ||||||
|                 <div className="flex items-center"> |  | ||||||
|                   <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> |  | ||||||
|                   Обработка... |  | ||||||
|                 </div> |  | ||||||
|               ) : ( |  | ||||||
|                 'Создать вакансию' |  | ||||||
|               )} |  | ||||||
|             </button> |  | ||||||
|           </div> |  | ||||||
|         </form> |  | ||||||
| 
 |  | ||||||
|         <p className="text-xs text-gray-500 text-center mt-4"> |  | ||||||
|           После загрузки файл будет обработан и вакансия появится в списке |  | ||||||
|         </p> |  | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|   ) |  | ||||||
| } |  | ||||||
| @ -1,22 +0,0 @@ | |||||||
| import { useQuery } from '@tanstack/react-query' |  | ||||||
| import { interviewReportService } from '@/services/reports.service' |  | ||||||
| 
 |  | ||||||
| export const useInterviewReports = (vacancyId: number) => { |  | ||||||
|   return useQuery({ |  | ||||||
|     queryKey: ['interviewReports', vacancyId], |  | ||||||
|     queryFn: () => interviewReportService.getReportsByVacancy(vacancyId), |  | ||||||
|     enabled: !!vacancyId, |  | ||||||
|     staleTime: 5 * 60 * 1000, // 5 минут
 |  | ||||||
|     retry: 2, |  | ||||||
|   }) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export const useInterviewReport = (sessionId: number) => { |  | ||||||
|   return useQuery({ |  | ||||||
|     queryKey: ['interviewReport', sessionId], |  | ||||||
|     queryFn: () => interviewReportService.getReportBySession(sessionId), |  | ||||||
|     enabled: !!sessionId, |  | ||||||
|     staleTime: 10 * 60 * 1000, // 10 минут
 |  | ||||||
|     retry: 2, |  | ||||||
|   }) |  | ||||||
| } |  | ||||||
| @ -38,28 +38,7 @@ export const useResumesByVacancy = (vacancyId: number) => { | |||||||
|     queryKey: ['resumes', 'by-vacancy', vacancyId], |     queryKey: ['resumes', 'by-vacancy', vacancyId], | ||||||
|     queryFn: () => resumeService.getResumes({ vacancy_id: vacancyId }), |     queryFn: () => resumeService.getResumes({ vacancy_id: vacancyId }), | ||||||
|     enabled: !!vacancyId, |     enabled: !!vacancyId, | ||||||
|     staleTime: 0, // Не кешируем для частых обновлений
 |     staleTime: 2 * 60 * 1000, // 2 minutes
 | ||||||
|     retry: 2, |     retry: 2, | ||||||
|     refetchInterval: false, // Отключаем автоматический refetch, управляем вручную
 |  | ||||||
|   }) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export const useValidateInterview = (resumeId: number, enabled: boolean = true) => { |  | ||||||
|   return useQuery({ |  | ||||||
|     queryKey: ['interview', 'validate', resumeId], |  | ||||||
|     queryFn: () => resumeService.validateInterview(resumeId), |  | ||||||
|     enabled: enabled && !!resumeId, |  | ||||||
|     retry: false, |  | ||||||
|     staleTime: 5 * 60 * 1000, // 5 минут
 |  | ||||||
|   }) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export const useInterviewToken = (resumeId: number, enabled: boolean = false) => { |  | ||||||
|   return useQuery({ |  | ||||||
|     queryKey: ['interview', 'token', resumeId], |  | ||||||
|     queryFn: () => resumeService.getInterviewToken(resumeId), |  | ||||||
|     enabled: enabled && !!resumeId, |  | ||||||
|     retry: false, |  | ||||||
|     staleTime: 30 * 60 * 1000, // 30 минут - токены живут дольше
 |  | ||||||
|   }) |   }) | ||||||
| } | } | ||||||
| @ -40,11 +40,3 @@ export const useSessionHealth = () => { | |||||||
|     retry: 2, |     retry: 2, | ||||||
|   }) |   }) | ||||||
| } | } | ||||||
| 
 |  | ||||||
| export const useForceEndInterview = () => { |  | ||||||
|   return useMutation({ |  | ||||||
|     mutationFn: (sessionId: number) => sessionService.forceEndInterview(sessionId), |  | ||||||
|     retry: false, // Не повторяем запрос при ошибке
 |  | ||||||
|     networkMode: 'always', |  | ||||||
|   }) |  | ||||||
| } |  | ||||||
| @ -1,14 +1,12 @@ | |||||||
| import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' | import { useQuery } from '@tanstack/react-query' | ||||||
| import { vacancyService } from '@/services/vacancy.service' | import { vacancyService } from '@/services/vacancy.service' | ||||||
| import { GetVacanciesParams } from '@/types/api' | import { GetVacanciesParams } from '@/types/api' | ||||||
| import { useEffect, useRef } from 'react' |  | ||||||
| 
 | 
 | ||||||
| export const useVacancies = (params?: GetVacanciesParams) => { | export const useVacancies = (params?: GetVacanciesParams) => { | ||||||
|   return useQuery({ |   return useQuery({ | ||||||
|     queryKey: ['vacancies', params], |     queryKey: ['vacancies', params], | ||||||
|     queryFn: () => vacancyService.getVacancies(params), |     queryFn: () => vacancyService.getVacancies(params), | ||||||
|     staleTime: 0, // Данные сразу считаются устаревшими
 |     staleTime: 5 * 60 * 1000, // 5 minutes
 | ||||||
|     refetchInterval: 5000, // Обновлять каждые 5 секунд
 |  | ||||||
|     retry: 2, |     retry: 2, | ||||||
|   }) |   }) | ||||||
| } | } | ||||||
| @ -22,59 +20,3 @@ export const useVacancy = (id: number) => { | |||||||
|     retry: 2, |     retry: 2, | ||||||
|   }) |   }) | ||||||
| } | } | ||||||
| 
 |  | ||||||
| export const useParseVacancyFile = () => { |  | ||||||
|   const mutation = useMutation({ |  | ||||||
|     mutationFn: ({ file, createVacancy }: { file: File; createVacancy?: boolean }) => |  | ||||||
|       vacancyService.parseFileAsync(file, createVacancy), |  | ||||||
|     onSuccess: (data) => { |  | ||||||
|       // Показать toast уведомление
 |  | ||||||
|       showToast('Задача парсинга запущена! Скоро вакансия появится в списке.') |  | ||||||
|     }, |  | ||||||
|   }) |  | ||||||
| 
 |  | ||||||
|   return mutation |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // Простая функция для показа toast без React компонента
 |  | ||||||
| const showToast = (message: string) => { |  | ||||||
|   // Создать элемент toast
 |  | ||||||
|   const toast = document.createElement('div') |  | ||||||
|   toast.textContent = message |  | ||||||
|   toast.style.cssText = ` |  | ||||||
|     position: fixed; |  | ||||||
|     bottom: 40px; |  | ||||||
|     right: 20px; |  | ||||||
|     background: #10b981; |  | ||||||
|     color: white; |  | ||||||
|     padding: 12px 20px; |  | ||||||
|     border-radius: 8px; |  | ||||||
|     box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); |  | ||||||
|     font-family: system-ui, -apple-system, sans-serif; |  | ||||||
|     font-size: 14px; |  | ||||||
|     z-index: 9999; |  | ||||||
|     max-width: 300px; |  | ||||||
|     word-wrap: break-word; |  | ||||||
|     transition: opacity 0.3s ease, transform 0.3s ease; |  | ||||||
|     transform: translateX(100%); |  | ||||||
|   ` |  | ||||||
|    |  | ||||||
|   // Добавить в DOM
 |  | ||||||
|   document.body.appendChild(toast) |  | ||||||
|    |  | ||||||
|   // Анимация появления
 |  | ||||||
|   setTimeout(() => { |  | ||||||
|     toast.style.transform = 'translateX(0)' |  | ||||||
|   }, 10) |  | ||||||
|    |  | ||||||
|   // Удалить через 5 секунд
 |  | ||||||
|   setTimeout(() => { |  | ||||||
|     toast.style.opacity = '0' |  | ||||||
|     toast.style.transform = 'translateX(100%)' |  | ||||||
|     setTimeout(() => { |  | ||||||
|       if (toast.parentNode) { |  | ||||||
|         document.body.removeChild(toast) |  | ||||||
|       } |  | ||||||
|     }, 300) |  | ||||||
|   }, 10000) |  | ||||||
| } |  | ||||||
| @ -1,6 +1,7 @@ | |||||||
| import ky from 'ky' | import ky from 'ky' | ||||||
| 
 | 
 | ||||||
| const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'https://hr.aiquity.xyz/api' | // Используем прокси Next.js для избежания CORS проблем
 | ||||||
|  | const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000' | ||||||
| 
 | 
 | ||||||
| // Базовый клиент без Content-Type заголовка
 | // Базовый клиент без Content-Type заголовка
 | ||||||
| const baseKyClient = ky.create({ | const baseKyClient = ky.create({ | ||||||
|  | |||||||
| @ -1,36 +1,13 @@ | |||||||
| /** @type {import('next').NextConfig} */ | /** @type {import('next').NextConfig} */ | ||||||
| const nextConfig = { | const nextConfig = { | ||||||
|   output: 'standalone', |   async rewrites() { | ||||||
|   async headers() { |  | ||||||
|     return [ |     return [ | ||||||
|       { |       { | ||||||
|         // Apply these headers to all routes
 |         source: '/api/v1/:path*', | ||||||
|         source: '/(.*)', |         destination: 'http://localhost:8000/api/v1/:path*', | ||||||
|         headers: [ |  | ||||||
|           { |  | ||||||
|             key: 'X-Frame-Options', |  | ||||||
|             value: 'DENY', |  | ||||||
|           }, |  | ||||||
|           { |  | ||||||
|             key: 'X-Content-Type-Options', |  | ||||||
|             value: 'nosniff', |  | ||||||
|           }, |  | ||||||
|           { |  | ||||||
|             key: 'Referrer-Policy', |  | ||||||
|             value: 'origin-when-cross-origin', |  | ||||||
|           }, |  | ||||||
|         ], |  | ||||||
|       }, |       }, | ||||||
|     ] |     ] | ||||||
|   }, |   }, | ||||||
|   // Разрешить WebSocket подключения к внешним доменам
 |  | ||||||
|   experimental: { |  | ||||||
|     serverComponentsExternalPackages: [], |  | ||||||
|   }, |  | ||||||
|   // Настройки для работы с внешними доменами
 |  | ||||||
|   images: { |  | ||||||
|     domains: ['hr.aiquity.xyz'], |  | ||||||
|   }, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| module.exports = nextConfig | module.exports = nextConfig | ||||||
| @ -9,12 +9,10 @@ | |||||||
|     "lint": "next lint" |     "lint": "next lint" | ||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@livekit/components-react": "^2.9.14", |  | ||||||
|     "@tanstack/react-query": "^5.85.6", |     "@tanstack/react-query": "^5.85.6", | ||||||
|     "@tanstack/react-query-devtools": "^5.85.6", |     "@tanstack/react-query-devtools": "^5.85.6", | ||||||
|     "ky": "^1.9.1", |     "ky": "^1.9.1", | ||||||
|     "livekit-client": "^2.15.6", |     "lucide-react": "^0.294.0", | ||||||
|     "lucide-react": "^0.542.0", |  | ||||||
|     "next": "14.0.4", |     "next": "14.0.4", | ||||||
|     "react": "^18.2.0", |     "react": "^18.2.0", | ||||||
|     "react-dom": "^18.2.0", |     "react-dom": "^18.2.0", | ||||||
|  | |||||||
| @ -1,71 +0,0 @@ | |||||||
| #!/bin/bash |  | ||||||
| 
 |  | ||||||
| # Build and push script for Yandex Cloud Container Registry |  | ||||||
| # Usage: ./scripts/build-and-push. [tag] |  | ||||||
| 
 |  | ||||||
| set -e |  | ||||||
| 
 |  | ||||||
| # Configuration |  | ||||||
| REGISTRY_ID="${YANDEX_REGISTRY_ID:-your-registry-id}" |  | ||||||
| IMAGE_NAME="hr-ai-frontend" |  | ||||||
| TAG="${1:-latest}" |  | ||||||
| FULL_IMAGE_NAME="cr.yandex/${REGISTRY_ID}/${IMAGE_NAME}:${TAG}" |  | ||||||
| 
 |  | ||||||
| # Colors for output |  | ||||||
| RED='\033[0;31m' |  | ||||||
| GREEN='\033[0;32m' |  | ||||||
| YELLOW='\033[1;33m' |  | ||||||
| NC='\033[0m' # No Color |  | ||||||
| 
 |  | ||||||
| echo -e "${YELLOW}Building and pushing HR AI Frontend to Yandex Cloud Container Registry${NC}" |  | ||||||
| 
 |  | ||||||
| # Check if required environment variables are set |  | ||||||
| if [ -z "$REGISTRY_ID" ] || [ "$REGISTRY_ID" = "your-registry-id" ]; then |  | ||||||
|     echo -e "${RED}Error: YANDEX_REGISTRY_ID environment variable is not set${NC}" |  | ||||||
|     echo "Please set it to your Yandex Cloud Container Registry ID" |  | ||||||
|     echo "Example: export YANDEX_REGISTRY_ID=crp1234567890abcdef" |  | ||||||
|     exit 1 |  | ||||||
| fi |  | ||||||
| 
 |  | ||||||
| # Check if yc CLI is installed and authenticated |  | ||||||
| if ! command -v yc &> /dev/null; then |  | ||||||
|     echo -e "${RED}Error: Yandex Cloud CLI (yc) is not installed${NC}" |  | ||||||
|     echo "Please install it from: https://cloud.yandex.ru/docs/cli/quickstart" |  | ||||||
|     exit 1 |  | ||||||
| fi |  | ||||||
| 
 |  | ||||||
| # Check authentication |  | ||||||
| if ! yc config list | grep -q "token:"; then |  | ||||||
|     echo -e "${RED}Error: Not authenticated with Yandex Cloud${NC}" |  | ||||||
|     echo "Please run: yc init" |  | ||||||
|     exit 1 |  | ||||||
| fi |  | ||||||
| 
 |  | ||||||
| echo -e "${YELLOW}Configuring Docker for Yandex Cloud Container Registry...${NC}" |  | ||||||
| yc container registry configure-docker |  | ||||||
| 
 |  | ||||||
| echo -e "${YELLOW}Building Docker image: ${FULL_IMAGE_NAME}${NC}" |  | ||||||
| docker build -t "${FULL_IMAGE_NAME}" . |  | ||||||
| 
 |  | ||||||
| echo -e "${YELLOW}Pushing image to registry...${NC}" |  | ||||||
| docker push "${FULL_IMAGE_NAME}" |  | ||||||
| 
 |  | ||||||
| echo -e "${GREEN}✓ Successfully built and pushed: ${FULL_IMAGE_NAME}${NC}" |  | ||||||
| 
 |  | ||||||
| # Also tag as latest if a specific tag was provided |  | ||||||
| if [ "$TAG" != "latest" ]; then |  | ||||||
|     LATEST_IMAGE_NAME="cr.yandex/${REGISTRY_ID}/${IMAGE_NAME}:latest" |  | ||||||
|     echo -e "${YELLOW}Tagging as latest...${NC}" |  | ||||||
|     docker tag "${FULL_IMAGE_NAME}" "${LATEST_IMAGE_NAME}" |  | ||||||
|     docker push "${LATEST_IMAGE_NAME}" |  | ||||||
|     echo -e "${GREEN}✓ Also pushed as: ${LATEST_IMAGE_NAME}${NC}" |  | ||||||
| fi |  | ||||||
| 
 |  | ||||||
| echo -e "${GREEN}Build and push completed successfully!${NC}" |  | ||||||
| echo "" |  | ||||||
| echo "Image is available at:" |  | ||||||
| echo "  ${FULL_IMAGE_NAME}" |  | ||||||
| echo "" |  | ||||||
| echo "To use in production, update your docker-compose.prod.yml:" |  | ||||||
| echo "  frontend:" |  | ||||||
| echo "    image: ${FULL_IMAGE_NAME}" |  | ||||||
| @ -1,40 +0,0 @@ | |||||||
| import { kyClient } from '@/lib/ky-client' |  | ||||||
| import { InterviewReport } from "@/components/VacancyReports"; |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| export const interviewReportService = { |  | ||||||
|   async getReportsByVacancy(vacancyId: number): Promise<InterviewReport[]> { |  | ||||||
|     if (!vacancyId) throw new Error('Vacancy ID is required') |  | ||||||
|     const endpoint = `v1/interview-reports/vacancy/${vacancyId}` |  | ||||||
|     return kyClient.get(endpoint).json<InterviewReport[]>() |  | ||||||
|   }, |  | ||||||
| 
 |  | ||||||
|   async getReportBySession(sessionId: number): Promise<InterviewReport> { |  | ||||||
|     if (!sessionId) throw new Error('Session ID is required') |  | ||||||
|     const endpoint = `v1/interview-reports/session/${sessionId}` |  | ||||||
|     return kyClient.get(endpoint).json<InterviewReport>() |  | ||||||
|   }, |  | ||||||
| 
 |  | ||||||
|   async updateReportScores(reportId: number, scores: Partial<InterviewReport>) { |  | ||||||
|     if (!reportId) throw new Error('Report ID is required') |  | ||||||
|     const endpoint = `v1/interview-reports/${reportId}/scores` |  | ||||||
|     return kyClient.patch(endpoint, { json: scores }).json() |  | ||||||
|   }, |  | ||||||
| 
 |  | ||||||
|   async updateReportNotes(reportId: number, notes: string) { |  | ||||||
|     if (!reportId) throw new Error('Report ID is required') |  | ||||||
|     const endpoint = `v1/interview-reports/${reportId}/notes` |  | ||||||
|     return kyClient.patch(endpoint, { json: { notes } }).json() |  | ||||||
|   }, |  | ||||||
| 
 |  | ||||||
|   async updateReportPdf(reportId: number, pdfUrl: string) { |  | ||||||
|     if (!reportId) throw new Error('Report ID is required') |  | ||||||
|     const endpoint = `v1/interview-reports/${reportId}/pdf` |  | ||||||
|     return kyClient.patch(endpoint, { json: { pdf_url: pdfUrl } }).json() |  | ||||||
|   }, |  | ||||||
| 
 |  | ||||||
|   async createReport(reportData: Partial<InterviewReport>) { |  | ||||||
|     const endpoint = `v1/interview-reports/create` |  | ||||||
|     return kyClient.post(endpoint, { json: reportData }).json<InterviewReport>() |  | ||||||
|   }, |  | ||||||
| } |  | ||||||
| @ -21,18 +21,17 @@ export const resumeService = { | |||||||
| 
 | 
 | ||||||
|     // Логируем данные для отладки
 |     // Логируем данные для отладки
 | ||||||
|     console.log('FormData entries:') |     console.log('FormData entries:') | ||||||
|     // @ts-ignore
 |  | ||||||
|     for (const [key, value] of formData.entries()) { |     for (const [key, value] of formData.entries()) { | ||||||
|       console.log(key, value) |       console.log(key, value) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return kyFormClient.post('v1/resumes/', { |     return kyFormClient.post('api/v1/resumes/', { | ||||||
|       body: formData, |       body: formData, | ||||||
|     }).json<ResumeRead>() |     }).json<ResumeRead>() | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|   async getResume(id: number): Promise<ResumeRead> { |   async getResume(id: number): Promise<ResumeRead> { | ||||||
|     return kyClient.get(`v1/resumes/${id}`).json<ResumeRead>() |     return kyClient.get(`api/v1/resumes/${id}`).json<ResumeRead>() | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|   async getResumes(params?: GetResumesParams): Promise<ResumeRead[]> { |   async getResumes(params?: GetResumesParams): Promise<ResumeRead[]> { | ||||||
| @ -46,15 +45,7 @@ export const resumeService = { | |||||||
|       }) |       }) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const endpoint = `v1/resumes/${searchParams.toString() ? `?${searchParams.toString()}` : ''}` |     const endpoint = `api/v1/resumes/${searchParams.toString() ? `?${searchParams.toString()}` : ''}` | ||||||
|     return kyClient.get(endpoint).json<ResumeRead[]>() |     return kyClient.get(endpoint).json<ResumeRead[]>() | ||||||
|   }, |   }, | ||||||
| 
 |  | ||||||
|   async validateInterview(resumeId: number): Promise<{ can_interview: boolean; message?: string }> { |  | ||||||
|     return kyClient.get(`v1/interview/${resumeId}/validate-interview`).json() |  | ||||||
|   }, |  | ||||||
| 
 |  | ||||||
|   async getInterviewToken(resumeId: number): Promise<{ token: string; roomName: string; serverUrl: string; session_id: number }> { |  | ||||||
|     return kyClient.post(`v1/interview/${resumeId}/token`).json() |  | ||||||
|   }, |  | ||||||
| } | } | ||||||
| @ -3,22 +3,18 @@ import { SessionRead } from '@/types/api' | |||||||
| 
 | 
 | ||||||
| export const sessionService = { | export const sessionService = { | ||||||
|   async getCurrentSession(): Promise<SessionRead> { |   async getCurrentSession(): Promise<SessionRead> { | ||||||
|     return kyClient.get('v1/sessions/current').json<SessionRead>() |     return kyClient.get('api/v1/sessions/current').json<SessionRead>() | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|   async refreshSession(): Promise<void> { |   async refreshSession(): Promise<void> { | ||||||
|     return kyClient.post('v1/sessions/refresh').json<void>() |     return kyClient.post('api/v1/sessions/refresh').json<void>() | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|   async logout(): Promise<void> { |   async logout(): Promise<void> { | ||||||
|     return kyClient.post('v1/sessions/logout').json<void>() |     return kyClient.post('api/v1/sessions/logout').json<void>() | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|   async healthCheck(): Promise<void> { |   async healthCheck(): Promise<void> { | ||||||
|     return kyClient.get('v1/sessions/health').json<void>() |     return kyClient.get('api/v1/sessions/health').json<void>() | ||||||
|   }, |  | ||||||
| 
 |  | ||||||
|   async forceEndInterview(sessionId: number): Promise<void> { |  | ||||||
|     return kyClient.post(`v1/admin/interview/${sessionId}/force-end`).json<void>() |  | ||||||
|   }, |   }, | ||||||
| } | } | ||||||
| @ -1,4 +1,4 @@ | |||||||
| import { kyClient, kyFormClient } from '@/lib/ky-client' | import { kyClient } from '@/lib/ky-client' | ||||||
| import { VacancyRead, GetVacanciesParams } from '@/types/api' | import { VacancyRead, GetVacanciesParams } from '@/types/api' | ||||||
| 
 | 
 | ||||||
| export const vacancyService = { | export const vacancyService = { | ||||||
| @ -13,26 +13,11 @@ export const vacancyService = { | |||||||
|       }) |       }) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const endpoint = `v1/vacancies/${searchParams.toString() ? `?${searchParams.toString()}` : ''}` |     const endpoint = `api/v1/vacancies/${searchParams.toString() ? `?${searchParams.toString()}` : ''}` | ||||||
|     return kyClient.get(endpoint).json<VacancyRead[]>() |     return kyClient.get(endpoint).json<VacancyRead[]>() | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|   async getVacancy(id: number): Promise<VacancyRead> { |   async getVacancy(id: number): Promise<VacancyRead> { | ||||||
|     return kyClient.get(`v1/vacancies/${id}`).json<VacancyRead>() |     return kyClient.get(`api/v1/vacancies/${id}`).json<VacancyRead>() | ||||||
|   }, |  | ||||||
| 
 |  | ||||||
|   async parseFileAsync(file: File, createVacancy: boolean = true): Promise<{ |  | ||||||
|     message: string |  | ||||||
|     task_id: string |  | ||||||
|     status: string |  | ||||||
|     check_status_url: string |  | ||||||
|   }> { |  | ||||||
|     const formData = new FormData() |  | ||||||
|     formData.append('file', file) |  | ||||||
|     formData.append('create_vacancy', createVacancy.toString()) |  | ||||||
| 
 |  | ||||||
|     return kyFormClient.post('v1/vacancies/parse-file-async', { |  | ||||||
|       body: formData |  | ||||||
|     }).json() |  | ||||||
|   }, |   }, | ||||||
| } | } | ||||||
							
								
								
									
										10
									
								
								types/api.ts
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								types/api.ts
									
									
									
									
									
								
							| @ -1,7 +1,7 @@ | |||||||
| export type EmploymentType = 'full' | 'part' | 'project' | 'volunteer' | 'probation' | export type EmploymentType = 'full' | 'part' | 'project' | 'volunteer' | 'probation' | ||||||
| export type Experience = 'noExperience' | 'between1And3' | 'between3And6' | 'moreThan6' | export type Experience = 'noExperience' | 'between1And3' | 'between3And6' | 'moreThan6' | ||||||
| export type Schedule = 'fullDay' | 'shift' | 'flexible' | 'remote' | 'flyInFlyOut' | export type Schedule = 'fullDay' | 'shift' | 'flexible' | 'remote' | 'flyInFlyOut' | ||||||
| export type ResumeStatus = 'pending' | 'parsing' | 'parse_failed' | 'parsed' | 'under_review' | 'interview_scheduled' | 'interviewed' | 'rejected' | 'accepted' | export type ResumeStatus = 'pending' | 'under_review' | 'interview_scheduled' | 'interviewed' | 'rejected' | 'accepted' | ||||||
| 
 | 
 | ||||||
| export interface VacancyRead { | export interface VacancyRead { | ||||||
|   id: number |   id: number | ||||||
| @ -15,9 +15,9 @@ export interface VacancyRead { | |||||||
|   salary_to?: number |   salary_to?: number | ||||||
|   salary_currency?: string |   salary_currency?: string | ||||||
|   gross_salary?: boolean |   gross_salary?: boolean | ||||||
|   company_name?: string |   company_name: string | ||||||
|   company_description?: string |   company_description?: string | ||||||
|   area_name?: string |   area_name: string | ||||||
|   metro_stations?: string |   metro_stations?: string | ||||||
|   address?: string |   address?: string | ||||||
|   professional_roles?: string |   professional_roles?: string | ||||||
| @ -43,9 +43,9 @@ export interface VacancyCreate { | |||||||
|   salary_to?: number |   salary_to?: number | ||||||
|   salary_currency?: string |   salary_currency?: string | ||||||
|   gross_salary?: boolean |   gross_salary?: boolean | ||||||
|   company_name?: string |   company_name: string | ||||||
|   company_description?: string |   company_description?: string | ||||||
|   area_name?: string |   area_name: string | ||||||
|   metro_stations?: string |   metro_stations?: string | ||||||
|   address?: string |   address?: string | ||||||
|   professional_roles?: string |   professional_roles?: string | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user