Ajustement des scores : test technique rapporté sur 20 et moyenne pondérée corrigée

This commit is contained in:
jeremy bayse
2026-03-20 17:46:42 +01:00
parent ec75942a79
commit 5c2bcb0169
5 changed files with 150 additions and 2 deletions

View File

@@ -129,6 +129,19 @@ class CandidateController extends Controller
return back()->with('success', 'Notes mises à jour avec succès.'); return back()->with('success', 'Notes mises à jour avec succès.');
} }
public function updateScores(Request $request, Candidate $candidate)
{
$request->validate([
'cv_score' => 'nullable|numeric|min:0|max:20',
'motivation_score' => 'nullable|numeric|min:0|max:10',
'interview_score' => 'nullable|numeric|min:0|max:30',
]);
$candidate->update($request->only(['cv_score', 'motivation_score', 'interview_score']));
return back()->with('success', 'Notes mises à jour avec succès.');
}
public function resetPassword(Candidate $candidate) public function resetPassword(Candidate $candidate)
{ {
$password = Str::random(10); $password = Str::random(10);

View File

@@ -9,11 +9,30 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
#[Fillable(['user_id', 'phone', 'linkedin_url', 'status', 'notes'])] #[Fillable(['user_id', 'phone', 'linkedin_url', 'status', 'notes', 'cv_score', 'motivation_score', 'interview_score'])]
class Candidate extends Model class Candidate extends Model
{ {
use HasFactory; use HasFactory;
protected $appends = ['weighted_score'];
public function getWeightedScoreAttribute(): float
{
$cv = (float)$this->cv_score;
$motivation = (float)$this->motivation_score;
$interview = (float)$this->interview_score;
// On récupère le meilleur test (ramené sur 20)
$bestAttempt = $this->attempts()->whereNotNull('finished_at')->get()->map(function($a) {
return $a->max_score > 0 ? ($a->score / $a->max_score) * 20 : 0;
})->max() ?? 0;
$totalPoints = $cv + $motivation + $interview + $bestAttempt;
$maxPoints = 20 + 10 + 30 + 20; // Total potentiel = 80
return round(($totalPoints / $maxPoints) * 20, 2);
}
public function user(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('candidates', function (Blueprint $table) {
$table->decimal('cv_score', 5, 2)->nullable()->after('notes');
$table->decimal('motivation_score', 5, 2)->nullable()->after('cv_score');
$table->decimal('interview_score', 5, 2)->nullable()->after('motivation_score');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('candidates', function (Blueprint $table) {
$table->dropColumn(['cv_score', 'motivation_score', 'interview_score']);
});
}
};

View File

@@ -28,6 +28,12 @@ const notesForm = useForm({
notes: props.candidate.notes || '' notes: props.candidate.notes || ''
}); });
const scoreForm = useForm({
cv_score: props.candidate.cv_score || 0,
motivation_score: props.candidate.motivation_score || 0,
interview_score: props.candidate.interview_score || 0,
});
const isPreview = ref(false); const isPreview = ref(false);
const renderedNotes = computed(() => marked.parse(notesForm.notes || '')); const renderedNotes = computed(() => marked.parse(notesForm.notes || ''));
@@ -85,6 +91,12 @@ const saveNotes = () => {
}); });
}; };
const saveScores = () => {
scoreForm.patch(route('admin.candidates.update-scores', props.candidate.id), {
preserveScroll: true,
});
};
const openPreview = (doc) => { const openPreview = (doc) => {
selectedDocument.value = doc; selectedDocument.value = doc;
}; };
@@ -216,8 +228,81 @@ const openPreview = (doc) => {
</div> </div>
</div> </div>
<!-- Main: Attempts --> <!-- Main: Content -->
<div class="xl:col-span-2 space-y-8"> <div class="xl:col-span-2 space-y-8">
<!-- Scores Dashboard -->
<div class="bg-white dark:bg-slate-800 rounded-3xl shadow-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="p-8 bg-gradient-to-br from-slate-900 to-slate-800 text-white flex flex-col md:flex-row md:items-center justify-between gap-8">
<div class="flex items-center gap-6">
<div class="relative flex items-center justify-center">
<svg class="w-24 h-24 transform -rotate-90">
<circle cx="48" cy="48" r="40" stroke="currentColor" stroke-width="8" fill="transparent" class="text-slate-700" />
<circle cx="48" cy="48" r="40" stroke="currentColor" stroke-width="8" fill="transparent" class="text-indigo-500"
:stroke-dasharray="251.2"
:stroke-dashoffset="251.2 - (candidate.weighted_score / 20) * 251.2"
stroke-linecap="round"
/>
</svg>
<span class="absolute text-2xl font-black">{{ candidate.weighted_score }}</span>
</div>
<div>
<h3 class="text-2xl font-black tracking-tight mb-1">Score Global</h3>
<p class="text-slate-400 text-sm font-medium">Note pondérée sur 20</p>
</div>
</div>
<div class="flex gap-4">
<PrimaryButton @click="saveScores" v-if="scoreForm.isDirty" class="!bg-indigo-500 hover:!bg-indigo-400 !border-none animate-bounce">
Enregistrer les modifications
</PrimaryButton>
</div>
</div>
<div class="p-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- CV Score -->
<div class="group">
<label class="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-3 block">Analyse CV /20</label>
<div class="relative">
<input type="number" v-model="scoreForm.cv_score" min="0" max="20" step="0.5"
class="w-full bg-slate-50 dark:bg-slate-900 border-none rounded-2xl p-4 font-black text-xl text-indigo-600 focus:ring-2 focus:ring-indigo-500/20 transition-all" />
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-300 font-bold">/ 20</div>
</div>
</div>
<!-- Letter Score -->
<div class="group">
<label class="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-3 block">Lettre Motiv. /10</label>
<div class="relative">
<input type="number" v-model="scoreForm.motivation_score" min="0" max="10" step="0.5"
class="w-full bg-slate-50 dark:bg-slate-900 border-none rounded-2xl p-4 font-black text-xl text-emerald-600 focus:ring-2 focus:ring-emerald-500/20 transition-all" />
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-300 font-bold">/ 10</div>
</div>
</div>
<!-- Interview Score -->
<div class="group">
<label class="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-3 block">Entretien /30</label>
<div class="relative">
<input type="number" v-model="scoreForm.interview_score" min="0" max="30" step="0.5"
class="w-full bg-slate-50 dark:bg-slate-900 border-none rounded-2xl p-4 font-black text-xl text-purple-600 focus:ring-2 focus:ring-purple-500/20 transition-all" />
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-300 font-bold">/ 30</div>
</div>
</div>
<!-- Test Score (Read Only) -->
<div class="group">
<label class="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-3 block">Test Technique /20</label>
<div class="w-full bg-slate-100 dark:bg-slate-900/50 rounded-2xl p-4 flex items-center justify-between border-2 border-dashed border-slate-200 dark:border-slate-700">
<span class="font-black text-xl text-slate-500">
{{ candidate.attempts.length > 0 ? (Math.max(...candidate.attempts.map(a => a.max_score > 0 ? a.score/a.max_score : 0)) * 20).toFixed(2) : '0.00' }}
</span>
<div class="p-2 bg-white dark:bg-slate-800 rounded-lg shadow-sm font-bold text-[10px] text-slate-400">
/ 20
</div>
</div>
</div>
</div>
</div>
<!-- Notes Section --> <!-- Notes 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="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"> <div class="flex items-center justify-between mb-6">

View File

@@ -53,6 +53,7 @@ Route::middleware('auth')->group(function () {
Route::get('/comparative', [\App\Http\Controllers\CandidateController::class, 'comparative'])->name('comparative'); Route::get('/comparative', [\App\Http\Controllers\CandidateController::class, 'comparative'])->name('comparative');
Route::resource('candidates', \App\Http\Controllers\CandidateController::class)->only(['index', 'store', 'show', 'destroy', 'update']); Route::resource('candidates', \App\Http\Controllers\CandidateController::class)->only(['index', 'store', 'show', 'destroy', 'update']);
Route::patch('/candidates/{candidate}/notes', [\App\Http\Controllers\CandidateController::class, 'updateNotes'])->name('candidates.update-notes'); Route::patch('/candidates/{candidate}/notes', [\App\Http\Controllers\CandidateController::class, 'updateNotes'])->name('candidates.update-notes');
Route::patch('/candidates/{candidate}/scores', [\App\Http\Controllers\CandidateController::class, 'updateScores'])->name('candidates.update-scores');
Route::post('/candidates/{candidate}/reset-password', [\App\Http\Controllers\CandidateController::class, 'resetPassword'])->name('candidates.reset-password'); Route::post('/candidates/{candidate}/reset-password', [\App\Http\Controllers\CandidateController::class, 'resetPassword'])->name('candidates.reset-password');
Route::get('/documents/{document}', [\App\Http\Controllers\DocumentController::class, 'show'])->name('documents.show'); Route::get('/documents/{document}', [\App\Http\Controllers\DocumentController::class, 'show'])->name('documents.show');