feat: implement candidate details page with Chart.js radar visualization and AI analysis integration
This commit is contained in:
@@ -2,13 +2,14 @@
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue';
|
||||
import axios from 'axios';
|
||||
import { Head, Link, router, useForm, usePage } from '@inertiajs/vue3';
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import Modal from '@/Components/Modal.vue';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import DangerButton from '@/Components/DangerButton.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import Chart from 'chart.js/auto';
|
||||
|
||||
const props = defineProps({
|
||||
candidate: Object,
|
||||
@@ -133,6 +134,117 @@ const updateAnswerScore = (answerId, score) => {
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Radar Chart ──────────────────────────────────────────────────────────────
|
||||
const radarCanvasRef = ref(null);
|
||||
let radarChartInstance = null;
|
||||
|
||||
// Calcul du score test (meilleure tentative ramenée sur 20)
|
||||
const bestTestScore = computed(() => {
|
||||
if (!props.candidate.attempts || props.candidate.attempts.length === 0) return 0;
|
||||
const finished = props.candidate.attempts.filter(a => a.finished_at && a.max_score > 0);
|
||||
if (finished.length === 0) return 0;
|
||||
return Math.max(...finished.map(a => (a.score / a.max_score) * 20));
|
||||
});
|
||||
|
||||
// Données radar normalisées en % (chaque axe / son max)
|
||||
const radarData = computed(() => ([
|
||||
Math.round((parseFloat(scoreForm.cv_score) / 20) * 100),
|
||||
Math.round((parseFloat(scoreForm.motivation_score) / 10) * 100),
|
||||
Math.round((parseFloat(scoreForm.interview_score) / 30) * 100),
|
||||
Math.round((bestTestScore.value / 20) * 100),
|
||||
]));
|
||||
|
||||
const buildRadarChart = () => {
|
||||
if (!radarCanvasRef.value) return;
|
||||
if (radarChartInstance) {
|
||||
radarChartInstance.destroy();
|
||||
radarChartInstance = null;
|
||||
}
|
||||
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const gridColor = isDark ? 'rgba(148,163,184,0.15)' : 'rgba(100,116,139,0.15)';
|
||||
const labelColor = isDark ? '#94a3b8' : '#64748b';
|
||||
|
||||
radarChartInstance = new Chart(radarCanvasRef.value, {
|
||||
type: 'radar',
|
||||
data: {
|
||||
labels: ['Analyse CV', 'Lettre Motiv.', 'Entretien', 'Test Technique'],
|
||||
datasets: [{
|
||||
label: 'Profil Candidat (%)',
|
||||
data: radarData.value,
|
||||
backgroundColor: 'rgba(99,102,241,0.15)',
|
||||
borderColor: 'rgba(99,102,241,0.9)',
|
||||
borderWidth: 2.5,
|
||||
pointBackgroundColor: 'rgba(99,102,241,1)',
|
||||
pointBorderColor: '#fff',
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
pointHoverBackgroundColor: 'rgba(139,92,246,1)',
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
animation: { duration: 700, easing: 'easeInOutQuart' },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => ` ${ctx.raw}%`,
|
||||
},
|
||||
backgroundColor: isDark ? '#1e293b' : '#fff',
|
||||
titleColor: isDark ? '#e2e8f0' : '#0f172a',
|
||||
bodyColor: isDark ? '#94a3b8' : '#475569',
|
||||
borderColor: isDark ? '#334155' : '#e2e8f0',
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
cornerRadius: 10,
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
r: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: {
|
||||
stepSize: 25,
|
||||
color: labelColor,
|
||||
backdropColor: 'transparent',
|
||||
font: { size: 9, weight: 'bold' },
|
||||
callback: (v) => v + '%',
|
||||
},
|
||||
grid: { color: gridColor },
|
||||
angleLines: { color: gridColor },
|
||||
pointLabels: {
|
||||
color: labelColor,
|
||||
font: { size: 11, weight: 'bold' },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => buildRadarChart());
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (radarChartInstance) radarChartInstance.destroy();
|
||||
});
|
||||
|
||||
// Mise à jour du radar quand les scores changent
|
||||
watch(
|
||||
() => [scoreForm.cv_score, scoreForm.motivation_score, scoreForm.interview_score, bestTestScore.value],
|
||||
() => {
|
||||
if (radarChartInstance) {
|
||||
radarChartInstance.data.datasets[0].data = radarData.value;
|
||||
radarChartInstance.update();
|
||||
}
|
||||
}
|
||||
);
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const aiAnalysis = ref(props.candidate.ai_analysis || null);
|
||||
const isAnalyzing = ref(false);
|
||||
const selectedProvider = ref(props.ai_config?.default || 'ollama');
|
||||
@@ -395,6 +507,94 @@ const runAI = async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Radar Chart Section -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h4 class="text-xl font-bold flex items-center gap-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z" />
|
||||
</svg>
|
||||
Profil de Compétences
|
||||
</h4>
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<span class="text-[10px] font-black uppercase tracking-widest text-slate-400">Score global</span>
|
||||
<span class="text-2xl font-black text-indigo-600">{{ candidate.weighted_score }}<span class="text-sm text-slate-300 font-normal ml-1">/20</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 items-center">
|
||||
<!-- Radar Canvas -->
|
||||
<div class="relative flex items-center justify-center">
|
||||
<canvas ref="radarCanvasRef" class="max-h-72"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Score Breakdown Legend -->
|
||||
<div class="space-y-4">
|
||||
<!-- CV -->
|
||||
<div class="group">
|
||||
<div class="flex justify-between items-center mb-1.5">
|
||||
<span class="text-xs font-black uppercase tracking-widest text-slate-500">Analyse CV</span>
|
||||
<span class="text-sm font-black text-indigo-600">{{ scoreForm.cv_score }} <span class="text-slate-300 font-normal">/ 20</span></span>
|
||||
</div>
|
||||
<div class="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-gradient-to-r from-indigo-400 to-indigo-600 rounded-full transition-all duration-700"
|
||||
:style="{ width: (scoreForm.cv_score / 20 * 100) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Motivation -->
|
||||
<div class="group">
|
||||
<div class="flex justify-between items-center mb-1.5">
|
||||
<span class="text-xs font-black uppercase tracking-widest text-slate-500">Lettre de Motivation</span>
|
||||
<span class="text-sm font-black text-emerald-600">{{ scoreForm.motivation_score }} <span class="text-slate-300 font-normal">/ 10</span></span>
|
||||
</div>
|
||||
<div class="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-gradient-to-r from-emerald-400 to-emerald-600 rounded-full transition-all duration-700"
|
||||
:style="{ width: (scoreForm.motivation_score / 10 * 100) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entretien -->
|
||||
<div class="group">
|
||||
<div class="flex justify-between items-center mb-1.5">
|
||||
<span class="text-xs font-black uppercase tracking-widest text-slate-500">Entretien</span>
|
||||
<span class="text-sm font-black text-purple-600">{{ scoreForm.interview_score }} <span class="text-slate-300 font-normal">/ 30</span></span>
|
||||
</div>
|
||||
<div class="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-gradient-to-r from-purple-400 to-purple-600 rounded-full transition-all duration-700"
|
||||
:style="{ width: (scoreForm.interview_score / 30 * 100) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Technique -->
|
||||
<div class="group">
|
||||
<div class="flex justify-between items-center mb-1.5">
|
||||
<span class="text-xs font-black uppercase tracking-widest text-slate-500">Test Technique</span>
|
||||
<span class="text-sm font-black text-amber-600">{{ bestTestScore.toFixed(2) }} <span class="text-slate-300 font-normal">/ 20</span></span>
|
||||
</div>
|
||||
<div class="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-gradient-to-r from-amber-400 to-amber-600 rounded-full transition-all duration-700"
|
||||
:style="{ width: (bestTestScore / 20 * 100) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score footer note -->
|
||||
<p class="text-[10px] text-slate-400 italic pt-2 border-t border-slate-100 dark:border-slate-700">
|
||||
Chaque axe est normalisé sur 100% par rapport à son barème maximum.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Analysis Section -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8 overflow-hidden relative">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
|
||||
|
||||
Reference in New Issue
Block a user