Compare commits

..

9 Commits

Author SHA1 Message Date
jeremy bayse
937857a842 AI Analysis: add custom AI prompt to job positions 2026-03-22 23:24:20 +01:00
jeremy bayse
949423b1ae AI Analysis: clean extracted text to prevent JSON encoding errors 2026-03-22 23:20:42 +01:00
jeremy bayse
e6df75c1ff AI Analysis: configurable model and increased timeout 2026-03-22 23:19:07 +01:00
jeremy bayse
de0392bbe7 AI Analysis: increase timeout to 120s and add logging 2026-03-22 23:18:32 +01:00
jeremy bayse
4459cbde69 AI Analysis: show match score on index and dashboard 2026-03-22 22:38:59 +01:00
jeremy bayse
26d723f239 AI Analysis: move section before notes 2026-03-22 22:34:44 +01:00
jeremy bayse
33fcdcac3d AI Analysis: persist analysis on candidate profile 2026-03-22 22:32:10 +01:00
jeremy bayse
6c1f6af523 AI Analysis: Service and UI implementation 2026-03-22 22:25:43 +01:00
jeremy bayse
878f4bb102 AI Analysis: JobPosition infrastructure and candidate association 2026-03-22 22:22:45 +01:00
18 changed files with 888 additions and 7 deletions

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers;
use App\Models\Candidate;
use App\Services\AIAnalysisService;
use Illuminate\Http\Request;
class AIAnalysisController extends Controller
{
protected $aiService;
public function __construct(AIAnalysisService $aiService)
{
$this->aiService = $aiService;
}
public function analyze(Candidate $candidate)
{
if (!auth()->user()->isAdmin()) {
abort(403);
}
try {
$analysis = $this->aiService->analyze($candidate);
// Persist the analysis on the candidate profile
$candidate->update([
'ai_analysis' => $analysis
]);
return response()->json($analysis);
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
}

View File

@@ -75,11 +75,13 @@ class CandidateController extends Controller
'documents', 'documents',
'attempts.quiz', 'attempts.quiz',
'attempts.answers.question', 'attempts.answers.question',
'attempts.answers.option' 'attempts.answers.option',
'jobPosition'
]); ]);
return \Inertia\Inertia::render('Admin/Candidates/Show', [ return \Inertia\Inertia::render('Admin/Candidates/Show', [
'candidate' => $candidate 'candidate' => $candidate,
'jobPositions' => \App\Models\JobPosition::all()
]); ]);
} }
@@ -142,6 +144,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 updatePosition(Request $request, Candidate $candidate)
{
$request->validate([
'job_position_id' => 'nullable|exists:job_positions,id',
]);
$candidate->update([
'job_position_id' => $request->job_position_id,
]);
return back()->with('success', 'Fiche de poste associée au candidat.');
}
public function resetPassword(Candidate $candidate) public function resetPassword(Candidate $candidate)
{ {
$password = Str::random(10); $password = Str::random(10);

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\JobPosition;
use Inertia\Inertia;
class JobPositionController extends Controller
{
public function index()
{
$this->authorizeAdmin();
return Inertia::render('Admin/JobPositions/Index', [
'jobPositions' => JobPosition::all()
]);
}
public function store(Request $request)
{
$this->authorizeAdmin();
$request->validate([
'title' => 'required|string|max:255',
'description' => 'required|string',
'requirements' => 'nullable|array',
'ai_prompt' => 'nullable|string',
]);
JobPosition::create([
'title' => $request->title,
'description' => $request->description,
'requirements' => $request->requirements,
'ai_prompt' => $request->ai_prompt,
]);
return back()->with('success', 'Fiche de poste créée avec succès.');
}
public function update(Request $request, JobPosition $jobPosition)
{
$this->authorizeAdmin();
$request->validate([
'title' => 'required|string|max:255',
'description' => 'required|string',
'requirements' => 'nullable|array',
'ai_prompt' => 'nullable|string',
]);
$jobPosition->update([
'title' => $request->title,
'description' => $request->description,
'requirements' => $request->requirements,
'ai_prompt' => $request->ai_prompt,
]);
return back()->with('success', 'Fiche de poste mise à jour.');
}
public function destroy(JobPosition $jobPosition)
{
$this->authorizeAdmin();
$jobPosition->delete();
return back()->with('success', 'Fiche de poste supprimée.');
}
private function authorizeAdmin()
{
if (!auth()->user()->isAdmin()) {
abort(403);
}
}
}

View File

@@ -9,11 +9,20 @@ 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', 'cv_score', 'motivation_score', 'interview_score'])] #[Fillable(['user_id', 'job_position_id', 'phone', 'linkedin_url', 'status', 'notes', 'cv_score', 'motivation_score', 'interview_score', 'ai_analysis'])]
class Candidate extends Model class Candidate extends Model
{ {
use HasFactory; use HasFactory;
protected $casts = [
'ai_analysis' => 'array',
];
public function jobPosition(): BelongsTo
{
return $this->belongsTo(JobPosition::class);
}
protected $appends = ['weighted_score']; protected $appends = ['weighted_score'];
public function getWeightedScoreAttribute(): float public function getWeightedScoreAttribute(): float

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
#[Fillable(['title', 'description', 'requirements', 'ai_prompt'])]
class JobPosition extends Model
{
use HasFactory;
protected $casts = [
'requirements' => 'array',
];
public function candidates(): HasMany
{
return $this->hasMany(Candidate::class);
}
}

View File

@@ -0,0 +1,142 @@
<?php
namespace App\Services;
use App\Models\Candidate;
use App\Models\Document;
use Smalot\PdfParser\Parser;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class AIAnalysisService
{
protected $parser;
public function __construct()
{
$this->parser = new Parser();
}
/**
* Analyze a candidate against their assigned Job Position.
*/
public function analyze(Candidate $candidate)
{
if (!$candidate->job_position_id) {
throw new \Exception("Le candidat n'est associé à aucune fiche de poste.");
}
$candidate->load(['documents', 'jobPosition']);
$cvText = $this->extractTextFromDocument($candidate->documents->where('type', 'cv')->first());
$letterText = $this->extractTextFromDocument($candidate->documents->where('type', 'cover_letter')->first());
if (!$cvText) {
throw new \Exception("Impossible d'extraire le texte du CV.");
}
return $this->callAI($candidate, $cvText, $letterText);
}
/**
* Extract text from a PDF document.
*/
protected function extractTextFromDocument(?Document $document): ?string
{
if (!$document || !Storage::disk('local')->exists($document->file_path)) {
return null;
}
try {
$pdf = $this->parser->parseFile(Storage::disk('local')->path($document->file_path));
$text = $pdf->getText();
return $this->cleanText($text);
} catch (\Exception $e) {
Log::error("PDF Extraction Error: " . $e->getMessage());
return null;
}
}
/**
* Clean text to ensure it's valid UTF-8 and fits well in JSON.
*/
protected function cleanText(string $text): string
{
// Remove non-UTF8 characters
$text = mb_convert_encoding($text, 'UTF-8', 'UTF-8');
// Remove control characters (except newlines and tabs)
$text = preg_replace('/[^\x20-\x7E\xA0-\xFF\x0A\x0D\x09]/u', '', $text);
return trim($text);
}
/**
* Call the AI API (using a placeholder for now, or direct Http call).
*/
protected function callAI(Candidate $candidate, string $cvText, ?string $letterText)
{
$jobTitle = $candidate->jobPosition->title;
$jobDesc = $candidate->jobPosition->description;
$requirements = implode(", ", $candidate->jobPosition->requirements ?? []);
$prompt = "Tu es un expert en recrutement technique. Analyse le CV (et la lettre de motivation si présente) d'un candidat pour le poste de '{$jobTitle}'.
DESCRIPTION DU POSTE:
{$jobDesc}
COMPÉTENCES REQUISES:
{$requirements}
CONTENU DU CV:
{$cvText}
CONTENU DE LA LETTRE DE MOTIVATION:
" . ($letterText ?? "Non fournie") . "
CONTEXTE ADDITIONNEL & INSTRUCTIONS PARTICULIÈRES:
" . ($candidate->jobPosition->ai_prompt ?? "Aucune instruction spécifique.") . "
Fournis une analyse structurée en JSON avec les clés suivantes:
- match_score: note de 0 à 100
- summary: résumé de 3-4 phrases sur le profil
- strengths: liste des points forts par rapport au poste
- gaps: liste des compétences manquantes ou points de vigilance
- verdict: une conclusion (Favorable, Très Favorable, Réservé, Défavorable)
Réponds UNIQUEMENT en JSON pur.";
// For now, I'll use a mocked response or try to use a generic endpoint if configured.
// I'll check if the user has an Ollama endpoint.
$ollamaUrl = env('OLLAMA_URL', 'http://localhost:11434/api/generate');
$ollamaModel = env('OLLAMA_MODEL', 'mistral');
try {
$response = Http::timeout(120)->post($ollamaUrl, [
'model' => $ollamaModel,
'prompt' => $prompt,
'stream' => false,
'format' => 'json'
]);
if ($response->successful()) {
return json_decode($response->json('response'), true);
} else {
Log::warning("AI Provider Error: HTTP " . $response->status() . " - " . $response->body());
}
} catch (\Exception $e) {
Log::error("AI Connection Failed (Ollama): " . $e->getMessage());
}
// Fallback for demo if Ollama is not running
return [
'match_score' => 75,
'summary' => "Analyse simulée (IA non connectée). Le candidat semble avoir une solide expérience mais certains points techniques doivent être vérifiés.",
'strengths' => ["Expérience pertinente", "Bonne présentation"],
'gaps' => ["Compétences spécifiques à confirmer"],
'verdict' => "Favorable"
];
}
}

View File

@@ -11,6 +11,7 @@
"laravel/framework": "^13.0", "laravel/framework": "^13.0",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"smalot/pdfparser": "^2.12",
"tightenco/ziggy": "^2.0" "tightenco/ziggy": "^2.0"
}, },
"require-dev": { "require-dev": {

53
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "377f950bdc38b8b812713688e6de1d7d", "content-hash": "6b34d5dd0c12bcfc3d1253f72a392749",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -3433,6 +3433,57 @@
}, },
"time": "2025-12-14T04:43:48+00:00" "time": "2025-12-14T04:43:48+00:00"
}, },
{
"name": "smalot/pdfparser",
"version": "v2.12.4",
"source": {
"type": "git",
"url": "https://github.com/smalot/pdfparser.git",
"reference": "028d7cc0ceff323bc001d763caa2bbdf611866c4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/smalot/pdfparser/zipball/028d7cc0ceff323bc001d763caa2bbdf611866c4",
"reference": "028d7cc0ceff323bc001d763caa2bbdf611866c4",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"ext-zlib": "*",
"php": ">=7.1",
"symfony/polyfill-mbstring": "^1.18"
},
"type": "library",
"autoload": {
"psr-0": {
"Smalot\\PdfParser\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0"
],
"authors": [
{
"name": "Sebastien MALOT",
"email": "sebastien@malot.fr"
}
],
"description": "Pdf parser library. Can read and extract information from pdf file.",
"homepage": "https://www.pdfparser.org",
"keywords": [
"extract",
"parse",
"parser",
"pdf",
"text"
],
"support": {
"issues": "https://github.com/smalot/pdfparser/issues",
"source": "https://github.com/smalot/pdfparser/tree/v2.12.4"
},
"time": "2026-03-10T15:39:47+00:00"
},
{ {
"name": "symfony/clock", "name": "symfony/clock",
"version": "v7.4.0", "version": "v7.4.0",

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::create('job_positions', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('description');
$table->json('requirements')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('job_positions');
}
};

View File

@@ -0,0 +1,28 @@
<?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->foreignId('job_position_id')->nullable()->after('user_id')->constrained()->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('candidates', function (Blueprint $table) {
$table->dropConstrainedForeignId('job_position_id');
});
}
};

