Compare commits
2 Commits
2df0d6def0
...
6c1f6af523
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c1f6af523 | ||
|
|
878f4bb102 |
31
app/Http/Controllers/AIAnalysisController.php
Normal file
31
app/Http/Controllers/AIAnalysisController.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?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);
|
||||
return response()->json($analysis);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,11 +75,13 @@ class CandidateController extends Controller
|
||||
'documents',
|
||||
'attempts.quiz',
|
||||
'attempts.answers.question',
|
||||
'attempts.answers.option'
|
||||
'attempts.answers.option',
|
||||
'jobPosition'
|
||||
]);
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
$password = Str::random(10);
|
||||
|
||||
73
app/Http/Controllers/JobPositionController.php
Normal file
73
app/Http/Controllers/JobPositionController.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?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',
|
||||
]);
|
||||
|
||||
JobPosition::create([
|
||||
'title' => $request->title,
|
||||
'description' => $request->description,
|
||||
'requirements' => $request->requirements,
|
||||
]);
|
||||
|
||||
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',
|
||||
]);
|
||||
|
||||
$jobPosition->update([
|
||||
'title' => $request->title,
|
||||
'description' => $request->description,
|
||||
'requirements' => $request->requirements,
|
||||
]);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,16 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
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'])]
|
||||
class Candidate extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public function jobPosition(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(JobPosition::class);
|
||||
}
|
||||
|
||||
protected $appends = ['weighted_score'];
|
||||
|
||||
public function getWeightedScoreAttribute(): float
|
||||
|
||||
23
app/Models/JobPosition.php
Normal file
23
app/Models/JobPosition.php
Normal 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'])]
|
||||
class JobPosition extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $casts = [
|
||||
'requirements' => 'array',
|
||||
];
|
||||
|
||||
public function candidates(): HasMany
|
||||
{
|
||||
return $this->hasMany(Candidate::class);
|
||||
}
|
||||
}
|
||||
121
app/Services/AIAnalysisService.php
Normal file
121
app/Services/AIAnalysisService.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?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));
|
||||
return $pdf->getText();
|
||||
} catch (\Exception $e) {
|
||||
Log::error("PDF Extraction Error: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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") . "
|
||||
|
||||
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 = config('services.ollama.url', 'http://localhost:11434/api/generate');
|
||||
|
||||
try {
|
||||
$response = Http::timeout(60)->post($ollamaUrl, [
|
||||
'model' => 'mistral', // or llama3
|
||||
'prompt' => $prompt,
|
||||
'stream' => false,
|
||||
'format' => 'json'
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return json_decode($response->json('response'), true);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("AI Analysis Call Failed: " . $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"
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/tinker": "^3.0",
|
||||
"smalot/pdfparser": "^2.12",
|
||||
"tightenco/ziggy": "^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
|
||||
53
composer.lock
generated
53
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "377f950bdc38b8b812713688e6de1d7d",
|
||||
"content-hash": "6b34d5dd0c12bcfc3d1253f72a392749",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -3433,6 +3433,57 @@
|
||||
},
|
||||
"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",
|
||||
"version": "v7.4.0",
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -56,6 +56,17 @@ const isSidebarOpen = ref(true);
|
||||
<span v-if="isSidebarOpen">Quiz</span>
|
||||
</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
|
||||
:href="route('admin.comparative')"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors"
|
||||
|
||||
@@ -10,12 +10,23 @@ import DangerButton from '@/Components/DangerButton.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
|
||||
const props = defineProps({
|
||||
candidate: Object
|
||||
candidate: Object,
|
||||
jobPositions: Array
|
||||
});
|
||||
|
||||
const page = usePage();
|
||||
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 docForm = useForm({
|
||||
@@ -108,6 +119,31 @@ const updateAnswerScore = (answerId, score) => {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
|
||||
const aiAnalysis = ref(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>
|
||||
|
||||
<template>
|
||||
@@ -149,7 +185,21 @@ const updateAnswerScore = (answerId, score) => {
|
||||
{{ candidate.user.name.charAt(0) }}
|
||||
</div>
|
||||
<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 items-center gap-3 p-3 bg-slate-50 dark:bg-slate-900 rounded-xl">
|
||||
@@ -390,6 +440,106 @@ const updateAnswerScore = (answerId, score) => {
|
||||
</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>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8">
|
||||
<h3 class="text-xl font-bold mb-8 flex items-center justify-between">
|
||||
Historique des Tests
|
||||
|
||||
200
resources/js/Pages/Admin/JobPositions/Index.vue
Normal file
200
resources/js/Pages/Admin/JobPositions/Index.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<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: []
|
||||
});
|
||||
|
||||
const openModal = (position = null) => {
|
||||
editingPosition.value = position;
|
||||
if (position) {
|
||||
form.title = position.title;
|
||||
form.description = position.description;
|
||||
form.requirements = position.requirements || [];
|
||||
} 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>
|
||||
<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>
|
||||
@@ -74,10 +74,13 @@ Route::middleware('auth')->group(function () {
|
||||
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}/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::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('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::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');
|
||||
|
||||
Reference in New Issue
Block a user