Added reports page

This commit is contained in:
jeez26 2025-09-08 16:08:40 +03:00
parent 57aca4b657
commit 365f1bde7a
7 changed files with 5262 additions and 3366 deletions

View File

@ -13,7 +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'
@ -285,6 +285,15 @@ export default function VacancyPage() {
</div> </div>
</div> </div>
) } ) }
<div>
<a
href={`/vacancy/report/${vacancyId}`}
rel="noopener noreferrer"
className="w-full flex items-center justify-center px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg shadow hover:bg-indigo-700 transition"
>
Открыть отчеты
</a>
</div>
{/* Application Form */ } {/* Application Form */ }
<ResumeUploadForm <ResumeUploadForm

View File

@ -0,0 +1,58 @@
'use client'
import { useParams, useRouter } from 'next/navigation'
import {
ArrowLeft,
} from 'lucide-react'
import VacancyReports from "@/components/VacancyReports";
import { useInterviewReports } from "@/hooks/useReports";
import React from "react";
export default function VacancyPage() {
const params = useParams()
const router = useRouter()
const vacancyId = parseInt(params.id as string)
const { data: reports, isLoading, error } = useInterviewReports(vacancyId)
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
)
}
if (error || !reports) {
return (
<div className="text-center py-12">
<div className="text-red-600 mb-4">
<p>Не удалось загрузить информацию об отчетах</p>
</div>
<button
onClick={ () => router.back() }
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700"
>
<ArrowLeft className="h-4 w-4 mr-2"/>
Назад
</button>
</div>
)
}
return (
<div className="max-w-4xl mx-auto space-y-8">
<div className="flex items-center justify-between">
<button
onClick={ () => router.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>
)
}

View File

@ -0,0 +1,171 @@
import React from "react";
import { FileText, Award, AlertTriangle, CheckCircle2 } from "lucide-react";
export interface InterviewReport {
id: number;
interview_session_id: number;
technical_skills_score: number;
experience_relevance_score: number;
communication_score: number;
problem_solving_score: number;
cultural_fit_score: number;
overall_score: number;
recommendation: "strongly_recommend" | "recommend" | "consider" | "reject";
strengths: Record<string, any>;
weaknesses: Record<string, any>;
red_flags: Record<string, any>;
next_steps: string | null;
interviewer_notes: string | null;
pdf_report_url: string | null;
}
interface VacancyReportsProps {
reports: InterviewReport[];
}
const recommendationLabels: Record<
InterviewReport["recommendation"],
string
> = {
strongly_recommend: "сильно рекомендую",
recommend: "рекомендую",
consider: "рассмотреть",
reject: "не рекомендую",
};
const recommendationColors: Record<
InterviewReport["recommendation"],
string
> = {
strongly_recommend: "text-green-700 bg-green-100",
recommend: "text-blue-700 bg-blue-100",
consider: "text-yellow-700 bg-yellow-100",
reject: "text-red-700 bg-red-100",
};
const scoringLabels: Record<string, string> = {
technical_skills_score: "Технические навыки",
experience_relevance_score: "Релевантность опыта",
communication_score: "Коммуникация",
problem_solving_score: "Решение задач",
cultural_fit_score: "Культурная совместимость",
overall_score: "Общая оценка",
};
const renderJsonAsList = (data: Record<string, any>) => {
return (
<ul className="list-disc list-inside text-xs bg-gray-50 p-2 rounded">
{Object.entries(data).map(([key, value]) => (
<li key={key}>
<span className="font-medium">{key}:</span> {String(value)}
</li>
))}
</ul>
);
};
export default function VacancyReports({ reports }: VacancyReportsProps) {
return (
<div className="space-y-6">
<h1 className="text-xl font-bold text-gray-900">
Отчёты по собеседованиям
</h1>
{reports.length === 0 ? (
<p className="text-gray-600">Пока нет отчётов по этой вакансии.</p>
) : (
<div className="flex flex-col gap-6">
{reports.map((report) => (
<div
key={report.id}
className="bg-white rounded-lg shadow-md p-6 flex flex-col justify-between"
>
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-2 flex items-center">
<Award className="h-5 w-5 mr-2 text-indigo-500" />
Отчёт #{report.id}
</h2>
<div
className={`inline-block px-3 py-1 rounded-full text-sm font-medium mb-4 ${
recommendationColors[report.recommendation]
}`}
>
{recommendationLabels[report.recommendation]}
</div>
<div className="space-y-2 text-gray-700 text-sm">
{Object.entries(scoringLabels).map(([key, label]) => {
const score = report[key as keyof InterviewReport] as number;
return (
<p key={key}>
<span className="font-medium">{label}:</span> {score}/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
View File

@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query'
import { interviewReportService } from '@/services/reports.service'
export const useInterviewReports = (vacancyId: number) => {
return useQuery({
queryKey: ['interviewReports', vacancyId],
queryFn: () => interviewReportService.getReportsByVacancy(vacancyId),
enabled: !!vacancyId,
staleTime: 5 * 60 * 1000, // 5 минут
retry: 2,
})
}
export const useInterviewReport = (sessionId: number) => {
return useQuery({
queryKey: ['interviewReport', sessionId],
queryFn: () => interviewReportService.getReportBySession(sessionId),
enabled: !!sessionId,
staleTime: 10 * 60 * 1000, // 10 минут
retry: 2,
})
}

View File

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

View File

@ -0,0 +1,40 @@
import { kyClient } from '@/lib/ky-client'
import { InterviewReport } from "@/components/VacancyReports";
export const interviewReportService = {
async getReportsByVacancy(vacancyId: number): Promise<InterviewReport[]> {
if (!vacancyId) throw new Error('Vacancy ID is required')
const endpoint = `v1/interview-reports/vacancy/${vacancyId}`
return kyClient.get(endpoint).json<InterviewReport[]>()
},
async getReportBySession(sessionId: number): Promise<InterviewReport> {
if (!sessionId) throw new Error('Session ID is required')
const endpoint = `v1/interview-reports/session/${sessionId}`
return kyClient.get(endpoint).json<InterviewReport>()
},
async updateReportScores(reportId: number, scores: Partial<InterviewReport>) {
if (!reportId) throw new Error('Report ID is required')
const endpoint = `v1/interview-reports/${reportId}/scores`
return kyClient.patch(endpoint, { json: scores }).json()
},
async updateReportNotes(reportId: number, notes: string) {
if (!reportId) throw new Error('Report ID is required')
const endpoint = `v1/interview-reports/${reportId}/notes`
return kyClient.patch(endpoint, { json: { notes } }).json()
},
async updateReportPdf(reportId: number, pdfUrl: string) {
if (!reportId) throw new Error('Report ID is required')
const endpoint = `v1/interview-reports/${reportId}/pdf`
return kyClient.patch(endpoint, { json: { pdf_url: pdfUrl } }).json()
},
async createReport(reportData: Partial<InterviewReport>) {
const endpoint = `v1/interview-reports/create`
return kyClient.post(endpoint, { json: reportData }).json<InterviewReport>()
},
}

8132
yarn.lock

File diff suppressed because it is too large Load Diff