Compare commits

..

10 Commits

Author SHA1 Message Date
206527fa0d fixes 2025-09-10 21:42:45 +03:00
d7fe54d6bb upd readme 2025-09-09 21:31:34 +05:00
5917bd93fa add microphone checking; add drop files 2025-09-09 20:32:13 +05:00
a837e97e72 add toast 2025-09-09 17:52:08 +05:00
65a9dddf08 Fix ts errors 2025-09-08 16:28:31 +03:00
365f1bde7a Added reports page 2025-09-08 16:08:40 +03:00
57aca4b657 add vacancy uploading 2025-09-08 17:26:45 +05:00
f2fd566cc4 fix env; add agent session hook stop 2025-09-08 00:21:05 +05:00
22c46c133f go back on end session 2025-09-04 23:55:22 +05:00
f31af7ef11 add interview 2025-09-03 14:37:38 +05:00
26 changed files with 6856 additions and 3360 deletions

16
.dockerignore Normal file
View 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
View 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
View 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
View File

@ -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
View 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>
)
}

View File

@ -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>
)
}

View File

@ -3,17 +3,18 @@
import { useParams, useRouter } from 'next/navigation'
import { useVacancy } from '@/hooks/useVacancy'
import { VacancyRead } from '@/types/api'
import {
ArrowLeft,
MapPin,
Clock,
Banknote,
Building,
Users,
import {
ArrowLeft,
MapPin,
Clock,
Banknote,
Building,
Users,
Calendar,
Phone,
Mail,
Globe
Globe,
FileText
} from 'lucide-react'
import ResumeUploadForm from '@/components/ResumeUploadForm'
@ -21,24 +22,24 @@ export default function VacancyPage() {
const params = useParams()
const router = useRouter()
const vacancyId = parseInt(params.id as string)
const { data: vacancy, isLoading, error } = useVacancy(vacancyId)
const formatSalary = (vacancy: VacancyRead) => {
if (!vacancy.salary_from && !vacancy.salary_to) return 'Зарплата не указана'
const currency = vacancy.salary_currency === 'RUR' ? '₽' : vacancy.salary_currency
if (vacancy.salary_from && vacancy.salary_to) {
return `${vacancy.salary_from.toLocaleString()} - ${vacancy.salary_to.toLocaleString()} ${currency}`
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" />
<a
href={`mailto:${vacancy.contacts_email}`}
<Mail className="h-4 w-4 mr-2"/>
<a
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" />
<a
href={`tel:${vacancy.contacts_phone}`}
<Phone className="h-4 w-4 mr-2"/>
<a
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" />
<a
href={vacancy.url}
<Globe className="h-4 w-4 mr-2"/>
<a
href={ vacancy.url }
target="_blank"
rel="noopener noreferrer"
className="hover:text-primary-600"
@ -281,18 +287,27 @@ 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 */}
<ResumeUploadForm
vacancyId={vacancy.id}
vacancyTitle={vacancy.title}
{/* Application Form */ }
<ResumeUploadForm
vacancyId={ vacancy.id }
vacancyTitle={ vacancy.title }
/>
</div>
</div>
</div>
)
}
}

View 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>
)
}

View 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>
)
}

View File

@ -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
}
// Check file type
const allowedTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain'
]
if (!allowedTypes.includes(selectedFile.type)) {
return
}
setFile(selectedFile)
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])
}
}
@ -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>

View 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>
);
}

View 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
View 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,
})
}

View File

@ -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 минут - токены живут дольше
})
}

View File

@ -39,4 +39,12 @@ export const useSessionHealth = () => {
staleTime: 2 * 60 * 1000, // 2 minutes
retry: 2,
})
}
export const useForceEndInterview = () => {
return useMutation({
mutationFn: (sessionId: number) => sessionService.forceEndInterview(sessionId),
retry: false, // Не повторяем запрос при ошибке
networkMode: 'always',
})
}

View File

@ -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,
})
}
@ -19,4 +21,60 @@ export const useVacancy = (id: number) => {
staleTime: 10 * 60 * 1000, // 10 minutes
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)
}

View File

@ -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({

View File

@ -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

View File

@ -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
View 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}"

View 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>()
},
}

View File

@ -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()
},
}

View File

@ -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>()
},
}

View File

@ -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()
},
}

View File

@ -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

7994
yarn.lock

File diff suppressed because it is too large Load Diff