From 878f4bb10202df0cdeb6dbb533bbd9743d842624 Mon Sep 17 00:00:00 2001 From: jeremy bayse Date: Sun, 22 Mar 2026 22:22:45 +0100 Subject: [PATCH] AI Analysis: JobPosition infrastructure and candidate association --- app/Http/Controllers/CandidateController.php | 19 +- .../Controllers/JobPositionController.php | 73 +++++++ app/Models/Candidate.php | 7 +- app/Models/JobPosition.php | 23 ++ composer.json | 1 + composer.lock | 53 ++++- ...3_22_211742_create_job_positions_table.php | 30 +++ ...dd_job_position_id_to_candidates_table.php | 28 +++ resources/js/Layouts/AdminLayout.vue | 11 + resources/js/Pages/Admin/Candidates/Show.vue | 29 ++- .../js/Pages/Admin/JobPositions/Index.vue | 200 ++++++++++++++++++ routes/web.php | 2 + 12 files changed, 470 insertions(+), 6 deletions(-) create mode 100644 app/Http/Controllers/JobPositionController.php create mode 100644 app/Models/JobPosition.php create mode 100644 database/migrations/2026_03_22_211742_create_job_positions_table.php create mode 100644 database/migrations/2026_03_22_211816_add_job_position_id_to_candidates_table.php create mode 100644 resources/js/Pages/Admin/JobPositions/Index.vue diff --git a/app/Http/Controllers/CandidateController.php b/app/Http/Controllers/CandidateController.php index ada4073..5a4d8e9 100644 --- a/app/Http/Controllers/CandidateController.php +++ b/app/Http/Controllers/CandidateController.php @@ -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); diff --git a/app/Http/Controllers/JobPositionController.php b/app/Http/Controllers/JobPositionController.php new file mode 100644 index 0000000..a5896e9 --- /dev/null +++ b/app/Http/Controllers/JobPositionController.php @@ -0,0 +1,73 @@ +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); + } + } +} diff --git a/app/Models/Candidate.php b/app/Models/Candidate.php index feb1d95..894027c 100644 --- a/app/Models/Candidate.php +++ b/app/Models/Candidate.php @@ -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 diff --git a/app/Models/JobPosition.php b/app/Models/JobPosition.php new file mode 100644 index 0000000..af40e07 --- /dev/null +++ b/app/Models/JobPosition.php @@ -0,0 +1,23 @@ + 'array', + ]; + + public function candidates(): HasMany + { + return $this->hasMany(Candidate::class); + } +} diff --git a/composer.json b/composer.json index 7df41fd..72f549e 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/composer.lock b/composer.lock index 89517e6..61b79ff 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/database/migrations/2026_03_22_211742_create_job_positions_table.php b/database/migrations/2026_03_22_211742_create_job_positions_table.php new file mode 100644 index 0000000..4054dce --- /dev/null +++ b/database/migrations/2026_03_22_211742_create_job_positions_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('title'); + $table->text('description'); + $table->json('requirements')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('job_positions'); + } +}; diff --git a/database/migrations/2026_03_22_211816_add_job_position_id_to_candidates_table.php b/database/migrations/2026_03_22_211816_add_job_position_id_to_candidates_table.php new file mode 100644 index 0000000..cc24cbe --- /dev/null +++ b/database/migrations/2026_03_22_211816_add_job_position_id_to_candidates_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/resources/js/Layouts/AdminLayout.vue b/resources/js/Layouts/AdminLayout.vue index cd6b44f..76857f7 100644 --- a/resources/js/Layouts/AdminLayout.vue +++ b/resources/js/Layouts/AdminLayout.vue @@ -56,6 +56,17 @@ const isSidebarOpen = ref(true); Quiz + + + + + Fiches de Poste + + 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) }}

{{ candidate.user.name }}

-

{{ candidate.user.email }}

+

{{ candidate.user.email }}

+ +
+ + +
diff --git a/resources/js/Pages/Admin/JobPositions/Index.vue b/resources/js/Pages/Admin/JobPositions/Index.vue new file mode 100644 index 0000000..e21587c --- /dev/null +++ b/resources/js/Pages/Admin/JobPositions/Index.vue @@ -0,0 +1,200 @@ + + + diff --git a/routes/web.php b/routes/web.php index 44be4d3..7ef2a25 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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');