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