feat: implement candidate details page with Chart.js radar visualization and AI analysis integration

This commit is contained in:
jeremy bayse
2026-04-14 18:13:17 +02:00
parent 4810ca9d9c
commit e68108a2b1
3 changed files with 221 additions and 1 deletions

19
package-lock.json generated
View File

@@ -6,6 +6,7 @@
"": { "": {
"dependencies": { "dependencies": {
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"chart.js": "^4.5.1",
"marked": "^17.0.4" "marked": "^17.0.4"
}, },
"devDependencies": { "devDependencies": {
@@ -195,6 +196,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
@@ -1266,6 +1273,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",

View File

@@ -22,6 +22,7 @@
}, },
"dependencies": { "dependencies": {
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"chart.js": "^4.5.1",
"marked": "^17.0.4" "marked": "^17.0.4"
} }
} }

View File

@@ -2,13 +2,14 @@
import AdminLayout from '@/Layouts/AdminLayout.vue'; import AdminLayout from '@/Layouts/AdminLayout.vue';
import axios from 'axios'; import axios from 'axios';
import { Head, Link, router, useForm, usePage } from '@inertiajs/vue3'; 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 { marked } from 'marked';
import Modal from '@/Components/Modal.vue'; import Modal from '@/Components/Modal.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue'; import SecondaryButton from '@/Components/SecondaryButton.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue'; import PrimaryButton from '@/Components/PrimaryButton.vue';
import DangerButton from '@/Components/DangerButton.vue'; import DangerButton from '@/Components/DangerButton.vue';
import InputError from '@/Components/InputError.vue'; import InputError from '@/Components/InputError.vue';
import Chart from 'chart.js/auto';
const props = defineProps({ const props = defineProps({
candidate: Object, 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 aiAnalysis = ref(props.candidate.ai_analysis || null);
const isAnalyzing = ref(false); const isAnalyzing = ref(false);
const selectedProvider = ref(props.ai_config?.default || 'ollama'); const selectedProvider = ref(props.ai_config?.default || 'ollama');
@@ -395,6 +507,94 @@ const runAI = async () => {
</div> </div>
</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 --> <!-- 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="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"> <div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">