Added reports page
This commit is contained in:
parent
57aca4b657
commit
365f1bde7a
@ -3,17 +3,17 @@
|
|||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { useVacancy } from '@/hooks/useVacancy'
|
import { useVacancy } from '@/hooks/useVacancy'
|
||||||
import { VacancyRead } from '@/types/api'
|
import { VacancyRead } from '@/types/api'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
MapPin,
|
MapPin,
|
||||||
Clock,
|
Clock,
|
||||||
Banknote,
|
Banknote,
|
||||||
Building,
|
Building,
|
||||||
Users,
|
Users,
|
||||||
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'
|
||||||
|
|
||||||
@ -21,24 +21,24 @@ export default function VacancyPage() {
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const vacancyId = parseInt(params.id as string)
|
const vacancyId = parseInt(params.id as string)
|
||||||
|
|
||||||
const { data: vacancy, isLoading, error } = useVacancy(vacancyId)
|
const { data: vacancy, isLoading, error } = useVacancy(vacancyId)
|
||||||
|
|
||||||
const formatSalary = (vacancy: VacancyRead) => {
|
const formatSalary = (vacancy: VacancyRead) => {
|
||||||
if (!vacancy.salary_from && !vacancy.salary_to) return 'Зарплата не указана'
|
if (!vacancy.salary_from && !vacancy.salary_to) return 'Зарплата не указана'
|
||||||
|
|
||||||
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 }`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,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>
|
||||||
@ -101,35 +101,35 @@ 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">
|
||||||
{vacancy.company_name || 'Не указано'}
|
{vacancy.company_name || 'Не указано'}
|
||||||
</span>
|
</span>
|
||||||
@ -137,143 +137,143 @@ export default function VacancyPage() {
|
|||||||
|
|
||||||
<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>{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>{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">
|
||||||
{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">{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">
|
||||||
{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>{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">{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>{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"
|
||||||
>
|
>
|
||||||
{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"
|
||||||
>
|
>
|
||||||
{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"
|
||||||
@ -281,18 +281,27 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
58
app/vacancy/report/[id]/page.tsx
Normal file
58
app/vacancy/report/[id]/page.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import VacancyReports from "@/components/VacancyReports";
|
||||||
|
import { useInterviewReports } from "@/hooks/useReports";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function VacancyPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const vacancyId = parseInt(params.id as string)
|
||||||
|
|
||||||
|
const { data: reports, isLoading, error } = useInterviewReports(vacancyId)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-[400px]">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !reports) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-red-600 mb-4">
|
||||||
|
<p>Не удалось загрузить информацию об отчетах</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={ () => router.back() }
|
||||||
|
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2"/>
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={ () => router.push(`/vacancy/${vacancyId}`) }
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
171
components/VacancyReports.tsx
Normal file
171
components/VacancyReports.tsx
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { FileText, Award, AlertTriangle, CheckCircle2 } from "lucide-react";
|
||||||
|
|
||||||
|
export interface InterviewReport {
|
||||||
|
id: number;
|
||||||
|
interview_session_id: number;
|
||||||
|
technical_skills_score: number;
|
||||||
|
experience_relevance_score: number;
|
||||||
|
communication_score: number;
|
||||||
|
problem_solving_score: number;
|
||||||
|
cultural_fit_score: number;
|
||||||
|
overall_score: number;
|
||||||
|
recommendation: "strongly_recommend" | "recommend" | "consider" | "reject";
|
||||||
|
strengths: Record<string, any>;
|
||||||
|
weaknesses: Record<string, any>;
|
||||||
|
red_flags: Record<string, any>;
|
||||||
|
next_steps: string | null;
|
||||||
|
interviewer_notes: string | null;
|
||||||
|
pdf_report_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VacancyReportsProps {
|
||||||
|
reports: InterviewReport[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const recommendationLabels: Record<
|
||||||
|
InterviewReport["recommendation"],
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
strongly_recommend: "сильно рекомендую",
|
||||||
|
recommend: "рекомендую",
|
||||||
|
consider: "рассмотреть",
|
||||||
|
reject: "не рекомендую",
|
||||||
|
};
|
||||||
|
|
||||||
|
const recommendationColors: Record<
|
||||||
|
InterviewReport["recommendation"],
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
strongly_recommend: "text-green-700 bg-green-100",
|
||||||
|
recommend: "text-blue-700 bg-blue-100",
|
||||||
|
consider: "text-yellow-700 bg-yellow-100",
|
||||||
|
reject: "text-red-700 bg-red-100",
|
||||||
|
};
|
||||||
|
|
||||||
|
const scoringLabels: Record<string, string> = {
|
||||||
|
technical_skills_score: "Технические навыки",
|
||||||
|
experience_relevance_score: "Релевантность опыта",
|
||||||
|
communication_score: "Коммуникация",
|
||||||
|
problem_solving_score: "Решение задач",
|
||||||
|
cultural_fit_score: "Культурная совместимость",
|
||||||
|
overall_score: "Общая оценка",
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderJsonAsList = (data: Record<string, any>) => {
|
||||||
|
return (
|
||||||
|
<ul className="list-disc list-inside text-xs bg-gray-50 p-2 rounded">
|
||||||
|
{Object.entries(data).map(([key, value]) => (
|
||||||
|
<li key={key}>
|
||||||
|
<span className="font-medium">{key}:</span> {String(value)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function VacancyReports({ reports }: VacancyReportsProps) {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">
|
||||||
|
Отчёты по собеседованиям
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{reports.length === 0 ? (
|
||||||
|
<p className="text-gray-600">Пока нет отчётов по этой вакансии.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{reports.map((report) => (
|
||||||
|
<div
|
||||||
|
key={report.id}
|
||||||
|
className="bg-white rounded-lg shadow-md p-6 flex flex-col justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-2 flex items-center">
|
||||||
|
<Award className="h-5 w-5 mr-2 text-indigo-500" />
|
||||||
|
Отчёт #{report.id}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`inline-block px-3 py-1 rounded-full text-sm font-medium mb-4 ${
|
||||||
|
recommendationColors[report.recommendation]
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{recommendationLabels[report.recommendation]}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-gray-700 text-sm">
|
||||||
|
{Object.entries(scoringLabels).map(([key, label]) => {
|
||||||
|
const score = report[key as keyof InterviewReport] as number;
|
||||||
|
return (
|
||||||
|
<p key={key}>
|
||||||
|
<span className="font-medium">{label}:</span> {score}/10
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
22
hooks/useReports.ts
Normal file
22
hooks/useReports.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { interviewReportService } from '@/services/reports.service'
|
||||||
|
|
||||||
|
export const useInterviewReports = (vacancyId: number) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['interviewReports', vacancyId],
|
||||||
|
queryFn: () => interviewReportService.getReportsByVacancy(vacancyId),
|
||||||
|
enabled: !!vacancyId,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 минут
|
||||||
|
retry: 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useInterviewReport = (sessionId: number) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['interviewReport', sessionId],
|
||||||
|
queryFn: () => interviewReportService.getReportBySession(sessionId),
|
||||||
|
enabled: !!sessionId,
|
||||||
|
staleTime: 10 * 60 * 1000, // 10 минут
|
||||||
|
retry: 2,
|
||||||
|
})
|
||||||
|
}
|
@ -14,7 +14,7 @@
|
|||||||
"@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",
|
"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",
|
||||||
|
40
services/reports.service.ts
Normal file
40
services/reports.service.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { kyClient } from '@/lib/ky-client'
|
||||||
|
import { InterviewReport } from "@/components/VacancyReports";
|
||||||
|
|
||||||
|
|
||||||
|
export const interviewReportService = {
|
||||||
|
async getReportsByVacancy(vacancyId: number): Promise<InterviewReport[]> {
|
||||||
|
if (!vacancyId) throw new Error('Vacancy ID is required')
|
||||||
|
const endpoint = `v1/interview-reports/vacancy/${vacancyId}`
|
||||||
|
return kyClient.get(endpoint).json<InterviewReport[]>()
|
||||||
|
},
|
||||||
|
|
||||||
|
async getReportBySession(sessionId: number): Promise<InterviewReport> {
|
||||||
|
if (!sessionId) throw new Error('Session ID is required')
|
||||||
|
const endpoint = `v1/interview-reports/session/${sessionId}`
|
||||||
|
return kyClient.get(endpoint).json<InterviewReport>()
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateReportScores(reportId: number, scores: Partial<InterviewReport>) {
|
||||||
|
if (!reportId) throw new Error('Report ID is required')
|
||||||
|
const endpoint = `v1/interview-reports/${reportId}/scores`
|
||||||
|
return kyClient.patch(endpoint, { json: scores }).json()
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateReportNotes(reportId: number, notes: string) {
|
||||||
|
if (!reportId) throw new Error('Report ID is required')
|
||||||
|
const endpoint = `v1/interview-reports/${reportId}/notes`
|
||||||
|
return kyClient.patch(endpoint, { json: { notes } }).json()
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateReportPdf(reportId: number, pdfUrl: string) {
|
||||||
|
if (!reportId) throw new Error('Report ID is required')
|
||||||
|
const endpoint = `v1/interview-reports/${reportId}/pdf`
|
||||||
|
return kyClient.patch(endpoint, { json: { pdf_url: pdfUrl } }).json()
|
||||||
|
},
|
||||||
|
|
||||||
|
async createReport(reportData: Partial<InterviewReport>) {
|
||||||
|
const endpoint = `v1/interview-reports/create`
|
||||||
|
return kyClient.post(endpoint, { json: reportData }).json<InterviewReport>()
|
||||||
|
},
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user