Compare commits
10 Commits
056f70a1ad
...
206527fa0d
Author | SHA1 | Date | |
---|---|---|---|
206527fa0d | |||
d7fe54d6bb | |||
5917bd93fa | |||
a837e97e72 | |||
65a9dddf08 | |||
365f1bde7a | |||
57aca4b657 | |||
f2fd566cc4 | |||
22c46c133f | |||
f31af7ef11 |
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@ -0,0 +1,16 @@
|
||||
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
|
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=https://hr.aiquity.xyz:8000/api
|
||||
NEXT_PUBLIC_LIVEKIT_URL=wss://hackaton-eizc9zqk.livekit.cloud
|
37
Dockerfile
Normal file
37
Dockerfile
Normal file
@ -0,0 +1,37 @@
|
||||
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 +1,130 @@
|
||||
# hr-ai
|
||||
# HR AI Frontend
|
||||
|
||||
Современная платформа для поиска работы с искусственным интеллектом, построенная на 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 отчеты
|
||||
- Заметки интервьюера и следующие шаги
|
||||
|
105
app/interview/[id]/page.tsx
Normal file
105
app/interview/[id]/page.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
'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>
|
||||
)
|
||||
}
|
179
app/page.tsx
179
app/page.tsx
@ -4,10 +4,12 @@ import { useState } from 'react'
|
||||
import { VacancyRead } from '@/types/api'
|
||||
import { useVacancies } from '@/hooks/useVacancy'
|
||||
import Link from 'next/link'
|
||||
import { Search, MapPin, Clock, Banknote } from 'lucide-react'
|
||||
import { Search, MapPin, Clock, Banknote, Plus } from 'lucide-react'
|
||||
import VacancyUploadForm from '@/components/VacancyUploadForm'
|
||||
|
||||
export default function HomePage() {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||
const [searchParams, setSearchParams] = useState({
|
||||
active_only: true,
|
||||
title: undefined as string | undefined,
|
||||
@ -62,6 +64,45 @@ export default function HomePage() {
|
||||
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) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[400px]">
|
||||
@ -77,8 +118,9 @@ export default function HomePage() {
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Найдите работу мечты
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
Платформа с искусственным интеллектом для поиска идеальной вакансии
|
||||
<p className="text-lg text-gray-600 max-w-3xl mx-auto">
|
||||
Выберите понравившуюся вам вакансию, заполните форму и прикрепите резюме.<br/>
|
||||
После недолговременной обработки вашего документа мы предоставим вам возможность подключится к сессии для собеседования, если ваше резюме удовлетворит вакансию.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -120,57 +162,64 @@ export default function HomePage() {
|
||||
{/* Vacancies Grid */}
|
||||
{!error && vacancies.length > 0 && (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{vacancies.map((vacancy) => (
|
||||
<Link
|
||||
key={vacancy.id}
|
||||
href={`/vacancy/${vacancy.id}`}
|
||||
className="block bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow border border-gray-200"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 line-clamp-2">
|
||||
{vacancy.title}
|
||||
</h3>
|
||||
{vacancy.premium && (
|
||||
<span className="ml-2 px-2 py-1 bg-yellow-100 text-yellow-800 text-xs font-medium rounded">
|
||||
Premium
|
||||
{getVacanciesWithPlaceholders(vacancies).map((item, index) => {
|
||||
if ('isPlaceholder' in item) {
|
||||
return <VacancyPlaceholder key={item.id} />
|
||||
}
|
||||
|
||||
const vacancy = item as VacancyRead
|
||||
return (
|
||||
<Link
|
||||
key={vacancy.id}
|
||||
href={`/vacancy/${vacancy.id}`}
|
||||
className="block bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow border border-gray-200"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 line-clamp-2">
|
||||
{vacancy.title}
|
||||
</h3>
|
||||
{vacancy.premium && (
|
||||
<span className="ml-2 px-2 py-1 bg-yellow-100 text-yellow-800 text-xs font-medium rounded">
|
||||
Premium
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center text-gray-600 text-sm">
|
||||
<Banknote className="h-4 w-4 mr-2" />
|
||||
<span>{formatSalary(vacancy)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-gray-600 text-sm">
|
||||
<MapPin className="h-4 w-4 mr-2" />
|
||||
<span>{formatNullableField(vacancy.area_name)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-gray-600 text-sm">
|
||||
<Clock className="h-4 w-4 mr-2" />
|
||||
<span>{getExperienceText(vacancy.experience)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-700 mb-4">
|
||||
<p className="font-medium">{formatNullableField(vacancy.company_name)}</p>
|
||||
<p className="line-clamp-2 mt-1">{formatNullableField(vacancy.description)}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="px-3 py-1 bg-primary-100 text-primary-800 text-xs font-medium rounded-full">
|
||||
{getEmploymentText(vacancy.employment_type)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(vacancy.published_at || vacancy.created_at).toLocaleDateString('ru-RU')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center text-gray-600 text-sm">
|
||||
<Banknote className="h-4 w-4 mr-2" />
|
||||
<span>{formatSalary(vacancy)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-gray-600 text-sm">
|
||||
<MapPin className="h-4 w-4 mr-2" />
|
||||
<span>{vacancy.area_name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-gray-600 text-sm">
|
||||
<Clock className="h-4 w-4 mr-2" />
|
||||
<span>{getExperienceText(vacancy.experience)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-700 mb-4">
|
||||
<p className="font-medium">{vacancy.company_name}</p>
|
||||
<p className="line-clamp-2 mt-1">{vacancy.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="px-3 py-1 bg-primary-100 text-primary-800 text-xs font-medium rounded-full">
|
||||
{getEmploymentText(vacancy.employment_type)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(vacancy.published_at || vacancy.created_at).toLocaleDateString('ru-RU')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -188,6 +237,34 @@ export default function HomePage() {
|
||||
</p>
|
||||
</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>
|
||||
)
|
||||
}
|
@ -13,7 +13,8 @@ import {
|
||||
Calendar,
|
||||
Phone,
|
||||
Mail,
|
||||
Globe
|
||||
Globe,
|
||||
FileText
|
||||
} from 'lucide-react'
|
||||
import ResumeUploadForm from '@/components/ResumeUploadForm'
|
||||
|
||||
@ -30,15 +31,15 @@ export default function VacancyPage() {
|
||||
const currency = vacancy.salary_currency === 'RUR' ? '₽' : vacancy.salary_currency
|
||||
|
||||
if (vacancy.salary_from && vacancy.salary_to) {
|
||||
return `${vacancy.salary_from.toLocaleString()} - ${vacancy.salary_to.toLocaleString()} ${currency}`
|
||||
return `${ vacancy.salary_from.toLocaleString() } - ${ vacancy.salary_to.toLocaleString() } ${ currency }`
|
||||
}
|
||||
|
||||
if (vacancy.salary_from) {
|
||||
return `от ${vacancy.salary_from.toLocaleString()} ${currency}`
|
||||
return `от ${ vacancy.salary_from.toLocaleString() } ${ currency }`
|
||||
}
|
||||
|
||||
if (vacancy.salary_to) {
|
||||
return `до ${vacancy.salary_to.toLocaleString()} ${currency}`
|
||||
return `до ${ vacancy.salary_to.toLocaleString() } ${ currency }`
|
||||
}
|
||||
}
|
||||
|
||||
@ -74,6 +75,11 @@ export default function VacancyPage() {
|
||||
return mapping[schedule as keyof typeof mapping] || schedule
|
||||
}
|
||||
|
||||
const formatNullableField = (value: string | null | undefined) => {
|
||||
if (!value || value === 'null') return 'Не указано'
|
||||
return value
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[400px]">
|
||||
@ -89,10 +95,10 @@ export default function VacancyPage() {
|
||||
<p>Не удалось загрузить информацию о вакансии</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
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" />
|
||||
<ArrowLeft className="h-4 w-4 mr-2"/>
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
@ -101,179 +107,179 @@ export default function VacancyPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
{/* Header */ }
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
onClick={ () => router.back() }
|
||||
className="inline-flex items-center text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 mr-2" />
|
||||
<ArrowLeft className="h-5 w-5 mr-2"/>
|
||||
Назад к вакансиям
|
||||
</button>
|
||||
|
||||
{vacancy.premium && (
|
||||
{ vacancy.premium && (
|
||||
<span className="px-3 py-1 bg-yellow-100 text-yellow-800 text-sm font-medium rounded-full">
|
||||
Premium
|
||||
</span>
|
||||
)}
|
||||
) }
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
{/* Main Content */ }
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Left Column - Vacancy Details */}
|
||||
{/* Left Column - Vacancy Details */ }
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Title and Company */}
|
||||
{/* Title and Company */ }
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
{vacancy.title}
|
||||
{ vacancy.title }
|
||||
</h1>
|
||||
|
||||
<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">
|
||||
{vacancy.company_name}
|
||||
{formatNullableField(vacancy.company_name)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex items-center text-gray-600">
|
||||
<Banknote className="h-4 w-4 mr-2" />
|
||||
<span>{formatSalary(vacancy)}</span>
|
||||
<Banknote className="h-4 w-4 mr-2"/>
|
||||
<span>{ formatSalary(vacancy) }</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-gray-600">
|
||||
<MapPin className="h-4 w-4 mr-2" />
|
||||
<span>{vacancy.area_name}</span>
|
||||
<span>{formatNullableField(vacancy.area_name)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-gray-600">
|
||||
<Clock className="h-4 w-4 mr-2" />
|
||||
<span>{getExperienceText(vacancy.experience)}</span>
|
||||
<Clock className="h-4 w-4 mr-2"/>
|
||||
<span>{ getExperienceText(vacancy.experience) }</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-gray-600">
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
<span>{getEmploymentText(vacancy.employment_type)}</span>
|
||||
<Users className="h-4 w-4 mr-2"/>
|
||||
<span>{ getEmploymentText(vacancy.employment_type) }</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-gray-600">
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
<span>{getScheduleText(vacancy.schedule)}</span>
|
||||
<Calendar className="h-4 w-4 mr-2"/>
|
||||
<span>{ formatNullableField(getScheduleText(vacancy.schedule)) }</span>
|
||||
</div>
|
||||
|
||||
{vacancy.published_at && (
|
||||
{ vacancy.published_at && (
|
||||
<div className="flex items-center text-gray-600">
|
||||
<Clock className="h-4 w-4 mr-2" />
|
||||
<span>Опубликовано {new Date(vacancy.published_at).toLocaleDateString('ru-RU')}</span>
|
||||
<Clock className="h-4 w-4 mr-2"/>
|
||||
<span>Опубликовано { new Date(vacancy.published_at).toLocaleDateString('ru-RU') }</span>
|
||||
</div>
|
||||
)}
|
||||
) }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{/* Description */ }
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
Описание вакансии
|
||||
</h2>
|
||||
<div className="prose prose-gray max-w-none">
|
||||
<p className="whitespace-pre-line text-gray-700 leading-relaxed">
|
||||
{vacancy.description}
|
||||
{ formatNullableField(vacancy.description) }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Skills */}
|
||||
{vacancy.key_skills && (
|
||||
{/* Key Skills */ }
|
||||
{ vacancy.key_skills && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
Ключевые навыки
|
||||
</h2>
|
||||
<div className="prose prose-gray max-w-none">
|
||||
<p className="text-gray-700">{vacancy.key_skills}</p>
|
||||
<p className="text-gray-700">{ formatNullableField(vacancy.key_skills) }</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) }
|
||||
|
||||
{/* Company Description */}
|
||||
{vacancy.company_description && (
|
||||
{/* Company Description */ }
|
||||
{ vacancy.company_description && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
О компании
|
||||
</h2>
|
||||
<div className="prose prose-gray max-w-none">
|
||||
<p className="whitespace-pre-line text-gray-700 leading-relaxed">
|
||||
{vacancy.company_description}
|
||||
{ formatNullableField(vacancy.company_description) }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) }
|
||||
|
||||
{/* Location Details */}
|
||||
{(vacancy.address || vacancy.metro_stations) && (
|
||||
{/* Location Details */ }
|
||||
{ ( vacancy.address || vacancy.metro_stations ) && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
Местоположение
|
||||
</h2>
|
||||
<div className="space-y-2 text-gray-700">
|
||||
{vacancy.address && (
|
||||
{ vacancy.address && (
|
||||
<div className="flex items-start">
|
||||
<MapPin className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<span>{vacancy.address}</span>
|
||||
<MapPin className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0"/>
|
||||
<span>{ formatNullableField(vacancy.address) }</span>
|
||||
</div>
|
||||
)}
|
||||
{vacancy.metro_stations && (
|
||||
) }
|
||||
{ vacancy.metro_stations && (
|
||||
<div className="flex items-start">
|
||||
<span className="text-sm font-medium mr-2">Метро:</span>
|
||||
<span className="text-sm">{vacancy.metro_stations}</span>
|
||||
<span className="text-sm">{ formatNullableField(vacancy.metro_stations) }</span>
|
||||
</div>
|
||||
)}
|
||||
) }
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) }
|
||||
</div>
|
||||
|
||||
{/* Right Column - Application Form and Contact Info */}
|
||||
{/* Right Column - Application Form and Contact Info */ }
|
||||
<div className="space-y-6">
|
||||
{/* Contact Information */}
|
||||
{(vacancy.contacts_name || vacancy.contacts_email || vacancy.contacts_phone || vacancy.url) && (
|
||||
{/* Contact Information */ }
|
||||
{ ( vacancy.contacts_name || vacancy.contacts_email || vacancy.contacts_phone || vacancy.url ) && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Контактная информация
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
{vacancy.contacts_name && (
|
||||
{ vacancy.contacts_name && (
|
||||
<div className="flex items-center text-gray-700">
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
<span>{vacancy.contacts_name}</span>
|
||||
<Users className="h-4 w-4 mr-2"/>
|
||||
<span>{ formatNullableField(vacancy.contacts_name) }</span>
|
||||
</div>
|
||||
)}
|
||||
{vacancy.contacts_email && (
|
||||
) }
|
||||
{ vacancy.contacts_email && (
|
||||
<div className="flex items-center text-gray-700">
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
<Mail className="h-4 w-4 mr-2"/>
|
||||
<a
|
||||
href={`mailto:${vacancy.contacts_email}`}
|
||||
href={ `mailto:${ vacancy.contacts_email }` }
|
||||
className="hover:text-primary-600"
|
||||
>
|
||||
{vacancy.contacts_email}
|
||||
{ formatNullableField(vacancy.contacts_email) }
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{vacancy.contacts_phone && (
|
||||
) }
|
||||
{ vacancy.contacts_phone && (
|
||||
<div className="flex items-center text-gray-700">
|
||||
<Phone className="h-4 w-4 mr-2" />
|
||||
<Phone className="h-4 w-4 mr-2"/>
|
||||
<a
|
||||
href={`tel:${vacancy.contacts_phone}`}
|
||||
href={ `tel:${ vacancy.contacts_phone }` }
|
||||
className="hover:text-primary-600"
|
||||
>
|
||||
{vacancy.contacts_phone}
|
||||
{ formatNullableField(vacancy.contacts_phone) }
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{vacancy.url && (
|
||||
) }
|
||||
{ vacancy.url && (
|
||||
<div className="flex items-center text-gray-700">
|
||||
<Globe className="h-4 w-4 mr-2" />
|
||||
<Globe className="h-4 w-4 mr-2"/>
|
||||
<a
|
||||
href={vacancy.url}
|
||||
href={ vacancy.url }
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-primary-600"
|
||||
@ -281,15 +287,24 @@ export default function VacancyPage() {
|
||||
Перейти к вакансии
|
||||
</a>
|
||||
</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
|
||||
vacancyId={vacancy.id}
|
||||
vacancyTitle={vacancy.title}
|
||||
vacancyId={ vacancy.id }
|
||||
vacancyTitle={ vacancy.title }
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
58
app/vacancy/report/[id]/page.tsx
Normal file
58
app/vacancy/report/[id]/page.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
'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>
|
||||
)
|
||||
}
|
431
components/InterviewSession.tsx
Normal file
431
components/InterviewSession.tsx
Normal file
@ -0,0 +1,431 @@
|
||||
'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,10 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
// @ts-ignore
|
||||
import InputMask from 'react-input-mask'
|
||||
import { ResumeCreate } from '@/types/api'
|
||||
import { useCreateResume, useResumesByVacancy } from '@/hooks/useResume'
|
||||
import { Upload, FileText, X, CheckCircle, Clock } from 'lucide-react'
|
||||
import { Upload, FileText, X, CheckCircle, Clock, Loader, AlertCircle, Mic } from 'lucide-react'
|
||||
|
||||
interface ResumeUploadFormProps {
|
||||
vacancyId: number
|
||||
@ -22,39 +23,84 @@ export default function ResumeUploadForm({ vacancyId, vacancyTitle, onSuccess }:
|
||||
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
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 { data: existingResumes, isLoading: isLoadingResumes } = useResumesByVacancy(vacancyId)
|
||||
const { data: existingResumes, isLoading: isLoadingResumes, refetch } = useResumesByVacancy(vacancyId)
|
||||
|
||||
// Проверяем есть ли уже резюме для этой вакансии в текущей сессии
|
||||
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 { name, value } = e.target
|
||||
setFormData(prev => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
const validateFile = (file: File) => {
|
||||
// Check file size (max 10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check file type
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
]
|
||||
|
||||
return allowedTypes.includes(file.type)
|
||||
}
|
||||
|
||||
const handleFileSelect = (selectedFile: File) => {
|
||||
if (validateFile(selectedFile)) {
|
||||
setFile(selectedFile)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0]
|
||||
if (selectedFile) {
|
||||
// Check file size (max 10MB)
|
||||
if (selectedFile.size > 10 * 1024 * 1024) {
|
||||
return
|
||||
}
|
||||
handleFileSelect(selectedFile)
|
||||
}
|
||||
}
|
||||
|
||||
// Check file type
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'text/plain'
|
||||
]
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowedTypes.includes(selectedFile.type)) {
|
||||
return
|
||||
}
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragActive(false)
|
||||
|
||||
setFile(selectedFile)
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
handleFileSelect(e.dataTransfer.files[0])
|
||||
}
|
||||
}
|
||||
|
||||
@ -125,42 +171,262 @@ 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) {
|
||||
return (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-6 w-6 text-green-600 mr-3" />
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-green-800">
|
||||
{success ? 'Резюме успешно отправлено!' : 'Ваше резюме уже отправлено!'}
|
||||
</h3>
|
||||
<p className="mt-2 text-green-700">
|
||||
Готовим для вас сессию для собеседования. Мы свяжемся с вами в ближайшее время.
|
||||
</p>
|
||||
{hasExistingResume && existingResumes && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{existingResumes.map((resume) => (
|
||||
<div key={resume.id} className="flex items-center text-sm text-green-600">
|
||||
<Clock className="h-4 w-4 mr-2" />
|
||||
<span>
|
||||
Отправлено: {new Date(resume.created_at).toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})} • Статус: {resume.status === 'pending' ? 'На рассмотрении' :
|
||||
resume.status === 'under_review' ? 'На проверке' :
|
||||
resume.status === 'interview_scheduled' ? 'Собеседование назначено' :
|
||||
resume.status === 'interviewed' ? 'Проведено собеседование' :
|
||||
resume.status === 'accepted' ? 'Принят' :
|
||||
resume.status === 'rejected' ? 'Отклонен' : resume.status}
|
||||
</span>
|
||||
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||
{hasExistingResume && existingResumes && existingResumes.map((resume) => (
|
||||
<div key={resume.id} className="p-6 border-b border-gray-100 last:border-b-0">
|
||||
{/* 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>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
Резюме
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{new Date(resume.created_at).toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@ -169,7 +435,7 @@ export default function ResumeUploadForm({ vacancyId, vacancyTitle, onSuccess }:
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Откликнуться на вакансию
|
||||
Откликнуться
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
{vacancyTitle}
|
||||
@ -257,7 +523,17 @@ export default function ResumeUploadForm({ vacancyId, vacancyTitle, onSuccess }:
|
||||
</label>
|
||||
|
||||
{!file ? (
|
||||
<div className="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md hover:border-primary-400 transition-colors">
|
||||
<div
|
||||
className={`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">
|
||||
<Upload className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<div className="flex text-sm text-gray-600">
|
||||
@ -271,14 +547,14 @@ export default function ResumeUploadForm({ vacancyId, vacancyTitle, onSuccess }:
|
||||
name="resume_file"
|
||||
type="file"
|
||||
className="sr-only"
|
||||
accept=".pdf,.doc,.docx,.txt"
|
||||
accept=".pdf,.docx"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</label>
|
||||
<p className="pl-1">или перетащите сюда</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
PDF, DOC, DOCX, TXT до 10 МБ
|
||||
PDF, DOCX до 10 МБ
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
171
components/VacancyReports.tsx
Normal file
171
components/VacancyReports.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
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>
|
||||
);
|
||||
}
|
173
components/VacancyUploadForm.tsx
Normal file
173
components/VacancyUploadForm.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
'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>
|
||||
)
|
||||
}
|
22
hooks/useReports.ts
Normal file
22
hooks/useReports.ts
Normal file
@ -0,0 +1,22 @@
|
||||
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,7 +38,28 @@ export const useResumesByVacancy = (vacancyId: number) => {
|
||||
queryKey: ['resumes', 'by-vacancy', vacancyId],
|
||||
queryFn: () => resumeService.getResumes({ vacancy_id: vacancyId }),
|
||||
enabled: !!vacancyId,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
staleTime: 0, // Не кешируем для частых обновлений
|
||||
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,3 +40,11 @@ export const useSessionHealth = () => {
|
||||
retry: 2,
|
||||
})
|
||||
}
|
||||
|
||||
export const useForceEndInterview = () => {
|
||||
return useMutation({
|
||||
mutationFn: (sessionId: number) => sessionService.forceEndInterview(sessionId),
|
||||
retry: false, // Не повторяем запрос при ошибке
|
||||
networkMode: 'always',
|
||||
})
|
||||
}
|
@ -1,12 +1,14 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { vacancyService } from '@/services/vacancy.service'
|
||||
import { GetVacanciesParams } from '@/types/api'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export const useVacancies = (params?: GetVacanciesParams) => {
|
||||
return useQuery({
|
||||
queryKey: ['vacancies', params],
|
||||
queryFn: () => vacancyService.getVacancies(params),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
staleTime: 0, // Данные сразу считаются устаревшими
|
||||
refetchInterval: 5000, // Обновлять каждые 5 секунд
|
||||
retry: 2,
|
||||
})
|
||||
}
|
||||
@ -20,3 +22,59 @@ export const useVacancy = (id: number) => {
|
||||
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,7 +1,6 @@
|
||||
import ky from 'ky'
|
||||
|
||||
// Используем прокси Next.js для избежания CORS проблем
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'https://hr.aiquity.xyz/api'
|
||||
|
||||
// Базовый клиент без Content-Type заголовка
|
||||
const baseKyClient = ky.create({
|
||||
|
@ -1,13 +1,36 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
async rewrites() {
|
||||
output: 'standalone',
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/api/v1/:path*',
|
||||
destination: 'http://localhost:8000/api/v1/:path*',
|
||||
// Apply these headers to all routes
|
||||
source: '/(.*)',
|
||||
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
|
@ -9,10 +9,12 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "^2.9.14",
|
||||
"@tanstack/react-query": "^5.85.6",
|
||||
"@tanstack/react-query-devtools": "^5.85.6",
|
||||
"ky": "^1.9.1",
|
||||
"lucide-react": "^0.294.0",
|
||||
"livekit-client": "^2.15.6",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "14.0.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
71
scripts/build-and-push.sh
Executable file
71
scripts/build-and-push.sh
Executable file
@ -0,0 +1,71 @@
|
||||
#!/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}"
|
40
services/reports.service.ts
Normal file
40
services/reports.service.ts
Normal file
@ -0,0 +1,40 @@
|
||||
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,17 +21,18 @@ export const resumeService = {
|
||||
|
||||
// Логируем данные для отладки
|
||||
console.log('FormData entries:')
|
||||
// @ts-ignore
|
||||
for (const [key, value] of formData.entries()) {
|
||||
console.log(key, value)
|
||||
}
|
||||
|
||||
return kyFormClient.post('api/v1/resumes/', {
|
||||
return kyFormClient.post('v1/resumes/', {
|
||||
body: formData,
|
||||
}).json<ResumeRead>()
|
||||
},
|
||||
|
||||
async getResume(id: number): Promise<ResumeRead> {
|
||||
return kyClient.get(`api/v1/resumes/${id}`).json<ResumeRead>()
|
||||
return kyClient.get(`v1/resumes/${id}`).json<ResumeRead>()
|
||||
},
|
||||
|
||||
async getResumes(params?: GetResumesParams): Promise<ResumeRead[]> {
|
||||
@ -45,7 +46,15 @@ export const resumeService = {
|
||||
})
|
||||
}
|
||||
|
||||
const endpoint = `api/v1/resumes/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`
|
||||
const endpoint = `v1/resumes/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`
|
||||
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,18 +3,22 @@ import { SessionRead } from '@/types/api'
|
||||
|
||||
export const sessionService = {
|
||||
async getCurrentSession(): Promise<SessionRead> {
|
||||
return kyClient.get('api/v1/sessions/current').json<SessionRead>()
|
||||
return kyClient.get('v1/sessions/current').json<SessionRead>()
|
||||
},
|
||||
|
||||
async refreshSession(): Promise<void> {
|
||||
return kyClient.post('api/v1/sessions/refresh').json<void>()
|
||||
return kyClient.post('v1/sessions/refresh').json<void>()
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
return kyClient.post('api/v1/sessions/logout').json<void>()
|
||||
return kyClient.post('v1/sessions/logout').json<void>()
|
||||
},
|
||||
|
||||
async healthCheck(): Promise<void> {
|
||||
return kyClient.get('api/v1/sessions/health').json<void>()
|
||||
return kyClient.get('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 } from '@/lib/ky-client'
|
||||
import { kyClient, kyFormClient } from '@/lib/ky-client'
|
||||
import { VacancyRead, GetVacanciesParams } from '@/types/api'
|
||||
|
||||
export const vacancyService = {
|
||||
@ -13,11 +13,26 @@ export const vacancyService = {
|
||||
})
|
||||
}
|
||||
|
||||
const endpoint = `api/v1/vacancies/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`
|
||||
const endpoint = `v1/vacancies/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`
|
||||
return kyClient.get(endpoint).json<VacancyRead[]>()
|
||||
},
|
||||
|
||||
async getVacancy(id: number): Promise<VacancyRead> {
|
||||
return kyClient.get(`api/v1/vacancies/${id}`).json<VacancyRead>()
|
||||
return kyClient.get(`v1/vacancies/${id}`).json<VacancyRead>()
|
||||
},
|
||||
|
||||
async parseFileAsync(file: File, createVacancy: boolean = true): Promise<{
|
||||
message: string
|
||||
task_id: string
|
||||
status: string
|
||||
check_status_url: string
|
||||
}> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('create_vacancy', createVacancy.toString())
|
||||
|
||||
return kyFormClient.post('v1/vacancies/parse-file-async', {
|
||||
body: formData
|
||||
}).json()
|
||||
},
|
||||
}
|
10
types/api.ts
10
types/api.ts
@ -1,7 +1,7 @@
|
||||
export type EmploymentType = 'full' | 'part' | 'project' | 'volunteer' | 'probation'
|
||||
export type Experience = 'noExperience' | 'between1And3' | 'between3And6' | 'moreThan6'
|
||||
export type Schedule = 'fullDay' | 'shift' | 'flexible' | 'remote' | 'flyInFlyOut'
|
||||
export type ResumeStatus = 'pending' | 'under_review' | 'interview_scheduled' | 'interviewed' | 'rejected' | 'accepted'
|
||||
export type ResumeStatus = 'pending' | 'parsing' | 'parse_failed' | 'parsed' | 'under_review' | 'interview_scheduled' | 'interviewed' | 'rejected' | 'accepted'
|
||||
|
||||
export interface VacancyRead {
|
||||
id: number
|
||||
@ -15,9 +15,9 @@ export interface VacancyRead {
|
||||
salary_to?: number
|
||||
salary_currency?: string
|
||||
gross_salary?: boolean
|
||||
company_name: string
|
||||
company_name?: string
|
||||
company_description?: string
|
||||
area_name: string
|
||||
area_name?: string
|
||||
metro_stations?: string
|
||||
address?: string
|
||||
professional_roles?: string
|
||||
@ -43,9 +43,9 @@ export interface VacancyCreate {
|
||||
salary_to?: number
|
||||
salary_currency?: string
|
||||
gross_salary?: boolean
|
||||
company_name: string
|
||||
company_name?: string
|
||||
company_description?: string
|
||||
area_name: string
|
||||
area_name?: string
|
||||
metro_stations?: string
|
||||
address?: string
|
||||
professional_roles?: string
|
||||
|
Loading…
Reference in New Issue
Block a user