diff --git a/package-lock.json b/package-lock.json index 6d674f3..fe899d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "dependencies": { "@tailwindcss/typography": "^0.5.19", + "chart.js": "^4.5.1", "marked": "^17.0.4" }, "devDependencies": { @@ -195,6 +196,12 @@ "@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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", @@ -1266,6 +1273,18 @@ "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": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", diff --git a/package.json b/package.json index ee66e77..febc9d0 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@tailwindcss/typography": "^0.5.19", + "chart.js": "^4.5.1", "marked": "^17.0.4" } } diff --git a/resources/js/Pages/Admin/Candidates/Show.vue b/resources/js/Pages/Admin/Candidates/Show.vue index 79f69e1..0d9c1ce 100644 --- a/resources/js/Pages/Admin/Candidates/Show.vue +++ b/resources/js/Pages/Admin/Candidates/Show.vue @@ -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 () => { + +
+ Chaque axe est normalisé sur 100% par rapport à son barème maximum. +
+