diff --git a/.env.example b/.env.example index c0660ea..776f182 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,8 @@ -APP_NAME=Laravel +APP_NAME=Recru.IT +# PRODUCTION: Set to 'production' and set APP_DEBUG=false APP_ENV=local APP_KEY= -APP_DEBUG=true +APP_DEBUG=false APP_URL=http://localhost APP_LOCALE=en @@ -18,7 +19,8 @@ BCRYPT_ROUNDS=12 LOG_CHANNEL=stack LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null -LOG_LEVEL=debug +# PRODUCTION: Use 'error' to avoid exposing sensitive data in logs +LOG_LEVEL=error DB_CONNECTION=sqlite # DB_HOST=127.0.0.1 @@ -29,7 +31,8 @@ DB_CONNECTION=sqlite SESSION_DRIVER=database SESSION_LIFETIME=120 -SESSION_ENCRYPT=false +# SECURITY: Must be 'true' in production to encrypt session data at rest +SESSION_ENCRYPT=true SESSION_PATH=/ SESSION_DOMAIN=null diff --git a/.gitignore b/.gitignore index b71b1ea..192eeae 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ Homestead.json Homestead.yaml Thumbs.db + +# Debug & temporary scripts (never commit these) +fix_*.php +test-*.php +scratch/ diff --git a/app/Http/Controllers/Admin/CandidateExportController.php b/app/Http/Controllers/Admin/CandidateExportController.php new file mode 100644 index 0000000..80df173 --- /dev/null +++ b/app/Http/Controllers/Admin/CandidateExportController.php @@ -0,0 +1,131 @@ +load([ + 'user', + 'jobPosition', + 'tenant', + 'attempts.quiz.questions', + 'attempts.answers.option', + 'attempts.answers.question', + 'documents' + ]); + + $filename = 'Dossier_' . str_replace(' ', '_', $candidate->user->name) . '_' . date('Ymd') . '.pdf'; + + // 1. Generate Main Report with DomPDF + $pdfReport = Pdf::loadView('pdfs.candidate-dossier', [ + 'candidate' => $candidate + ]); + $reportBinary = $pdfReport->output(); + + // 2. Setup FPDI for merging + $mergedPdf = new \setasign\Fpdi\Fpdi(); + + // Add Main Report Pages + $reportTmp = tempnam(sys_get_temp_dir(), 'pdf_report_'); + file_put_contents($reportTmp, $reportBinary); + + try { + $pageCount = $mergedPdf->setSourceFile($reportTmp); + for ($pageNo = 1; $pageNo <= $pageCount; $pageNo++) { + $templateId = $mergedPdf->importPage($pageNo); + $size = $mergedPdf->getTemplateSize($templateId); + $mergedPdf->AddPage($size['orientation'], [$size['width'], $size['height']]); + $mergedPdf->useTemplate($templateId); + } + } catch (\Exception $e) { + \Log::error('FPDI Error on report: ' . $e->getMessage()); + } + @unlink($reportTmp); + + // 3. Append Candidate Documents (CV, Letter) + foreach ($candidate->documents as $doc) { + if (\Storage::disk('local')->exists($doc->file_path)) { + $filePath = \Storage::disk('local')->path($doc->file_path); + + try { + $pageCount = $mergedPdf->setSourceFile($filePath); + for ($pageNo = 1; $pageNo <= $pageCount; $pageNo++) { + $templateId = $mergedPdf->importPage($pageNo); + $size = $mergedPdf->getTemplateSize($templateId); + $mergedPdf->AddPage($size['orientation'], [$size['width'], $size['height']]); + $mergedPdf->useTemplate($templateId); + } + } catch (\Exception $e) { + \Log::warning('Could not merge document ID ' . $doc->id . ': ' . $e->getMessage()); + + // Add a professional placeholder page for unmergable documents + $mergedPdf->AddPage('P', 'A4'); + $mergedPdf->SetFont('Arial', 'B', 16); + $mergedPdf->SetTextColor(0, 79, 130); // Primary color + $mergedPdf->Ln(50); + $mergedPdf->Cell(0, 20, utf8_decode("DOCUMENT JOINT : " . strtoupper($doc->type)), 0, 1, 'C'); + $mergedPdf->SetFont('Arial', 'I', 12); + $mergedPdf->Cell(0, 10, utf8_decode($doc->original_name), 0, 1, 'C'); + + $mergedPdf->Ln(30); + $mergedPdf->SetFont('Arial', '', 11); + $mergedPdf->SetTextColor(100, 100, 100); + $mergedPdf->MultiCell(0, 8, utf8_decode("Ce document n'a pas pu être fusionné automatiquement au dossier car son format est trop récent (PDF 1.5+).\n\nPour garantir l'intégrité de la mise en page, veuillez consulter ce document séparément via l'interface du tableau de bord candidat."), 0, 'C'); + + $mergedPdf->Ln(20); + $mergedPdf->SetDrawColor(224, 176, 76); // Highlight color + $mergedPdf->Line(60, $mergedPdf->GetY(), 150, $mergedPdf->GetY()); + } + } + } + + return response($mergedPdf->Output('S'), 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + 'Cache-Control' => 'no-cache, no-store, must-revalidate', + 'Pragma' => 'no-cache', + 'Expires' => '0', + ]); + } + + public function exportZip(Candidate $candidate) + { + $candidate->load(['user', 'jobPosition', 'tenant', 'attempts.quiz.questions', 'attempts.answers.option', 'attempts.answers.question', 'documents']); + + $baseName = 'Dossier_' . str_replace(' ', '_', $candidate->user->name) . '_' . date('Ymd'); + $zipPath = tempnam(sys_get_temp_dir(), 'candidate_zip_'); + $zip = new \ZipArchive(); + + if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + return back()->with('error', 'Impossible de créer le fichier ZIP.'); + } + + // 1. Add the main report (Rapport CABM) + $pdfReport = Pdf::loadView('pdfs.candidate-dossier', [ + 'candidate' => $candidate + ]); + $zip->addFromString($baseName . '/Rapport_Synthese_CABM.pdf', $pdfReport->output()); + + // 2. Add original documents + foreach ($candidate->documents as $doc) { + if (\Storage::disk('local')->exists($doc->file_path)) { + $content = \Storage::disk('local')->get($doc->file_path); + // Sanitize original name or use type + $ext = pathinfo($doc->original_name, PATHINFO_EXTENSION); + $fileName = strtoupper($doc->type) . '_' . $doc->original_name; + $zip->addFromString($baseName . '/Documents_Originaux/' . $fileName, $content); + } + } + + $zip->close(); + + return response()->download($zipPath, $baseName . '.zip')->deleteFileAfterSend(true); + } +} diff --git a/app/Http/Controllers/AttemptController.php b/app/Http/Controllers/AttemptController.php index a047277..2886f96 100644 --- a/app/Http/Controllers/AttemptController.php +++ b/app/Http/Controllers/AttemptController.php @@ -90,12 +90,23 @@ class AttemptController extends Controller public function saveAnswer(Request $request, Attempt $attempt) { + // Security: Verify the authenticated user owns this attempt + $candidate = auth()->user()->candidate; + if (!$candidate || $attempt->candidate_id !== $candidate->id) { + abort(403, 'You are not authorized to submit answers for this attempt.'); + } + $request->validate([ 'question_id' => 'required|exists:questions,id', 'option_id' => 'nullable|exists:options,id', 'text_content' => 'nullable|string', ]); + // Extra guard: prevent answering a finished attempt + if ($attempt->finished_at) { + return response()->json(['error' => 'This attempt is already finished.'], 403); + } + Answer::updateOrCreate( [ 'attempt_id' => $attempt->id, @@ -112,6 +123,12 @@ class AttemptController extends Controller public function finish(Attempt $attempt) { + // Security: Verify the authenticated user owns this attempt + $candidate = auth()->user()->candidate; + if (!$candidate || $attempt->candidate_id !== $candidate->id) { + abort(403, 'You are not authorized to finish this attempt.'); + } + if ($attempt->finished_at) { return redirect()->route('dashboard'); } diff --git a/app/Http/Controllers/BackupController.php b/app/Http/Controllers/BackupController.php index fba343e..3b8fc7d 100644 --- a/app/Http/Controllers/BackupController.php +++ b/app/Http/Controllers/BackupController.php @@ -11,6 +11,11 @@ class BackupController extends Controller { public function download() { + // Security: Only super admins can download backups containing all tenant data + if (!auth()->user()->isSuperAdmin()) { + abort(403, 'Seuls les super administrateurs peuvent télécharger des sauvegardes.'); + } + $databaseName = env('DB_DATABASE'); $userName = env('DB_USERNAME'); $password = env('DB_PASSWORD'); diff --git a/app/Http/Controllers/CandidateController.php b/app/Http/Controllers/CandidateController.php index 09405fb..e9916c4 100644 --- a/app/Http/Controllers/CandidateController.php +++ b/app/Http/Controllers/CandidateController.php @@ -139,8 +139,20 @@ class CandidateController extends Controller $request->validate([ 'cv' => 'nullable|file|mimes:pdf|max:5120', 'cover_letter' => 'nullable|file|mimes:pdf|max:5120', + 'name' => 'nullable|string|max:255', + 'email' => 'nullable|string|email|max:255|unique:users,email,' . $candidate->user_id, + 'phone' => 'nullable|string|max:255', + 'linkedin_url' => 'nullable|url|max:255', ]); + // Update User info if name or email present + if ($request->has('name') || $request->has('email')) { + $candidate->user->update($request->only(['name', 'email'])); + } + + // Update Candidate info + $candidate->update($request->only(['phone', 'linkedin_url'])); + if ($request->hasFile('cv')) { $this->replaceDocument($candidate, $request->file('cv'), 'cv'); } @@ -149,20 +161,24 @@ class CandidateController extends Controller $this->replaceDocument($candidate, $request->file('cover_letter'), 'cover_letter'); } - return back()->with('success', 'Documents mis à jour avec succès.'); + return back()->with('success', 'Profil mis à jour avec succès.'); } public function updateNotes(Request $request, Candidate $candidate) { $request->validate([ 'notes' => 'nullable|string', + 'interview_details' => 'nullable|array', + 'interview_score' => 'nullable|numeric|min:0|max:30', ]); $candidate->update([ 'notes' => $request->notes, + 'interview_details' => $request->interview_details, + 'interview_score' => $request->has('interview_score') ? $request->interview_score : $candidate->interview_score, ]); - return back()->with('success', 'Notes mises à jour avec succès.'); + return back()->with('success', 'Entretien mis à jour avec succès.'); } public function updateScores(Request $request, Candidate $candidate) diff --git a/app/Models/Candidate.php b/app/Models/Candidate.php index f88c850..1d95730 100644 --- a/app/Models/Candidate.php +++ b/app/Models/Candidate.php @@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use App\Traits\BelongsToTenant; -#[Fillable(['user_id', 'job_position_id', 'phone', 'linkedin_url', 'status', 'is_selected', 'notes', 'cv_score', 'motivation_score', 'interview_score', 'ai_analysis', 'tenant_id'])] +#[Fillable(['user_id', 'job_position_id', 'phone', 'linkedin_url', 'status', 'is_selected', 'notes', 'cv_score', 'motivation_score', 'interview_score', 'interview_details', 'ai_analysis', 'tenant_id'])] class Candidate extends Model { use HasFactory, BelongsToTenant; @@ -31,6 +31,7 @@ class Candidate extends Model protected $casts = [ 'ai_analysis' => 'array', 'is_selected' => 'boolean', + 'interview_details' => 'array', ]; public function jobPosition(): BelongsTo diff --git a/app/Models/JobPosition.php b/app/Models/JobPosition.php index 0d029c0..7e05de9 100644 --- a/app/Models/JobPosition.php +++ b/app/Models/JobPosition.php @@ -9,13 +9,14 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use App\Traits\BelongsToTenant; -#[Fillable(['title', 'description', 'requirements', 'ai_prompt', 'tenant_id'])] +#[Fillable(['title', 'description', 'requirements', 'ai_prompt', 'gemini_cache_id', 'gemini_cache_expires_at', 'tenant_id'])] class JobPosition extends Model { use HasFactory, BelongsToTenant; protected $casts = [ 'requirements' => 'array', + 'gemini_cache_expires_at' => 'datetime', ]; public function candidates(): HasMany diff --git a/app/Services/AIAnalysisService.php b/app/Services/AIAnalysisService.php index 5940a4f..f9eedba 100644 --- a/app/Services/AIAnalysisService.php +++ b/app/Services/AIAnalysisService.php @@ -79,14 +79,16 @@ class AIAnalysisService { $provider = $provider ?: env('AI_DEFAULT_PROVIDER', 'ollama'); - $jobTitle = $candidate->jobPosition->title; - $jobDesc = $candidate->jobPosition->description; - $requirements = implode(", ", $candidate->jobPosition->requirements ?? []); + $job = $candidate->jobPosition; + $jobTitle = $job->title; + $jobDesc = $job->description; + $requirements = implode(", ", $job->requirements ?? []); - $prompt = "Tu es un expert en recrutement technique. Ton rôle est d'analyser le profil d'un candidat pour le poste de '{$jobTitle}'."; + // Static Part: The job context and instructions (Cacheable) + $staticPrompt = "Tu es un expert en recrutement technique spécialisé dans l'infrastructure et la cybersécurité. Ton rôle est d'analyser le profil d'un candidat pour le poste de '{$jobTitle}'."; - if (!$candidate->jobPosition->ai_prompt) { - $prompt .= " Attache une grande importance aux compétences techniques et à l'expérience, mais aussi à la capacité d'intégration et à la motivation. + if (!$job->ai_prompt) { + $staticPrompt .= " Attache une grande importance aux compétences techniques et à l'expérience, mais aussi à la capacité d'intégration et à la motivation. DESCRIPTION DU POSTE: {$jobDesc} @@ -94,11 +96,6 @@ class AIAnalysisService 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 @@ -106,8 +103,7 @@ class AIAnalysisService - gaps: liste des compétences manquantes ou points de vigilance - verdict: une conclusion (Favorable, Très Favorable, Réservé, Défavorable)"; } else { - // Context injection for the custom prompt - $prompt .= " + $staticPrompt .= " CONTEXTE DU POSTE: {$jobDesc} @@ -115,23 +111,23 @@ class AIAnalysisService COMPÉTENCES REQUISES: {$requirements} - CONTENU DU CV DU CANDIDAT: - {$cvText} - - CONTENU DE LA LETTRE DE MOTIVATION: - " . ($letterText ?? "Non fournie") . " - CONSIGNES D'ANALYSE SPÉCIFIQUES: - " . $candidate->jobPosition->ai_prompt; + " . $job->ai_prompt; } - $prompt .= "\n\nRéponds UNIQUEMENT en JSON pur, sans texte avant ou après. Assure-toi que le JSON est valide."; + $staticPrompt .= "\n\nRéponds UNIQUEMENT en JSON pur, sans texte avant ou après. Assure-toi que le JSON est valide."; + + // Dynamic Part: The candidate data (Not cached) + $dynamicPrompt = "CONTENU DU CV DU CANDIDAT:\n{$cvText}\n\nCONTENU DE LA LETTRE DE MOTIVATION:\n" . ($letterText ?? "Non fournie"); + + // Full prompt for providers not using context caching + $fullPrompt = $staticPrompt . "\n\n" . $dynamicPrompt; $analysis = match ($provider) { - 'openai' => $this->callOpenAI($prompt), - 'anthropic' => $this->callAnthropic($prompt), - 'gemini' => $this->callGemini($prompt), - default => $this->callOllama($prompt), + 'openai' => $this->callOpenAI($fullPrompt), + 'anthropic' => $this->callAnthropic($fullPrompt), + 'gemini' => $this->callGemini($dynamicPrompt, $staticPrompt, $job), + default => $this->callOllama($fullPrompt), }; // Normalize keys for frontend compatibility @@ -257,29 +253,122 @@ class AIAnalysisService return $this->getSimulatedAnalysis(); } - protected function callGemini(string $prompt) + protected function callGemini(string $dynamicPrompt, ?string $staticPrompt = null, ?\App\Models\JobPosition $job = null) { $apiKey = env('GEMINI_API_KEY'); if (!$apiKey) return $this->getSimulatedAnalysis(); - try { - $response = Http::timeout(60)->post("https://generativelanguage.googleapis.com/v1/models/gemini-2.5-flash:generateContent?key=" . $apiKey, [ - 'contents' => [['parts' => [['text' => $prompt]]]] - ]); + // Models to try in order (Updated for 2026 models) + $models = [ + 'gemini-3.1-flash-lite-preview', + 'gemini-3-flash-preview', + 'gemini-1.5-flash-latest' + ]; - if ($response->successful()) { - $text = $response->json('candidates.0.content.parts.0.text'); - return json_decode($this->extractJson($text), true); - } else { - Log::error("Gemini API Error: " . $response->status() . " - " . $response->body()); + foreach ($models as $model) { + try { + $version = (str_contains($model, '2.0') || str_contains($model, '3.')) ? 'v1beta' : 'v1'; + $url = "https://generativelanguage.googleapis.com/{$version}/models/{$model}:generateContent?key=" . $apiKey; + + $generationConfig = [ + 'temperature' => 0.2, + 'responseMimeType' => 'application/json' + ]; + + $payload = [ + 'generationConfig' => $generationConfig, + 'contents' => [ + ['role' => 'user', 'parts' => [['text' => $dynamicPrompt]]] + ] + ]; + + // Attempt to use Context Caching if static prompt and job are provided + if ($staticPrompt && $job && $version === 'v1beta') { + $cacheId = $this->getOrCreateContextCache($job, $staticPrompt, $model); + if ($cacheId) { + $payload['cachedContent'] = $cacheId; + // When using cache, the static part is already in the cache + } else { + // Fallback: prepend static part if cache fails + $payload['contents'][0]['parts'][0]['text'] = $staticPrompt . "\n\n" . $dynamicPrompt; + } + } else if ($staticPrompt) { + // Non-cached fallback + $payload['contents'][0]['parts'][0]['text'] = $staticPrompt . "\n\n" . $dynamicPrompt; + } + + $response = Http::timeout(60)->post($url, $payload); + + if ($response->successful()) { + $candidate = $response->json('candidates.0'); + if (isset($candidate['finishReason']) && $candidate['finishReason'] !== 'STOP') { + Log::warning("Gemini warning: Analysis finished with reason " . $candidate['finishReason']); + } + + $text = $candidate['content']['parts'][0]['text'] ?? null; + if ($text) { + $json = $this->extractJson($text); + $decoded = json_decode($json, true); + if ($decoded) return $decoded; + } + } else { + Log::error("Gemini API Error ($model): " . $response->status() . " - " . $response->body()); + } + } catch (\Exception $e) { + Log::error("Gemini Connection Failed ($model): " . $e->getMessage()); } - } catch (\Exception $e) { - Log::error("Gemini Connection Failed: " . $e->getMessage()); } return $this->getSimulatedAnalysis(); } + /** + * Get or create a Gemini Context Cache for a specific Job Position. + */ + protected function getOrCreateContextCache(\App\Models\JobPosition $job, string $staticPrompt, string $model) + { + + if (strlen($staticPrompt) < 120000) { + return null; + } + + // Check if we already have a valid cache for this job + if ($job->gemini_cache_id && $job->gemini_cache_expires_at && $job->gemini_cache_expires_at->isFuture()) { + // Basic verification: the cache is tied to a specific model + // We assume the stored cache is for the primary model + return $job->gemini_cache_id; + } + + $apiKey = env('GEMINI_API_KEY'); + try { + // Create Context Cache (TTL of 1 hour) + $response = Http::timeout(30)->post("https://generativelanguage.googleapis.com/v1beta/cachedContents?key=" . $apiKey, [ + 'model' => "models/{$model}", + 'contents' => [ + ['role' => 'user', 'parts' => [['text' => $staticPrompt]]] + ], + 'ttl' => '3600s' + ]); + + if ($response->successful()) { + $cacheId = $response->json('name'); + $job->update([ + 'gemini_cache_id' => $cacheId, + 'gemini_cache_expires_at' => now()->addHour() + ]); + return $cacheId; + } + + // Log l'erreur pour comprendre pourquoi le cache a été refusé + Log::warning("Gemini Cache Refused: " . $response->body()); + + } catch (\Exception $e) { + Log::error("Gemini Cache Lifecycle Error: " . $e->getMessage()); + } + + return null; + } + private function extractJson($string) { preg_match('/\{.*\}/s', $string, $matches); diff --git a/app/Traits/BelongsToTenant.php b/app/Traits/BelongsToTenant.php index 85cde01..811a42a 100644 --- a/app/Traits/BelongsToTenant.php +++ b/app/Traits/BelongsToTenant.php @@ -18,12 +18,9 @@ trait BelongsToTenant return; } - // Candidates don't have a tenant_id but must access - // quizzes/job positions linked to their position - if ($user->role === 'candidate') { - return; - } - + // All other users (admins and candidates) are filtered by their tenant. + // This includes candidates, who must only see data from their own organization. + // Resources with a null tenant_id are considered global and always visible. if ($user->tenant_id) { $builder->where(function ($query) use ($user) { $query->where('tenant_id', $user->tenant_id) diff --git a/composer.json b/composer.json index 72f549e..2aac46c 100644 --- a/composer.json +++ b/composer.json @@ -7,11 +7,15 @@ "license": "MIT", "require": { "php": "^8.3", + "barryvdh/laravel-dompdf": "^3.1", + "fpdf/fpdf": "^1.86", "inertiajs/inertia-laravel": "^2.0", "laravel/framework": "^13.0", "laravel/sanctum": "^4.0", "laravel/tinker": "^3.0", + "setasign/fpdi": "2.6", "smalot/pdfparser": "^2.12", + "tecnickcom/tcpdf": "^6.11", "tightenco/ziggy": "^2.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 61b79ff..5c35d01 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,85 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6b34d5dd0c12bcfc3d1253f72a392749", + "content-hash": "d92de938914aa91aa69bd500464d10d5", "packages": [ + { + "name": "barryvdh/laravel-dompdf", + "version": "v3.1.2", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-dompdf.git", + "reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/ee3b72b19ccdf57d0243116ecb2b90261344dedc", + "reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc", + "shasum": "" + }, + "require": { + "dompdf/dompdf": "^3.0", + "illuminate/support": "^9|^10|^11|^12|^13.0", + "php": "^8.1" + }, + "require-dev": { + "larastan/larastan": "^2.7|^3.0", + "orchestra/testbench": "^7|^8|^9.16|^10|^11.0", + "phpro/grumphp": "^2.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "PDF": "Barryvdh\\DomPDF\\Facade\\Pdf", + "Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf" + }, + "providers": [ + "Barryvdh\\DomPDF\\ServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Barryvdh\\DomPDF\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "A DOMPDF Wrapper for Laravel", + "keywords": [ + "dompdf", + "laravel", + "pdf" + ], + "support": { + "issues": "https://github.com/barryvdh/laravel-dompdf/issues", + "source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.2" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2026-02-21T08:51:10+00:00" + }, { "name": "brick/math", "version": "0.14.8", @@ -377,6 +454,161 @@ ], "time": "2024-02-05T11:56:58+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.1.5", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" + }, + "time": "2026-03-03T13:54:37+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, { "name": "dragonmantank/cron-expression", "version": "v3.6.0", @@ -508,6 +740,59 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "fpdf/fpdf", + "version": "1.86.1", + "source": { + "type": "git", + "url": "https://github.com/coreydoughty/Fpdf.git", + "reference": "2034ab9f7b03b8294933d7fd27828d13963368e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/coreydoughty/Fpdf/zipball/2034ab9f7b03b8294933d7fd27828d13963368e5", + "reference": "2034ab9f7b03b8294933d7fd27828d13963368e5", + "shasum": "" + }, + "require": { + "php": ">=5.6.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "FPDF": "Fpdf\\Fpdf" + } + } + }, + "autoload": { + "psr-4": { + "Fpdf\\": "src/Fpdf" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Corey Doughty", + "email": "corey@doughty.ca" + } + ], + "description": "FPDF Composer Wrapper", + "homepage": "https://github.com/coreydoughty/Fpdf", + "keywords": [ + "fpdf", + "pdf", + "wrapper" + ], + "support": { + "issues": "https://github.com/coreydoughty/Fpdf/issues", + "source": "https://github.com/coreydoughty/Fpdf/tree/1.86.1" + }, + "time": "2025-12-08T14:03:59+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -2158,6 +2443,73 @@ ], "time": "2026-03-08T20:05:35+00:00" }, + { + "name": "masterminds/html5", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + }, + "time": "2025-07-25T09:04:22+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -3433,6 +3785,158 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.3.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.32 || 2.1.32", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7", + "phpunit/phpunit": "8.5.52", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.2.8", + "rector/type-perfect": "1.0.0 || 2.1.0", + "squizlabs/php_codesniffer": "4.0.1", + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.4.x-dev" + } + }, + "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0" + }, + "time": "2026-03-03T17:31:43+00:00" + }, + { + "name": "setasign/fpdi", + "version": "v2.6.0", + "source": { + "type": "git", + "url": "https://github.com/Setasign/FPDI.git", + "reference": "a6db878129ec6c7e141316ee71872923e7f1b7ad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Setasign/FPDI/zipball/a6db878129ec6c7e141316ee71872923e7f1b7ad", + "reference": "a6db878129ec6c7e141316ee71872923e7f1b7ad", + "shasum": "" + }, + "require": { + "ext-zlib": "*", + "php": "^5.6 || ^7.0 || ^8.0" + }, + "conflict": { + "setasign/tfpdf": "<1.31" + }, + "require-dev": { + "phpunit/phpunit": "~5.7", + "setasign/fpdf": "~1.8.6", + "setasign/tfpdf": "~1.33", + "squizlabs/php_codesniffer": "^3.5", + "tecnickcom/tcpdf": "~6.2" + }, + "suggest": { + "setasign/fpdf": "FPDI will extend this class but as it is also possible to use TCPDF or tFPDF as an alternative. There's no fixed dependency configured." + }, + "type": "library", + "autoload": { + "psr-4": { + "setasign\\Fpdi\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Slabon", + "email": "jan.slabon@setasign.com", + "homepage": "https://www.setasign.com" + }, + { + "name": "Maximilian Kresse", + "email": "maximilian.kresse@setasign.com", + "homepage": "https://www.setasign.com" + } + ], + "description": "FPDI is a collection of PHP classes facilitating developers to read pages from existing PDF documents and use them as templates in FPDF. Because it is also possible to use FPDI with TCPDF, there are no fixed dependencies defined. Please see suggestions for packages which evaluates the dependencies automatically.", + "homepage": "https://www.setasign.com/fpdi", + "keywords": [ + "fpdf", + "fpdi", + "pdf" + ], + "support": { + "issues": "https://github.com/Setasign/FPDI/issues", + "source": "https://github.com/Setasign/FPDI/tree/v2.6.0" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/setasign/fpdi", + "type": "tidelift" + } + ], + "time": "2023-12-11T16:03:32+00:00" + }, { "name": "smalot/pdfparser", "version": "v2.12.4", @@ -5985,6 +6489,220 @@ ], "time": "2026-02-15T10:53:20+00:00" }, + { + "name": "tecnickcom/tcpdf", + "version": "6.11.2", + "source": { + "type": "git", + "url": "https://github.com/tecnickcom/TCPDF.git", + "reference": "e1e2ade18e574e963473f53271591edd8c0033ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tecnickcom/TCPDF/zipball/e1e2ade18e574e963473f53271591edd8c0033ec", + "reference": "e1e2ade18e574e963473f53271591edd8c0033ec", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "php": ">=7.1.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "config", + "include", + "tcpdf.php", + "tcpdf_barcodes_1d.php", + "tcpdf_barcodes_2d.php", + "include/tcpdf_colors.php", + "include/tcpdf_filters.php", + "include/tcpdf_font_data.php", + "include/tcpdf_fonts.php", + "include/tcpdf_images.php", + "include/tcpdf_static.php", + "include/barcodes/datamatrix.php", + "include/barcodes/pdf417.php", + "include/barcodes/qrcode.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Nicola Asuni", + "email": "info@tecnick.com", + "role": "lead" + } + ], + "description": "TCPDF is a PHP class for generating PDF documents and barcodes.", + "homepage": "http://www.tcpdf.org/", + "keywords": [ + "PDFD32000-2008", + "TCPDF", + "barcodes", + "datamatrix", + "pdf", + "pdf417", + "qrcode" + ], + "support": { + "issues": "https://github.com/tecnickcom/TCPDF/issues", + "source": "https://github.com/tecnickcom/TCPDF/tree/6.11.2" + }, + "funding": [ + { + "url": "https://www.paypal.com/donate/?hosted_button_id=NZUEC5XS8MFBJ", + "type": "custom" + } + ], + "time": "2026-03-03T08:58:10+00:00" + }, + { + "name": "thecodingmachine/safe", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2026-02-04T18:08:13+00:00" + }, { "name": "tightenco/ziggy", "version": "v2.6.2", diff --git a/database/migrations/2026_04_19_070716_add_gemini_cache_to_job_positions_table.php b/database/migrations/2026_04_19_070716_add_gemini_cache_to_job_positions_table.php new file mode 100644 index 0000000..c49f468 --- /dev/null +++ b/database/migrations/2026_04_19_070716_add_gemini_cache_to_job_positions_table.php @@ -0,0 +1,29 @@ +string('gemini_cache_id')->nullable()->after('ai_prompt'); + $table->timestamp('gemini_cache_expires_at')->nullable()->after('gemini_cache_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('job_positions', function (Blueprint $table) { + $table->dropColumn(['gemini_cache_id', 'gemini_cache_expires_at']); + }); + } +}; diff --git a/database/migrations/2026_04_19_081631_add_interview_details_to_candidates_table.php b/database/migrations/2026_04_19_081631_add_interview_details_to_candidates_table.php new file mode 100644 index 0000000..c83a5af --- /dev/null +++ b/database/migrations/2026_04_19_081631_add_interview_details_to_candidates_table.php @@ -0,0 +1,28 @@ +json('interview_details')->nullable()->after('interview_score'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('candidates', function (Blueprint $table) { + $table->dropColumn('interview_details'); + }); + } +}; diff --git a/fix_max_scores.php b/fix_max_scores.php deleted file mode 100644 index be2b6cf..0000000 --- a/fix_max_scores.php +++ /dev/null @@ -1,17 +0,0 @@ -make(Illuminate\Contracts\Console\Kernel::class); -$kernel->bootstrap(); - -use App\Models\Attempt; - -$attempts = Attempt::whereNull('max_score')->get(); -foreach ($attempts as $attempt) { - if ($attempt->quiz) { - $max = $attempt->quiz->questions->sum('points'); - $attempt->update(['max_score' => $max]); - echo "Updated attempt {$attempt->id} with max_score {$max}\n"; - } -} diff --git a/resources/js/Components/PrimaryButton.vue b/resources/js/Components/PrimaryButton.vue index 3bf8eb9..842cc89 100644 --- a/resources/js/Components/PrimaryButton.vue +++ b/resources/js/Components/PrimaryButton.vue @@ -1,6 +1,6 @@ -
-
-

