AI Analysis: JobPosition infrastructure and candidate association

This commit is contained in:
jeremy bayse
2026-03-22 22:22:45 +01:00
parent 2df0d6def0
commit 878f4bb102
12 changed files with 470 additions and 6 deletions

View File

@@ -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);

View 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);
}
}
}

View File

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

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'])]
class JobPosition extends Model
{
use HasFactory;
protected $casts = [
'requirements' => 'array',
];
public function candidates(): HasMany
{
return $this->hasMany(Candidate::class);
}
}

View File

@@ -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
View File

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

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

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

View File

@@ -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({
@@ -149,7 +160,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">

View 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>

View File

@@ -74,10 +74,12 @@ 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::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');