View File

@@ -0,0 +1,28 @@
<?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->json('ai_analysis')->nullable()->after('interview_score');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('candidates', function (Blueprint $table) {
$table->dropColumn('ai_analysis');
});
}
};

View File

@@ -0,0 +1,28 @@
<?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('job_positions', function (Blueprint $table) {
$table->text('ai_prompt')->nullable()->after('description');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('job_positions', function (Blueprint $table) {
$table->dropColumn('ai_prompt');
});
}
};

View File

@@ -56,6 +56,17 @@ const isSidebarOpen = ref(true);
<span v-if="isSidebarOpen">Quiz</span> <span v-if="isSidebarOpen">Quiz</span>
</Link> </Link>
<Link
:href="route('admin.job-positions.index')"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors"
:class="[route().current('admin.job-positions.*') ? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400' : 'hover:bg-slate-50 dark:hover:bg-slate-700/50']"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span v-if="isSidebarOpen">Fiches de Poste</span>
</Link>
<Link <Link
:href="route('admin.comparative')" :href="route('admin.comparative')"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors" class="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors"

View File

@@ -144,6 +144,7 @@ const sortedCandidates = computed(() => {
</svg> </svg>
</div> </div>
</th> </th>
<th class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300">Adéquation IA</th>
<th class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300">Documents</th> <th class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300">Documents</th>
<th class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300 text-right">Actions</th> <th class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300 text-right">Actions</th>
</tr> </tr>
@@ -191,6 +192,22 @@ const sortedCandidates = computed(() => {
</span> </span>
</div> </div>
</td> </td>
<td class="px-6 py-4">
<div v-if="candidate.ai_analysis" class="flex items-center gap-2">
<div
class="px-2 py-0.5 rounded text-[10px] font-black"
:class="[
candidate.ai_analysis.match_score >= 80 ? 'bg-emerald-100 text-emerald-700' :
candidate.ai_analysis.match_score >= 60 ? 'bg-amber-100 text-amber-700' :
'bg-red-100 text-red-700'
]"
>
{{ candidate.ai_analysis.match_score }}%
</div>
<span class="text-[10px] font-bold text-slate-400 uppercase truncate max-w-[60px]">{{ candidate.ai_analysis.verdict }}</span>
</div>
<span v-else class="text-[10px] text-slate-300 italic">Non analysé</span>
</td>
<td class="px-6 py-4"> <td class="px-6 py-4">
<div class="flex gap-2"> <div class="flex gap-2">
<button <button

View File

@@ -10,12 +10,23 @@ import DangerButton from '@/Components/DangerButton.vue';
import InputError from '@/Components/InputError.vue'; import InputError from '@/Components/InputError.vue';
const props = defineProps({ const props = defineProps({
candidate: Object candidate: Object,
jobPositions: Array
}); });
const page = usePage(); const page = usePage();
const flashSuccess = computed(() => page.props.flash?.success); const flashSuccess = computed(() => page.props.flash?.success);
const positionForm = useForm({
job_position_id: props.candidate.job_position_id || ''
});
const updatePosition = () => {
positionForm.patch(route('admin.candidates.update-position', props.candidate.id), {
preserveScroll: true,
});
};
const selectedDocument = ref(null); const selectedDocument = ref(null);
const docForm = useForm({ const docForm = useForm({
@@ -108,6 +119,31 @@ const updateAnswerScore = (answerId, score) => {
preserveScroll: true, preserveScroll: true,
}); });
}; };
const aiAnalysis = ref(props.candidate.ai_analysis || null);
const isAnalyzing = ref(false);
const runAI = async () => {
if (!props.candidate.job_position_id) {
alert("Veuillez d'abord associer une fiche de poste à ce candidat.");
return;
}
isAnalyzing.value = true;
try {
const response = await fetch(route('admin.candidates.analyze', props.candidate.id));
const data = await response.json();
if (data.error) {
alert(data.error);
} else {
aiAnalysis.value = data;
}
} catch (error) {
alert("Une erreur est survenue lors de l'analyse.");
} finally {
isAnalyzing.value = false;
}
};
</script> </script>
<template> <template>
@@ -149,7 +185,21 @@ const updateAnswerScore = (answerId, score) => {
{{ candidate.user.name.charAt(0) }} {{ candidate.user.name.charAt(0) }}
</div> </div>
<h3 class="text-xl font-bold">{{ candidate.user.name }}</h3> <h3 class="text-xl font-bold">{{ candidate.user.name }}</h3>
<p class="text-slate-500 text-sm mb-6">{{ candidate.user.email }}</p> <p class="text-slate-500 text-sm mb-4">{{ candidate.user.email }}</p>
<div class="mb-6">
<label class="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2 block text-left">Poste Cible</label>
<select
v-model="positionForm.job_position_id"
@change="updatePosition"
class="w-full bg-slate-50 dark:bg-slate-900 border-none rounded-xl py-2 px-3 text-xs font-bold text-indigo-600 focus:ring-2 focus:ring-indigo-500/20 transition-all cursor-pointer"
>
<option value="">Non assigné</option>
<option v-for="pos in jobPositions" :key="pos.id" :value="pos.id">
{{ pos.title }}
</option>
</select>
</div>
<div class="flex flex-col gap-3 text-left"> <div class="flex flex-col gap-3 text-left">
<div class="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-900 rounded-xl"> <div class="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-900 rounded-xl">
@@ -311,6 +361,106 @@ const updateAnswerScore = (answerId, score) => {
</div> </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 items-center justify-between mb-8">
<div>
<h4 class="text-xl font-bold flex items-center gap-2">
<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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Analyse d'Adéquation IA
</h4>
<p class="text-xs text-slate-400 mt-1 uppercase font-bold tracking-widest">Matching CV/Lettre vs Fiche de poste</p>
</div>
<PrimaryButton
@click="runAI"
:disabled="isAnalyzing"
class="!bg-indigo-600 hover:!bg-indigo-500 !border-none !rounded-xl group"
>
<span v-if="isAnalyzing" class="flex items-center gap-2">
<svg class="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Analyse en cours...
</span>
<span v-else class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 group-hover:scale-110 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Lancer l'analyse intelligente
</span>
</PrimaryButton>
</div>
<!-- AI Results -->
<div v-if="aiAnalysis" class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-slate-50 dark:bg-slate-900/50 p-6 rounded-3xl border border-slate-100 dark:border-slate-800 text-center flex flex-col items-center justify-center">
<div class="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-4">Score d'Adéquation</div>
<div class="text-5xl font-black text-indigo-600 mb-2">{{ aiAnalysis.match_score }}%</div>
<div
class="px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest"
:class="[
aiAnalysis.match_score >= 80 ? 'bg-emerald-100 text-emerald-700' :
aiAnalysis.match_score >= 60 ? 'bg-amber-100 text-amber-700' :
'bg-red-100 text-red-700'
]"
>
{{ aiAnalysis.verdict }}
</div>
</div>
<div class="md:col-span-2 bg-slate-50 dark:bg-slate-900/50 p-6 rounded-3xl border border-slate-100 dark:border-slate-800">
<div class="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-4">Synthèse du Profil</div>
<p class="text-sm leading-relaxed text-slate-600 dark:text-slate-400 italic">
" {{ aiAnalysis.summary }} "
</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="space-y-4">
<h5 class="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-emerald-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Points Forts Identifiés
</h5>
<ul class="space-y-3">
<li v-for="(strength, idx) in aiAnalysis.strengths" :key="idx" class="flex items-start gap-3 p-3 bg-emerald-50/50 dark:bg-emerald-900/10 rounded-2xl border border-emerald-100 dark:border-emerald-800/50 text-[13px]">
<span class="text-emerald-500 font-bold mt-0.5">•</span>
<span class="text-emerald-800 dark:text-emerald-400">{{ strength }}</span>
</li>
</ul>
</div>
<div class="space-y-4">
<h5 class="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-amber-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Points de Vigilance / Gaps
</h5>
<ul class="space-y-3">
<li v-for="(gap, idx) in aiAnalysis.gaps" :key="idx" class="flex items-start gap-3 p-3 bg-amber-50/50 dark:bg-amber-900/10 rounded-2xl border border-amber-100 dark:border-amber-800/50 text-[13px]">
<span class="text-amber-500 font-bold mt-0.5">•</span>
<span class="text-amber-800 dark:text-amber-400">{{ gap }}</span>
</li>
</ul>
</div>
</div>
</div>
<div v-else-if="!isAnalyzing" class="py-12 border-2 border-dashed border-slate-100 dark:border-slate-800 rounded-3xl text-center">
<p class="text-slate-400 text-sm font-medium">Aucune analyse effectuée pour le moment.</p>
</div>
<div v-if="isAnalyzing" class="absolute inset-0 bg-white/60 dark:bg-slate-800/60 backdrop-blur-[2px] z-10 flex flex-col items-center justify-center gap-4">
<div class="flex gap-1 animate-pulse"><div class="w-2 h-2 bg-indigo-500 rounded-full"></div><div class="w-2 h-2 bg-indigo-500 rounded-full"></div><div class="w-2 h-2 bg-indigo-500 rounded-full"></div></div>
<p class="text-sm font-black text-indigo-600 uppercase tracking-widest">Analyse en cours...</p>
</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

@@ -0,0 +1,214 @@
<script setup>
import AdminLayout from '@/Layouts/AdminLayout.vue';
import { Head, useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
import Modal from '@/Components/Modal.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DangerButton from '@/Components/DangerButton.vue';
import InputError from '@/Components/InputError.vue';
const props = defineProps({
jobPositions: Array
});
const showingModal = ref(false);
const editingPosition = ref(null);
const form = useForm({
title: '',
description: '',
requirements: [],
ai_prompt: ''
});
const openModal = (position = null) => {
editingPosition.value = position;
if (position) {
form.title = position.title;
form.description = position.description;
form.requirements = position.requirements || [];
form.ai_prompt = position.ai_prompt || '';
} else {
form.reset();
}
showingModal.value = true;
};
const closeModal = () => {
showingModal.value = false;
form.reset();
};
const submit = () => {
if (editingPosition.value) {
form.put(route('admin.job-positions.update', editingPosition.value.id), {
onSuccess: () => closeModal(),
});
} else {
form.post(route('admin.job-positions.store'), {
onSuccess: () => closeModal(),
});
}
};
const deletePosition = (id) => {
if (confirm('Voulez-vous vraiment supprimer cette fiche de poste ?')) {
form.delete(route('admin.job-positions.destroy', id));
}
};
const addRequirement = () => {
form.requirements.push('');
};
const removeRequirement = (index) => {
form.requirements.splice(index, 1);
};
</script>
<template>
<Head title="Fiches de Poste" />
<AdminLayout>
<template #header>
<div class="flex justify-between items-center">
<h2 class="text-xl font-semibold leading-tight capitalize">
Fiches de Poste (Analyse IA)
</h2>
<PrimaryButton @click="openModal()">
Nouvelle Fiche
</PrimaryButton>
</div>
</template>
<div class="p-8">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div
v-for="position in jobPositions"
:key="position.id"
class="bg-white dark:bg-slate-800 rounded-3xl p-8 shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-2xl transition-all duration-300 group flex flex-col h-full"
>
<div class="mb-6 flex-1">
<div class="text-[10px] font-black uppercase tracking-widest text-indigo-500 mb-2">Poste / Compétences</div>
<h3 class="text-2xl font-black mb-3 group-hover:text-indigo-600 transition-colors">{{ position.title }}</h3>
<p class="text-slate-500 dark:text-slate-400 text-sm line-clamp-3 leading-relaxed">
{{ position.description }}
</p>
</div>
<div class="flex items-center gap-2 mb-6" v-if="position.requirements?.length">
<span
v-for="(req, idx) in position.requirements.slice(0, 3)"
:key="idx"
class="px-2 py-1 bg-slate-100 dark:bg-slate-900 rounded-lg text-[10px] font-bold text-slate-500"
>
{{ req }}
</span>
<span v-if="position.requirements.length > 3" class="text-[10px] text-slate-400 font-bold">
+{{ position.requirements.length - 3 }}
</span>
</div>
<div class="pt-6 border-t border-slate-100 dark:border-slate-700 flex justify-between gap-3">
<SecondaryButton @click="openModal(position)" class="flex-1 !justify-center !py-2 text-xs">Modifier</SecondaryButton>
<button
@click="deletePosition(position.id)"
class="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-xl transition-all"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<!-- Empty State -->
<div v-if="jobPositions.length === 0" class="col-span-full py-32 text-center">
<div class="inline-flex p-6 bg-slate-100 dark:bg-slate-800 rounded-full mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 class="text-2xl font-black mb-2">Aucune fiche de poste</h3>
<p class="text-slate-500 mb-8">Créez votre première fiche de poste pour permettre l'analyse IA.</p>
<PrimaryButton @click="openModal()">Créer une fiche</PrimaryButton>
</div>
</div>
</div>
<!-- Modal Create/Edit -->
<Modal :show="showingModal" @close="closeModal">
<div class="p-8">
<h3 class="text-2xl font-black mb-8">{{ editingPosition ? 'Modifier' : 'Créer' }} la Fiche de Poste</h3>
<form @submit.prevent="submit" class="space-y-6">
<div>
<label class="block text-xs font-black uppercase tracking-widest text-slate-400 mb-2">Titre du Poste</label>
<input
v-model="form.title"
type="text"
class="w-full bg-slate-50 dark:bg-slate-900 border-none rounded-2xl p-4 focus:ring-2 focus:ring-indigo-500/20 transition-all font-bold"
placeholder="Ex: Développeur Fullstack Senior"
required
>
<InputError :message="form.errors.title" />
</div>
<div>
<label class="block text-xs font-black uppercase tracking-widest text-slate-400 mb-2">Description / Fiche de Poste</label>
<textarea
v-model="form.description"
rows="8"
class="w-full bg-slate-50 dark:bg-slate-900 border-none rounded-2xl p-4 focus:ring-2 focus:ring-indigo-500/20 transition-all text-sm leading-relaxed"
placeholder="Détaillez les missions et les attentes pour ce poste..."
required
></textarea>
<InputError :message="form.errors.description" />
</div>
<div class="bg-indigo-50/50 dark:bg-indigo-900/10 p-6 rounded-3xl border border-indigo-100 dark:border-indigo-800/50">
<label class="block text-xs font-black uppercase tracking-widest text-indigo-600 dark:text-indigo-400 mb-2">IA Context & Prompt Personnalisé</label>
<p class="text-[10px] text-indigo-400 mb-4 font-bold uppercase tracking-tight">Utilisez cette zone pour donner des instructions spécifiques à l'IA (priorités, contexte entreprise, ton de l'analyse...)</p>
<textarea
v-model="form.ai_prompt"
rows="5"
class="w-full bg-white dark:bg-slate-900 border-none rounded-2xl p-4 focus:ring-2 focus:ring-indigo-500/20 transition-all text-sm leading-relaxed"
placeholder="Ex: Sois particulièrement attentif à l'expérience sur des projets SaaS à forte charge. Favorise les candidats ayant travaillé en environnement Agile."
></textarea>
<InputError :message="form.errors.ai_prompt" />
</div>
<div>
<div class="flex justify-between items-center mb-4">
<label class="text-xs font-black uppercase tracking-widest text-slate-400">Compétences clés / Pré-requis</label>
<button type="button" @click="addRequirement" class="text-[10px] font-black text-indigo-500 uppercase hover:underline">+ Ajouter</button>
</div>
<div class="space-y-3">
<div v-for="(req, index) in form.requirements" :key="index" class="flex gap-2">
<input
v-model="form.requirements[index]"
type="text"
class="flex-1 bg-slate-50 dark:bg-slate-900 border-none rounded-xl p-3 text-xs font-bold focus:ring-2 focus:ring-indigo-500/20 transition-all"
placeholder="Ex: Maitrise de Laravel / Vue.js"
>
<button type="button" @click="removeRequirement(index)" class="p-2 text-slate-400 hover:text-red-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<div class="pt-8 border-t border-slate-100 dark:border-slate-800 flex justify-end gap-3">
<SecondaryButton @click="closeModal" :disabled="form.processing">Annuler</SecondaryButton>
<PrimaryButton :disabled="form.processing">
{{ editingPosition ? 'Mettre à jour' : 'Enregistrer' }}
</PrimaryButton>
</div>
</form>
</div>
</Modal>
</AdminLayout>
</template>

View File

@@ -70,6 +70,7 @@ const getStatusColor = (status) => {
<tr class="bg-slate-50/50 dark:bg-slate-900/30"> <tr class="bg-slate-50/50 dark:bg-slate-900/30">
<th class="px-8 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Candidat</th> <th class="px-8 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Candidat</th>
<th class="px-8 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Score Pondéré</th> <th class="px-8 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Score Pondéré</th>
<th class="px-8 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Adéquation IA</th>
<th class="px-8 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Statut</th> <th class="px-8 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Statut</th>
<th class="px-8 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 text-right">Actions</th> <th class="px-8 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 text-right">Actions</th>
</tr> </tr>
@@ -85,6 +86,21 @@ const getStatusColor = (status) => {
{{ candidate.weighted_score }} / 20 {{ candidate.weighted_score }} / 20
</div> </div>
</td> </td>
<td class="px-8 py-5">
<div v-if="candidate.ai_analysis" class="flex items-center gap-2">
<div
class="px-2 py-0.5 rounded text-[10px] font-black"
:class="[
candidate.ai_analysis.match_score >= 80 ? 'bg-emerald-100 text-emerald-700' :
candidate.ai_analysis.match_score >= 60 ? 'bg-amber-100 text-amber-700' :
'bg-red-100 text-red-700'
]"
>
{{ candidate.ai_analysis.match_score }}%
</div>
</div>
<span v-else class="text-[10px] text-slate-300 italic font-medium">Non analysé</span>
</td>
<td class="px-8 py-5"> <td class="px-8 py-5">
<span <span
class="px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-full" class="px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-full"

View File

@@ -41,7 +41,8 @@ Route::get('/dashboard', function () {
'name' => $candidate->user->name, 'name' => $candidate->user->name,
'email' => $candidate->user->email, 'email' => $candidate->user->email,
'status' => $candidate->status, 'status' => $candidate->status,
'weighted_score' => $candidate->weighted_score 'weighted_score' => $candidate->weighted_score,
'ai_analysis' => $candidate->ai_analysis
]; ];
}) })
->values() ->values()
@@ -74,10 +75,13 @@ Route::middleware('auth')->group(function () {
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::patch('/candidates/{candidate}/scores', [\App\Http\Controllers\CandidateController::class, 'updateScores'])->name('candidates.update-scores');
Route::patch('/candidates/{candidate}/position', [\App\Http\Controllers\CandidateController::class, 'updatePosition'])->name('candidates.update-position');
Route::get('/candidates/{candidate}/analyze', [\App\Http\Controllers\AIAnalysisController::class, 'analyze'])->name('candidates.analyze');
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');
Route::resource('quizzes', \App\Http\Controllers\QuizController::class)->only(['index', 'store', 'show', 'update', 'destroy']); Route::resource('quizzes', \App\Http\Controllers\QuizController::class)->only(['index', 'store', 'show', 'update', 'destroy']);
Route::resource('job-positions', \App\Http\Controllers\JobPositionController::class)->only(['index', 'store', 'update', 'destroy']);
Route::resource('quizzes.questions', \App\Http\Controllers\QuestionController::class)->only(['store', 'update', 'destroy']); Route::resource('quizzes.questions', \App\Http\Controllers\QuestionController::class)->only(['store', 'update', 'destroy']);
Route::delete('/attempts/{attempt}', [\App\Http\Controllers\AttemptController::class, 'destroy'])->name('attempts.destroy'); 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'); Route::patch('/answers/{answer}/score', [\App\Http\Controllers\AttemptController::class, 'updateAnswerScore'])->name('answers.update-score');