Liste des Candidats

-
-
+
+
+

+
+ Liste des Candidats +

+
+
-
- +
- - - -
- Nom - - - -
- - -
- Email - - - -
- - -
- Structure - - - -
- - -
- Fiche de Poste - - - -
- - -
- Statut - - - -
- - -
- Score /20 - - - -
- - -
- Adéquation IA - - - -
- - Documents - Actions - - - - - - - - - - - -
{{ candidate.user.name }}
-
{{ candidate.phone }}
- - - {{ candidate.user.email }} - - - {{ candidate.tenant ? candidate.tenant.name : 'Aucun' }} - - - {{ candidate.job_position ? candidate.job_position.title : 'Non assigné' }} - - - - {{ candidate.status }} - - - -
-
-
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - -
+ + +
+ Nom +
- - {{ candidate.weighted_score }} +
+
+ Contact + +
+
+
+ Structure + +
+
+
+ Poste Ciblé + +
+
+
+ Statut + +
+
+
+ Score + +
+
+
+ IA Match + +
+
DocsActions
+ + + + + + {{ candidate.user.name }} + +
{{ candidate.phone || 'Pas de numéro' }}
+
+ {{ candidate.user.email }} + + {{ candidate.tenant ? candidate.tenant.name : 'Aucune' }} + + {{ candidate.job_position ? candidate.job_position.title : 'Non assigné' }} + + + {{ candidate.status }} - - -
-
- {{ candidate.ai_analysis.match_score }}% +
+
+ {{ candidate.weighted_score }} / 20
- {{ candidate.ai_analysis.verdict }} - - Non analysé -
-
- -
-
-
- Détails - -
-
- Aucun candidat trouvé. -
+ + +
+
+ {{ candidate.ai_analysis.match_score }}% +
+ {{ candidate.ai_analysis.verdict }} +
+ Non analysé + + +
+ + - +
+ + +
+ + + + +
+ + + + +
+ Aucun candidat trouvé. +
+ + + + +
diff --git a/resources/js/Pages/Admin/Candidates/Show.vue b/resources/js/Pages/Admin/Candidates/Show.vue index badff07..286f246 100644 --- a/resources/js/Pages/Admin/Candidates/Show.vue +++ b/resources/js/Pages/Admin/Candidates/Show.vue @@ -21,10 +21,29 @@ const props = defineProps({ const page = usePage(); const flashSuccess = computed(() => page.props.flash?.success); +const activeTab = ref('overview'); + const positionForm = useForm({ job_position_id: props.candidate.job_position_id || '' }); +const showEditDetailsModal = ref(false); +const detailsForm = useForm({ + name: props.candidate.user.name, + email: props.candidate.user.email, + phone: props.candidate.phone || '', + linkedin_url: props.candidate.linkedin_url || '', +}); + +const updateDetails = () => { + detailsForm.put(route('admin.candidates.update', props.candidate.id), { + preserveScroll: true, + onSuccess: () => { + showEditDetailsModal.value = false; + }, + }); +}; + const updatePosition = () => { positionForm.patch(route('admin.candidates.update-position', props.candidate.id), { preserveScroll: true, @@ -53,8 +72,20 @@ const docForm = useForm({ _method: 'PUT' // For file upload via PUT in Laravel }); +const rawInterviewDetails = props.candidate.interview_details || {}; const notesForm = useForm({ - notes: props.candidate.notes || '' + notes: props.candidate.notes || '', + interview_details: { + questions: rawInterviewDetails.questions || [], + appreciation: rawInterviewDetails.appreciation || 0, + soft_skills: rawInterviewDetails.soft_skills || [ + { name: 'Communication & Pédagogie', score: 0 }, + { name: 'Esprit d\'équipe & Collaboration', score: 0 }, + { name: 'Résolution de problèmes & Logique', score: 0 }, + { name: 'Adaptabilité & Résilience', score: 0 }, + { name: 'Autonomie & Proactivité', score: 0 } + ] + } }); const scoreForm = useForm({ @@ -114,11 +145,6 @@ const updateDocuments = () => { }); }; -const saveNotes = () => { - notesForm.patch(route('admin.candidates.update-notes', props.candidate.id), { - preserveScroll: true, - }); -}; const saveScores = () => { scoreForm.patch(route('admin.candidates.update-scores', props.candidate.id), { @@ -150,12 +176,21 @@ const bestTestScore = computed(() => { return Math.max(...finished.map(a => (a.score / a.max_score) * 20)); }); +// Calculated Soft Skills average +const softSkillsScore = computed(() => { + const skills = notesForm.interview_details.soft_skills || []; + if (skills.length === 0) return 0; + const total = skills.reduce((acc, s) => acc + (parseFloat(s.score) || 0), 0); + return Number((total / skills.length).toFixed(1)); +}); + // Données radar normalisées en % (chaque axe / son max) const radarData = computed(() => ([ Math.round((parseFloat(scoreForm.cv_score) / 20) * 100), Math.round((parseFloat(scoreForm.motivation_score) / 10) * 100), Math.round((parseFloat(scoreForm.interview_score) / 30) * 100), Math.round((bestTestScore.value / 20) * 100), + Math.round((softSkillsScore.value / 10) * 100), // Max is 10 for avg soft skills ])); const buildRadarChart = () => { @@ -172,7 +207,7 @@ const buildRadarChart = () => { radarChartInstance = new Chart(radarCanvasRef.value, { type: 'radar', data: { - labels: ['Analyse CV', 'Lettre Motiv.', 'Entretien', 'Test Technique'], + labels: ['Analyse CV', 'Lettre Motiv.', 'Entretien', 'Test Technique', 'Soft Skills'], datasets: [{ label: 'Profil Candidat (%)', data: radarData.value, @@ -239,7 +274,7 @@ onUnmounted(() => { // Mise à jour du radar quand les scores changent watch( - () => [scoreForm.cv_score, scoreForm.motivation_score, scoreForm.interview_score, bestTestScore.value], + () => [scoreForm.cv_score, scoreForm.motivation_score, scoreForm.interview_score, bestTestScore.value, softSkillsScore.value], () => { if (radarChartInstance) { radarChartInstance.data.datasets[0].data = radarData.value; @@ -247,6 +282,13 @@ watch( } } ); + +// Ré-initialisation du radar lors du switch d'onglet +watch(activeTab, (newTab) => { + if (newTab === 'overview') { + nextTick(() => buildRadarChart()); + } +}); // ────────────────────────────────────────────────────────────────────────────── const aiAnalysis = ref(props.candidate.ai_analysis || null); @@ -254,6 +296,43 @@ const isAnalyzing = ref(false); const selectedProvider = ref(props.ai_config?.default || 'ollama'); const forceAnalysis = ref(false); +// ─── Interview Scoring Logic ─────────────────────────────────────────────────── +const calculatedInterviewScore = computed(() => { + const qScore = (notesForm.interview_details.questions || []).reduce((acc, q) => acc + (parseFloat(q.score) || 0), 0); + const appScore = parseFloat(notesForm.interview_details.appreciation) || 0; + return Math.min(30, qScore + appScore); +}); + +// Auto-populate questions from AI analysis if empty +watch(aiAnalysis, (newVal) => { + if (newVal && newVal.questions_entretien_suggerees && (!notesForm.interview_details.questions || notesForm.interview_details.questions.length === 0)) { + notesForm.interview_details.questions = newVal.questions_entretien_suggerees.map(q => ({ + question: q, + score: 0, + comment: '' + })); + } +}, { immediate: true }); + +// Sync with global score form and auto-save logic +watch(calculatedInterviewScore, (newVal) => { + scoreForm.interview_score = newVal; +}); + +const saveNotes = () => { + notesForm.transform((data) => ({ + ...data, + interview_score: calculatedInterviewScore.value + })).patch(route('admin.candidates.update-notes', props.candidate.id), { + preserveScroll: true, + onSuccess: () => { + // Update raw candidate data to reflect the new score in computed fields if necessary + props.candidate.interview_score = calculatedInterviewScore.value; + props.candidate.interview_details = notesForm.interview_details; + } + }); +}; + // Error Modal state const showErrorModal = ref(false); const modalErrorMessage = ref(""); @@ -310,156 +389,161 @@ const runAI = async () => {
-
- -
- -
-
-
-
- +
- - -
-
- {{ candidate.user.name.charAt(0) }} -
-

{{ candidate.user.name }}

-

{{ candidate.user.email }}

- -
- - -
- - -
- - -

Note: modifie aussi le rattachement de l'utilisateur.

-
- -
-
- - - - {{ candidate.phone || 'Non renseigné' }} + {{ candidate.is_selected ? 'Retenu' : 'Sélectionner' }} + +
+ +
+ + + {{ candidate.user.email }} + + + + {{ candidate.phone }} + + + + LinkedIn + +
+
+ + +
+
+ + +
+ +
+ +
- - - - - LinkedIn Profile -
-
-
- Réinitialiser MDP - Supprimer Compte
+
- -
-

- - - - Documents joints -

-
+ +
+
+ + + + - -
-
Ajouter / Remplacer
-
-
-
- -
-
- -
-
- - - - Mettre à jour les fichiers - - -
-
- -
+ +
@@ -611,21 +695,20 @@ const runAI = async () => { :style="{ width: (bestTestScore / 20 * 100) + '%' }" >
+
- - -

- Chaque axe est normalisé sur 100% par rapport à son barème maximum. -

+ + +

+ Chaque axe est normalisé sur 100% par rapport à son barème maximum. +

+
- -
- - -
+ +
@@ -798,18 +881,6 @@ const runAI = async () => {
- -
-
Préparation d'entretien : Questions suggérées
-
-
-
- {{ idx + 1 }} -
-

{{ q }}

-
-
-
@@ -834,8 +905,157 @@ const runAI = async () => {
- -
+ + +
+ +
+
+
Évaluation de l'entretien
+
+ +
+ Total Entretien + {{ calculatedInterviewScore }} / 30 +
+
+
+ +
+
+
+
+ {{ idx + 1 }} +
+
+ +

{{ q.question }}

+
+
+ +
+
+ +
+ +
+
+
+ + +
+
+
+
+ + +
+
+
Évaluation des Soft Skills
+
+ Moyenne Soft Skills + {{ (notesForm.interview_details.soft_skills.reduce((acc, s) => acc + (parseFloat(s.score) || 0), 0) / Math.max(1, notesForm.interview_details.soft_skills.length)).toFixed(1) }} / 10 +
+
+ +
+
+
+ {{ skill.name }} + + {{ skill.score }} / 10 + +
+ +
+ +
+
+
+
+ + +
+
+
+ +
+ + / 10 +
+
+
+
+
+ Pondération Totale Entretien +
+ {{ calculatedInterviewScore }}/ 30 +
+
+ + Enregistrer l'évaluation + Calcul des scores & profil radar + +
+
+
+
+
+ +

@@ -913,7 +1133,10 @@ const runAI = async () => {

+
+ +

@@ -1027,8 +1250,84 @@ const runAI = async () => { Ce candidat n'a pas encore terminé de test.

-
-
+
+ + +
+
+

+ + + + Gestion des Documents +

+ +
+ Aucun document disponible pour ce candidat. +
+ +
+ +
+ +
+
Ajouter ou Remplacer les documents
+
+
+
+ + +
+
+ + +
+
+ + Enregistrer les nouveaux documents + +
+
+
+
+ @@ -1048,6 +1347,68 @@ const runAI = async () => { + + +
+

Modifier les informations

+ +
+
+
+ + + +
+
+ + + +
+
+ +
+
+ + + +
+
+ + + +
+
+ +
+ Annuler + + Enregistrer les modifications + +
+
+
+
+
diff --git a/resources/js/Pages/Auth/Login.vue b/resources/js/Pages/Auth/Login.vue index 8e906a9..9f48491 100644 --- a/resources/js/Pages/Auth/Login.vue +++ b/resources/js/Pages/Auth/Login.vue @@ -31,69 +31,77 @@ const submit = () => { -
+
-
-
Total Candidats
-
{{ stats.total_candidates }}
+ +
+
Total Candidats
+
{{ stats.total_candidates }}
+
-
-
+ + +
+
-
+
Retenus
-
{{ stats.selected_candidates }}
+
{{ stats.selected_candidates }}
-
-
Tests terminés
-
{{ stats.finished_tests }}
+ + +
+
Tests terminés
+
{{ stats.finished_tests }}
+
-
-
Moyenne Générale
-
{{ stats.average_score }} / 20
+ + +
+
Moyenne Générale
+
{{ stats.average_score }} / 20
+
-
-
Meilleur Score
-
{{ stats.best_score }} / 20
+ + +
+
Meilleur Score
+
{{ stats.best_score }} / 20
+
-
-
-

Top 10 Candidats

- - Voir tous les candidats → +
+
+

+ + Top 10 Candidats +

+ + Voir tous
- - - - - - + + + + + + - - + + - @@ -154,19 +176,19 @@ const getStatusColor = (status) => { - -
+ +
-
+
✦ Espace Candidat
-

- Bienvenue, {{ user.name }} ! +

+ Bienvenue, {{ user.name }} !

-

+

Voici les tests techniques préparés pour votre candidature. Installez-vous confortablement avant de commencer.

@@ -176,34 +198,31 @@ const getStatusColor = (status) => {
-
+
-
- +
+
-

{{ quiz.title }}

-

+

{{ quiz.title }}

+

{{ quiz.description }}

-
+
-
Durée
-
{{ quiz.duration_minutes }} min
+
Durée
+
{{ quiz.duration_minutes }} min
-
- +
+ Terminé @@ -211,9 +230,7 @@ const getStatusColor = (status) => { Démarrer → @@ -222,21 +239,21 @@ const getStatusColor = (status) => {
-
-
- +
+
+
-

Aucun test assigné

-

+

Aucun test assigné

+

Votre dossier est en cours de traitement. Un administrateur vous assignera bientôt vos tests techniques.

-
-

RecruitQuizz Platform • v{{ $page.props.app_version }}

+
+ © {{ new Date().getFullYear() }} — Communauté d'Agglomération Béziers Méditerranée
diff --git a/resources/js/Pages/Welcome.vue b/resources/js/Pages/Welcome.vue index 61581e2..487724d 100644 --- a/resources/js/Pages/Welcome.vue +++ b/resources/js/Pages/Welcome.vue @@ -2,184 +2,185 @@ import { Head, Link } from '@inertiajs/vue3'; defineProps({ - canLogin: Boolean, - canRegister: Boolean, + canLogin: { + type: Boolean, + }, }); diff --git a/resources/views/pdfs/candidate-dossier.blade.php b/resources/views/pdfs/candidate-dossier.blade.php new file mode 100644 index 0000000..ec97294 --- /dev/null +++ b/resources/views/pdfs/candidate-dossier.blade.php @@ -0,0 +1,366 @@ + + + + + Dossier Candidat - {{ $candidate->user->name }} + + + +
+ +
+ +
+
CABM - Dossier de Synthèse
+

{{ $candidate->user->name }}

+
Candidature au poste de : {{ $candidate->jobPosition->title ?? 'Poste non défini' }}
+
+ +
+
+
Score Global Pondéré
+
{{ $candidate->weighted_score }}/20
+
+
+
Contact
+
{{ $candidate->user->email }} @if($candidate->phone) | {{ $candidate->phone }} @endif
+
+
+
Structure
+
{{ $candidate->tenant->name ?? 'N/A' }}
+
+ @if($candidate->linkedin_url) +
+
LinkedIn
+
{{ $candidate->linkedin_url }}
+
+ @endif +
+ + + @if($candidate->ai_analysis) +

Analyse Decisionnelle

+
+ Verdict : {{ $candidate->ai_analysis['verdict'] ?? 'N/A' }} ({{ $candidate->ai_analysis['match_score'] }}%) +
+
+ {{ $candidate->ai_analysis['summary'] }} +
+ +
+
+

Points Forts

+
    + @foreach($candidate->ai_analysis['strengths'] ?? [] as $s) +
  • {{ $s }}
  • + @endforeach +
+
+
+

Points de Vigilance

+
    + @foreach($candidate->ai_analysis['gaps'] ?? [] as $g) +
  • {{ $g }}
  • + @endforeach +
+
+
+ @endif + +
+ + +

Résultats des Tests Techniques

+ @forelse($candidate->attempts as $attempt) +
+

{{ $attempt->quiz->title }} (Fait le {{ $attempt->finished_at->format('d/m/Y H:i') }})

+
Score : {{ $attempt->score }} / {{ $attempt->max_score }}
+ +
CandidatScore PondéréAdéquation IAStatutActions
CandidatScore PondéréAdéquation IAStatutActions
-
{{ candidate.name }}
-
{{ candidate.email }}
+
{{ candidate.name }}
+
{{ candidate.email }}
-
- {{ candidate.weighted_score }} / 20 +
+ {{ candidate.weighted_score }} / 20
{{ candidate.ai_analysis.match_score }}%
- Non analysé + Non analysé
{{ candidate.status }} @@ -133,19 +154,20 @@ const getStatusColor = (status) => { - - +
- Aucun candidat pour le moment. + +
+ Aucun candidat pour le moment. +
+ + + + + + + + @php + $quizQuestions = $attempt->quiz->questions; + $answers = $attempt->answers->keyBy('question_id'); + @endphp + @foreach($quizQuestions as $question) + @php $answer = $answers->get($question->id); @endphp + + + + + @endforeach + +
QuestionRéponse / Score
+ {{ $question->label }} + @if($question->description) +
{{ $question->description }}
+ @endif +
+ @if($answer) + @if($question->type === 'qcm') +
+ {{ $answer->option?->option_text ?? 'N/A' }} + ({{ $answer->option?->is_correct ? 'Correct' : 'Incorrect' }}) +
+ @else +
"{{ $answer->text_content }}"
+
Note : {{ $answer->score }} / {{ $question->points }}
+ @endif + @else + Pas de réponse + @endif +
+
+ @empty +

Aucun test technique effectué.

+ @endforelse + +
+ + +

Grille d'Évaluation (Entretien)

+

Support pour prise de notes manuelle durant l'échange. Échelle de 0 à 10.

+ +

1. Compétences Métier & Pré-requis

+ + + + + @for($i=0; $i<=10; $i++) @endfor + + + + @php + $requirements = $candidate->jobPosition->requirements ?? ['Compétences techniques générales', 'Expérience domaine', 'Outils & Méthodes']; + @endphp + @foreach($requirements as $req) + + + @for($i=0; $i<=10; $i++) @endfor + + @endforeach + +
Critères{{ $i }}
{{ $req }}
+ +
+ +
+
+ +

2. Savoir être & Adaptabilité

+ + + + + @for($i=0; $i<=10; $i++) @endfor + + + + @php + $softSkills = [ + 'Communication & Pédagogie', + 'Esprit d\'équipe & Collaboration', + 'Résolution de problèmes & Logique', + 'Adaptabilité & Résilience', + 'Autonomie & Proactivité' + ]; + @endphp + @foreach($softSkills as $skill) + + + @for($i=0; $i<=10; $i++) @endfor + + @endforeach + +
Personnalité{{ $i }}
{{ $skill }}
+ +
+ +
+
+ +
+ +

3. Questions d'Entretien (Guide)

+ @if($candidate->ai_analysis && !empty($candidate->ai_analysis['questions_entretien_suggerees'])) + @foreach($candidate->ai_analysis['questions_entretien_suggerees'] as $idx => $question) +
+
Q{{ $idx + 1 }}. {{ $question }}
+
+
+ @endforeach + @else +

Aucune question suggérée. Utilisez vos questions standards.

+ @for($i=1; $i<=5; $i++) +
+
+
+
+ @endfor + @endif + +
+
Verdict Final & Avis Client
+
+
+ + FAVORABLE +
+
+ + A REVOIR +
+
+ + DEFAVORABLE +
+
+
+ +
+
+
+
+ + + + diff --git a/routes/auth.php b/routes/auth.php index 3926ecf..ef1472c 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -12,10 +12,6 @@ use App\Http\Controllers\Auth\VerifyEmailController; use Illuminate\Support\Facades\Route; Route::middleware('guest')->group(function () { - Route::get('register', [RegisteredUserController::class, 'create']) - ->name('register'); - - Route::post('register', [RegisteredUserController::class, 'store']); Route::get('login', [AuthenticatedSessionController::class, 'create']) ->name('login'); diff --git a/routes/web.php b/routes/web.php index bcf3390..f08e9be 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,9 +8,6 @@ use Inertia\Inertia; Route::get('/', function () { return Inertia::render('Welcome', [ 'canLogin' => Route::has('login'), - 'canRegister' => Route::has('register'), - 'laravelVersion' => Application::VERSION, - 'phpVersion' => PHP_VERSION, ]); }); @@ -91,6 +88,8 @@ Route::middleware('auth')->group(function () { Route::post('/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::get('/candidates/{candidate}/export-dossier', [\App\Http\Controllers\Admin\CandidateExportController::class, 'exportDossier'])->name('candidates.export-dossier'); + Route::get('/candidates/{candidate}/export-zip', [\App\Http\Controllers\Admin\CandidateExportController::class, 'exportZip'])->name('candidates.export-zip'); 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']); diff --git a/tailwind.config.js b/tailwind.config.js index e5fb6e5..2c5b836 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -14,8 +14,19 @@ export default { theme: { extend: { + colors: { + primary: '#1a4b8c', // Bleu Méditerranée + accent: '#c8102e', // Rouge Occitan + highlight: '#f5a800', // Or du Midi + sky: '#3a7abf', // Bleu ciel + sand: '#e8e0d0', // Sable garrigue + anthracite: '#2d2d2d', // Anthracite + neutral: '#f0ece4', // Fond neutre + }, fontFamily: { - sans: ['Figtree', ...defaultTheme.fontFamily.sans], + sans: ['Helvetica Neue', 'Arial', 'sans-serif'], + serif: ['Merriweather', 'Georgia', 'serif'], + subtitle: ['Nunito', 'Gill Sans', 'sans-serif'], }, }, },