Compare commits

..

2 Commits

6 changed files with 248 additions and 2 deletions

View File

@@ -94,4 +94,18 @@ class UserController extends Controller
return back()->with('success', 'Administrateur supprimé.');
}
public function resetPassword(User $user)
{
if (!auth()->user()->isSuperAdmin()) {
abort(403, 'Unauthorized action.');
}
$password = Str::random(10);
$user->update([
'password' => Hash::make($password)
]);
return back()->with('success', 'Nouveau mot de passe généré pour ' . $user->name . ' : ' . $password);
}
}

19
package-lock.json generated
View File

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

View File

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

View File

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

View File

@@ -1,8 +1,10 @@
<script setup>
import { Head, useForm, Link } from '@inertiajs/vue3';
import { Head, useForm, Link, usePage } from '@inertiajs/vue3';
import { ref } from 'vue';
import AdminLayout from '@/Layouts/AdminLayout.vue';
const page = usePage();
const props = defineProps({
users: Array,
tenants: Array
@@ -51,6 +53,12 @@ const deleteUser = (user) => {
}
};
const resetPassword = (user) => {
if (confirm(`Êtes-vous sûr de vouloir réinitialiser le mot de passe de ${user.name} ? Un nouveau mot de passe sera généré aléatoirement.`)) {
form.post(route('admin.users.reset-password', user.id));
}
};
const cancel = () => {
isCreating.value = false;
editingUser.value = null;
@@ -136,6 +144,9 @@ const cancel = () => {
{{ user.tenant ? user.tenant.name : (user.role === 'super_admin' ? 'Toutes les structures' : 'Aucun rattachement') }}
</td>
<td class="py-3 px-6 text-right space-x-2">
<button v-if="page.props.auth.user.role === 'super_admin'" @click="resetPassword(user)" class="text-orange-600 hover:text-orange-900 px-3 py-1 rounded bg-orange-50 hover:bg-orange-100 transition-colors" title="Réinitialiser le mot de passe">
MDP
</button>
<button @click="editUser(user)" class="text-indigo-600 hover:text-indigo-900 px-3 py-1 rounded bg-indigo-50 hover:bg-indigo-100 transition-colors">Modifier</button>
<button @click="deleteUser(user)" class="text-red-600 hover:text-red-900 px-3 py-1 rounded bg-red-50 hover:bg-red-100 transition-colors">Supprimer</button>
</td>

View File

@@ -86,6 +86,7 @@ Route::middleware('auth')->group(function () {
Route::resource('quizzes.questions', \App\Http\Controllers\QuestionController::class)->only(['store', 'update', 'destroy']);
Route::resource('tenants', \App\Http\Controllers\TenantController::class)->only(['index', 'store', 'update', 'destroy']);
Route::resource('users', \App\Http\Controllers\UserController::class)->except(['show', 'create', 'edit']);
Route::post('/users/{user}/reset-password', [\App\Http\Controllers\UserController::class, 'resetPassword'])->name('users.reset-password');
Route::get('/backup', [\App\Http\Controllers\BackupController::class, 'download'])->name('backup');
Route::delete('/attempts/{attempt}', [\App\Http\Controllers\AttemptController::class, 'destroy'])->name('attempts.destroy');
Route::patch('/answers/{answer}/score', [\App\Http\Controllers\AttemptController::class, 'updateAnswerScore'])->name('answers.update-score');