feat: implementation des dossiers candidats PDF, gestion des entretiens et optimisation de l'analyse IA

This commit is contained in:
jeremy bayse
2026-04-19 15:35:16 +02:00
parent 4017e3d9c5
commit f3d630d741
27 changed files with 2550 additions and 741 deletions

View File

@@ -1,7 +1,8 @@
APP_NAME=Laravel APP_NAME=Recru.IT
# PRODUCTION: Set to 'production' and set APP_DEBUG=false
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=false
APP_URL=http://localhost APP_URL=http://localhost
APP_LOCALE=en APP_LOCALE=en
@@ -18,7 +19,8 @@ BCRYPT_ROUNDS=12
LOG_CHANNEL=stack LOG_CHANNEL=stack
LOG_STACK=single LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug # PRODUCTION: Use 'error' to avoid exposing sensitive data in logs
LOG_LEVEL=error
DB_CONNECTION=sqlite DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1 # DB_HOST=127.0.0.1
@@ -29,7 +31,8 @@ DB_CONNECTION=sqlite
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false # SECURITY: Must be 'true' in production to encrypt session data at rest
SESSION_ENCRYPT=true
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=null

5
.gitignore vendored
View File

@@ -22,3 +22,8 @@
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
Thumbs.db Thumbs.db
# Debug & temporary scripts (never commit these)
fix_*.php
test-*.php
scratch/

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Candidate;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Request;
class CandidateExportController extends Controller
{
public function exportDossier(Candidate $candidate)
{
$candidate->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);
}
}

View File

@@ -90,12 +90,23 @@ class AttemptController extends Controller
public function saveAnswer(Request $request, Attempt $attempt) 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([ $request->validate([
'question_id' => 'required|exists:questions,id', 'question_id' => 'required|exists:questions,id',
'option_id' => 'nullable|exists:options,id', 'option_id' => 'nullable|exists:options,id',
'text_content' => 'nullable|string', '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( Answer::updateOrCreate(
[ [
'attempt_id' => $attempt->id, 'attempt_id' => $attempt->id,
@@ -112,6 +123,12 @@ class AttemptController extends Controller
public function finish(Attempt $attempt) 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) { if ($attempt->finished_at) {
return redirect()->route('dashboard'); return redirect()->route('dashboard');
} }

View File

@@ -11,6 +11,11 @@ class BackupController extends Controller
{ {
public function download() 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'); $databaseName = env('DB_DATABASE');
$userName = env('DB_USERNAME'); $userName = env('DB_USERNAME');
$password = env('DB_PASSWORD'); $password = env('DB_PASSWORD');

View File

@@ -139,8 +139,20 @@ class CandidateController extends Controller
$request->validate([ $request->validate([
'cv' => 'nullable|file|mimes:pdf|max:5120', 'cv' => 'nullable|file|mimes:pdf|max:5120',
'cover_letter' => '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')) { if ($request->hasFile('cv')) {
$this->replaceDocument($candidate, $request->file('cv'), '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'); $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) public function updateNotes(Request $request, Candidate $candidate)
{ {
$request->validate([ $request->validate([
'notes' => 'nullable|string', 'notes' => 'nullable|string',
'interview_details' => 'nullable|array',
'interview_score' => 'nullable|numeric|min:0|max:30',
]); ]);
$candidate->update([ $candidate->update([
'notes' => $request->notes, '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) public function updateScores(Request $request, Candidate $candidate)

View File

@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use App\Traits\BelongsToTenant; 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 class Candidate extends Model
{ {
use HasFactory, BelongsToTenant; use HasFactory, BelongsToTenant;
@@ -31,6 +31,7 @@ class Candidate extends Model
protected $casts = [ protected $casts = [
'ai_analysis' => 'array', 'ai_analysis' => 'array',
'is_selected' => 'boolean', 'is_selected' => 'boolean',
'interview_details' => 'array',
]; ];
public function jobPosition(): BelongsTo public function jobPosition(): BelongsTo

View File

@@ -9,13 +9,14 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use App\Traits\BelongsToTenant; 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 class JobPosition extends Model
{ {
use HasFactory, BelongsToTenant; use HasFactory, BelongsToTenant;
protected $casts = [ protected $casts = [
'requirements' => 'array', 'requirements' => 'array',
'gemini_cache_expires_at' => 'datetime',
]; ];
public function candidates(): HasMany public function candidates(): HasMany

View File

@@ -79,14 +79,16 @@ class AIAnalysisService
{ {
$provider = $provider ?: env('AI_DEFAULT_PROVIDER', 'ollama'); $provider = $provider ?: env('AI_DEFAULT_PROVIDER', 'ollama');
$jobTitle = $candidate->jobPosition->title; $job = $candidate->jobPosition;
$jobDesc = $candidate->jobPosition->description; $jobTitle = $job->title;
$requirements = implode(", ", $candidate->jobPosition->requirements ?? []); $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) { if (!$job->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. $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: DESCRIPTION DU POSTE:
{$jobDesc} {$jobDesc}
@@ -94,11 +96,6 @@ class AIAnalysisService
COMPÉTENCES REQUISES: COMPÉTENCES REQUISES:
{$requirements} {$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: Fournis une analyse structurée en JSON avec les clés suivantes:
- match_score: note de 0 à 100 - match_score: note de 0 à 100
- summary: résumé de 3-4 phrases sur le profil - 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 - gaps: liste des compétences manquantes ou points de vigilance
- verdict: une conclusion (Favorable, Très Favorable, Réservé, Défavorable)"; - verdict: une conclusion (Favorable, Très Favorable, Réservé, Défavorable)";
} else { } else {
// Context injection for the custom prompt $staticPrompt .= "
$prompt .= "
CONTEXTE DU POSTE: CONTEXTE DU POSTE:
{$jobDesc} {$jobDesc}
@@ -115,23 +111,23 @@ class AIAnalysisService
COMPÉTENCES REQUISES: COMPÉTENCES REQUISES:
{$requirements} {$requirements}
CONTENU DU CV DU CANDIDAT:
{$cvText}
CONTENU DE LA LETTRE DE MOTIVATION:
" . ($letterText ?? "Non fournie") . "
CONSIGNES D'ANALYSE SPÉCIFIQUES: 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) { $analysis = match ($provider) {
'openai' => $this->callOpenAI($prompt), 'openai' => $this->callOpenAI($fullPrompt),
'anthropic' => $this->callAnthropic($prompt), 'anthropic' => $this->callAnthropic($fullPrompt),
'gemini' => $this->callGemini($prompt), 'gemini' => $this->callGemini($dynamicPrompt, $staticPrompt, $job),
default => $this->callOllama($prompt), default => $this->callOllama($fullPrompt),
}; };
// Normalize keys for frontend compatibility // Normalize keys for frontend compatibility
@@ -257,29 +253,122 @@ class AIAnalysisService
return $this->getSimulatedAnalysis(); 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'); $apiKey = env('GEMINI_API_KEY');
if (!$apiKey) return $this->getSimulatedAnalysis(); if (!$apiKey) return $this->getSimulatedAnalysis();
try { // Models to try in order (Updated for 2026 models)
$response = Http::timeout(60)->post("https://generativelanguage.googleapis.com/v1/models/gemini-2.5-flash:generateContent?key=" . $apiKey, [ $models = [
'contents' => [['parts' => [['text' => $prompt]]]] 'gemini-3.1-flash-lite-preview',
]); 'gemini-3-flash-preview',
'gemini-1.5-flash-latest'
];
if ($response->successful()) { foreach ($models as $model) {
$text = $response->json('candidates.0.content.parts.0.text'); try {
return json_decode($this->extractJson($text), true); $version = (str_contains($model, '2.0') || str_contains($model, '3.')) ? 'v1beta' : 'v1';
} else { $url = "https://generativelanguage.googleapis.com/{$version}/models/{$model}:generateContent?key=" . $apiKey;
Log::error("Gemini API Error: " . $response->status() . " - " . $response->body());
$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(); 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) private function extractJson($string)
{ {
preg_match('/\{.*\}/s', $string, $matches); preg_match('/\{.*\}/s', $string, $matches);

View File

@@ -18,12 +18,9 @@ trait BelongsToTenant
return; return;
} }
// Candidates don't have a tenant_id but must access // All other users (admins and candidates) are filtered by their tenant.
// quizzes/job positions linked to their position // This includes candidates, who must only see data from their own organization.
if ($user->role === 'candidate') { // Resources with a null tenant_id are considered global and always visible.
return;
}
if ($user->tenant_id) { if ($user->tenant_id) {
$builder->where(function ($query) use ($user) { $builder->where(function ($query) use ($user) {
$query->where('tenant_id', $user->tenant_id) $query->where('tenant_id', $user->tenant_id)

View File

@@ -7,11 +7,15 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"barryvdh/laravel-dompdf": "^3.1",
"fpdf/fpdf": "^1.86",
"inertiajs/inertia-laravel": "^2.0", "inertiajs/inertia-laravel": "^2.0",
"laravel/framework": "^13.0", "laravel/framework": "^13.0",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"setasign/fpdi": "2.6",
"smalot/pdfparser": "^2.12", "smalot/pdfparser": "^2.12",
"tecnickcom/tcpdf": "^6.11",
"tightenco/ziggy": "^2.0" "tightenco/ziggy": "^2.0"
}, },
"require-dev": { "require-dev": {

720
composer.lock generated
View File

@@ -4,8 +4,85 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "6b34d5dd0c12bcfc3d1253f72a392749", "content-hash": "d92de938914aa91aa69bd500464d10d5",
"packages": [ "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", "name": "brick/math",
"version": "0.14.8", "version": "0.14.8",
@@ -377,6 +454,161 @@
], ],
"time": "2024-02-05T11:56:58+00:00" "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", "name": "dragonmantank/cron-expression",
"version": "v3.6.0", "version": "v3.6.0",
@@ -508,6 +740,59 @@
], ],
"time": "2025-03-06T22:45:56+00:00" "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", "name": "fruitcake/php-cors",
"version": "v1.4.0", "version": "v1.4.0",
@@ -2158,6 +2443,73 @@
], ],
"time": "2026-03-08T20:05:35+00:00" "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", "name": "monolog/monolog",
"version": "3.10.0", "version": "3.10.0",
@@ -3433,6 +3785,158 @@
}, },
"time": "2025-12-14T04:43:48+00:00" "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", "name": "smalot/pdfparser",
"version": "v2.12.4", "version": "v2.12.4",
@@ -5985,6 +6489,220 @@
], ],
"time": "2026-02-15T10:53:20+00:00" "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", "name": "tightenco/ziggy",
"version": "v2.6.2", "version": "v2.6.2",

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('job_positions', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('candidates', function (Blueprint $table) {
$table->json('interview_details')->nullable()->after('interview_score');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('candidates', function (Blueprint $table) {
$table->dropColumn('interview_details');
});
}
};

View File

@@ -1,17 +0,0 @@
<?php
require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
$kernel = $app->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";
}
}

View File

@@ -1,6 +1,6 @@
<template> <template>
<button <button
class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 active:bg-gray-900" class="inline-flex items-center justify-center rounded-xl border border-transparent bg-highlight px-6 py-3 font-subtitle text-xs font-bold uppercase tracking-widest text-[#3a2800] shadow-md shadow-highlight/20 transition-all duration-300 ease-in-out hover:-translate-y-0.5 hover:brightness-110 hover:shadow-lg hover:shadow-highlight/30 focus:outline-none focus:ring-2 focus:ring-highlight/50 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
> >
<slot /> <slot />
</button> </button>

View File

@@ -11,121 +11,133 @@ const isSidebarOpen = ref(true);
<template> <template>
<EnvironmentBanner /> <EnvironmentBanner />
<div class="min-h-screen bg-slate-50 dark:bg-slate-900 flex text-slate-900 dark:text-slate-100"> <div class="min-h-screen bg-sand flex text-anthracite font-sans selection:bg-highlight selection:text-anthracite">
<!-- Sidebar --> <!-- Sidebar -->
<aside <aside
:class="[isSidebarOpen ? 'w-64' : 'w-20']" :class="[isSidebarOpen ? 'w-64' : 'w-20']"
class="hidden md:flex flex-col bg-white dark:bg-slate-800 border-r border-slate-200 dark:border-slate-700 transition-all duration-300" class="hidden md:flex flex-col bg-primary transition-all duration-300 shadow-xl z-20"
> >
<div class="h-16 flex items-center px-6 border-b border-slate-200 dark:border-slate-700"> <div class="h-16 flex items-center px-5 bg-primary/90 border-b border-white/10">
<Link :href="route('dashboard')" class="flex items-center gap-3 overflow-hidden"> <Link :href="route('dashboard')" class="flex items-center gap-3 overflow-hidden ml-1">
<ApplicationLogo class="h-8 w-8 fill-indigo-600" /> <div class="w-8 h-8 bg-highlight rounded flex items-center justify-center shrink-0 shadow-sm shadow-highlight/20">
<span v-if="isSidebarOpen" class="font-bold text-xl tracking-tight whitespace-nowrap">Recrut.IT</span> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#3a2800]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9l-.707.707M12 18v3m4.95-4.95l.707.707M12 3c-4.418 0-8 3.582-8 8 0 2.209.895 4.209 2.343 5.657L12 21l5.657-5.343A7.994 7.994 0 0020 11c0-4.418-3.582-8-8-8z" />
</svg>
</div>
<span v-if="isSidebarOpen" class="font-serif font-black text-xl tracking-tight whitespace-nowrap text-white">RECRU<span class="text-accent italic px-0.5">IT</span></span>
</Link> </Link>
</div> </div>
<nav class="flex-1 py-6 px-3 space-y-1"> <nav class="flex-1 py-6 px-3 space-y-1.5 overflow-y-auto custom-scrollbar">
<Link <Link
:href="route('dashboard')" :href="route('dashboard')"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors" class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('dashboard') ? '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']" :class="[route().current('dashboard') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg> </svg>
<span v-if="isSidebarOpen">Tableau de bord</span> <span v-if="isSidebarOpen" class="truncate">Tableau de bord</span>
</Link> </Link>
<Link <Link
:href="route('admin.candidates.index')" :href="route('admin.candidates.index')"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors" class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('admin.candidates.*') ? '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']" :class="[route().current('admin.candidates.*') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg> </svg>
<span v-if="isSidebarOpen">Candidats</span> <span v-if="isSidebarOpen" class="truncate">Candidats</span>
</Link> </Link>
<Link <Link
:href="route('admin.quizzes.index')" :href="route('admin.quizzes.index')"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors" class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('admin.quizzes.*') ? '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']" :class="[route().current('admin.quizzes.*') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<span v-if="isSidebarOpen">Quiz</span> <span v-if="isSidebarOpen" class="truncate">Quiz</span>
</Link> </Link>
<Link <Link
:href="route('admin.job-positions.index')" :href="route('admin.job-positions.index')"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors" class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
: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']" :class="[route().current('admin.job-positions.*') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" 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" /> <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> </svg>
<span v-if="isSidebarOpen">Fiches de Poste</span> <span v-if="isSidebarOpen" class="truncate">Fiches de Poste</span>
</Link> </Link>
<Link <Link
:href="route('admin.comparative')" :href="route('admin.comparative')"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors" class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('admin.comparative') ? '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']" :class="[route().current('admin.comparative') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg> </svg>
<span v-if="isSidebarOpen">Comparateur</span> <span v-if="isSidebarOpen" class="truncate">Comparateur</span>
</Link> </Link>
<div v-if="$page.props.auth.user.role === 'super_admin'" class="pt-4 pb-2">
<div v-show="isSidebarOpen" class="px-3 text-[10px] font-black uppercase tracking-widest text-white/30">Configuration</div>
<div v-show="!isSidebarOpen" class="h-[1px] w-8 mx-auto bg-white/10"></div>
</div>
<Link <Link
v-if="$page.props.auth.user.role === 'super_admin'" v-if="$page.props.auth.user.role === 'super_admin'"
:href="route('admin.tenants.index')" :href="route('admin.tenants.index')"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors" class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('admin.tenants.*') ? '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']" :class="[route().current('admin.tenants.*') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg> </svg>
<span v-if="isSidebarOpen">Structures</span> <span v-if="isSidebarOpen" class="truncate">Structures</span>
</Link> </Link>
<Link <Link
v-if="$page.props.auth.user.role === 'super_admin'" v-if="$page.props.auth.user.role === 'super_admin'"
:href="route('admin.users.index')" :href="route('admin.users.index')"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors" class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('admin.users.*') ? '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']" :class="[route().current('admin.users.*') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg> </svg>
<span v-if="isSidebarOpen">Équipe SaaS</span> <span v-if="isSidebarOpen" class="truncate">Équipe SaaS</span>
</Link> </Link>
</nav> </nav>
<div class="p-4 border-t border-slate-200 dark:border-slate-700"> <div class="p-4 border-t border-white/10 bg-primary/80">
<button <button
@click="isSidebarOpen = !isSidebarOpen" @click="isSidebarOpen = !isSidebarOpen"
class="flex items-center justify-center w-full h-10 rounded-lg bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors" class="flex items-center justify-center w-full h-10 rounded-lg text-white/50 hover:bg-white/10 hover:text-white transition-all duration-300"
> >
<svg v-if="isSidebarOpen" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg v-if="isSidebarOpen" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg> </svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg> </svg>
</button> </button>
<div v-if="isSidebarOpen" class="mt-4 text-[10px] text-slate-400 text-center font-mono"> <div v-show="isSidebarOpen" class="mt-4 text-[9px] font-bold uppercase tracking-widest text-[#3a7abf] text-center">
v{{ $page.props.app_version }} App v{{ $page.props.app_version }}
</div> </div>
</div> </div>
</aside> </aside>
<!-- Main Content --> <!-- Main Content -->
<div class="flex-1 flex flex-col min-w-0 overflow-hidden"> <div class="flex-1 flex flex-col min-w-0 overflow-hidden bg-neutral">
<header class="h-16 flex items-center justify-between px-8 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700"> <header class="h-16 shrink-0 flex items-center justify-between px-8 bg-white border-b border-anthracite/5 shadow-sm z-10 relative">
<div> <div>
<h2 v-if="$slots.header" class="font-semibold text-lg"> <h2 v-if="$slots.header" class="font-serif font-black text-xl text-primary capitalize tracking-tight flex items-center gap-3">
<div class="w-1.5 h-6 bg-highlight rounded-full hidden md:block"></div>
<slot name="header" /> <slot name="header" />
</h2> </h2>
</div> </div>
@@ -133,25 +145,47 @@ const isSidebarOpen = ref(true);
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<Dropdown align="right" width="48"> <Dropdown align="right" width="48">
<template #trigger> <template #trigger>
<button class="flex items-center gap-2 text-sm font-medium hover:text-indigo-600 transition-colors"> <button class="flex items-center gap-2 text-sm font-subtitle font-bold hover:text-primary transition-colors cursor-pointer py-2">
{{ $page.props.auth.user.name }} <div class="w-8 h-8 rounded-full bg-sand flex items-center justify-center text-primary border border-primary/10">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> {{ $page.props.auth.user.name.charAt(0) }}
</div>
<span class="hidden md:block">{{ $page.props.auth.user.name }}</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-anthracite/40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg> </svg>
</button> </button>
</template> </template>
<template #content> <template #content>
<DropdownLink :href="route('profile.edit')">Profil</DropdownLink> <DropdownLink :href="route('profile.edit')">Paramètres du profil</DropdownLink>
<DropdownLink :href="route('admin.backup')" as="a">Sauvegarde App</DropdownLink> <DropdownLink :href="route('admin.backup')" as="a" class="!text-sky font-bold" v-if="$page.props.auth.user.role === 'super_admin'">Sauvegarde Base de données</DropdownLink>
<DropdownLink :href="route('logout')" method="post" as="button">Déconnexion</DropdownLink> <div class="border-t border-anthracite/5 my-1"></div>
<DropdownLink :href="route('logout')" method="post" as="button" class="!text-accent font-bold">Se déconnecter</DropdownLink>
</template> </template>
</Dropdown> </Dropdown>
</div> </div>
</header> </header>
<main class="flex-1 overflow-y-auto p-8"> <main class="flex-1 overflow-y-auto p-4 md:p-8">
<slot /> <slot />
</main> </main>
</div> </div>
</div> </div>
</template> </template>
<style>
/* Firefox scrollbar config for sidebar */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
</style>

View File

@@ -6,19 +6,33 @@ import EnvironmentBanner from '@/Components/EnvironmentBanner.vue';
<template> <template>
<EnvironmentBanner /> <EnvironmentBanner />
<div <div class="flex min-h-screen flex-col items-center justify-center bg-neutral pt-6 sm:pt-0 font-sans text-anthracite selection:bg-highlight selection:text-anthracite">
class="flex min-h-screen flex-col items-center bg-gray-100 pt-6 sm:justify-center sm:pt-0"
>
<div>
<Link href="/">
<ApplicationLogo class="h-20 w-20 fill-current text-gray-500" />
</Link>
</div>
<div <div class="w-full max-w-md px-6">
class="mt-6 w-full overflow-hidden bg-white px-6 py-4 shadow-md sm:max-w-md sm:rounded-lg" <!-- Header and Logo -->
> <div class="mb-8 flex flex-col justify-center items-center gap-4">
<slot /> <Link href="/" class="flex flex-col items-center gap-3 group">
<div class="w-16 h-16 bg-primary rounded-xl flex items-center justify-center shadow-lg shadow-primary/30 group-hover:-translate-y-1 transition-all duration-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9l-.707.707M12 18v3m4.95-4.95l.707.707M12 3c-4.418 0-8 3.582-8 8 0 2.209.895 4.209 2.343 5.657L12 21l5.657-5.343A7.994 7.994 0 0020 11c0-4.418-3.582-8-8-8z" />
</svg>
</div>
<span class="text-3xl font-serif font-black tracking-tight text-primary">RECRU<span class="text-accent italic px-1">IT</span></span>
<span class="text-xs font-subtitle uppercase tracking-[0.2em] text-anthracite/50 font-bold mt-1">Espace sécurisé</span>
</Link>
</div>
<!-- Content Card -->
<div class="w-full overflow-hidden bg-white px-8 py-10 shadow-xl shadow-anthracite/5 rounded-3xl border border-anthracite/5">
<slot />
</div>
<!-- Footer Footer -->
<div class="mt-8 text-center">
<Link href="/" class="text-primary hover:text-highlight transition-colors text-sm font-subtitle font-bold">
&larr; Retour à l'accueil
</Link>
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -172,24 +172,26 @@ const batchAnalyze = async () => {
Gestion des Candidats Gestion des Candidats
</template> </template>
<div class="flex justify-between items-end mb-8"> <div class="flex flex-col md:flex-row justify-between items-start md:items-end mb-8 gap-6">
<div class="space-y-4"> <div class="space-y-4 w-full md:w-auto">
<h3 class="text-2xl font-bold">Liste des Candidats</h3> <h3 class="text-3xl font-serif font-black text-primary capitalize tracking-tight flex items-center gap-3">
<div class="flex items-center gap-6"> <div class="w-1.5 h-8 bg-highlight rounded-full"></div>
<div class="flex items-center gap-3 bg-white dark:bg-slate-800 p-2 rounded-xl border border-slate-200 dark:border-slate-700 shadow-sm"> Liste des Candidats
</h3>
<div class="flex flex-col sm:flex-row items-center gap-4">
<div class="flex items-center gap-3 bg-white p-2 rounded-xl border border-anthracite/5 shadow-sm min-w-max">
<label class="flex items-center gap-2 cursor-pointer px-2"> <label class="flex items-center gap-2 cursor-pointer px-2">
<input type="checkbox" v-model="showOnlySelected" class="rounded border-amber-300 text-amber-500 focus:ring-amber-500/20 cursor-pointer"> <input type="checkbox" v-model="showOnlySelected" class="rounded border-highlight/50 text-highlight focus:ring-highlight/20 cursor-pointer">
<span class="text-sm font-bold text-slate-700 dark:text-slate-300">Retenus uniquement</span> <span class="text-xs font-bold text-primary uppercase tracking-widest">Retenus uniquement</span>
</label> </label>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3 w-full sm:w-auto">
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">Filtrer par fiche de poste :</label>
<select <select
v-model="selectedJobPosition" v-model="selectedJobPosition"
class="block w-64 rounded-xl border-slate-300 dark:border-slate-700 dark:bg-slate-900 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" class="block w-full sm:w-72 rounded-xl border-anthracite/10 shadow-sm focus:border-primary focus:ring-primary/20 text-sm font-medium text-anthracite transition-all"
> >
<option value="">Toutes les fiches de poste</option> <option value="">Toutes les fiches de poste</option>
<option value="none"> Non assigné (Candidature Spontanée)</option> <option value="none" class="italic"> Candidature Spontanée</option>
<option v-for="jp in jobPositions" :key="jp.id" :value="jp.id"> <option v-for="jp in jobPositions" :key="jp.id" :value="jp.id">
{{ jp.title }} {{ jp.title }}
</option> </option>
@@ -197,15 +199,15 @@ const batchAnalyze = async () => {
</div> </div>
</div> </div>
</div> </div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4 w-full md:w-auto justify-end">
<div v-if="selectedIds.length > 0" class="flex items-center gap-3 animate-in fade-in slide-in-from-right-4 duration-300"> <div v-if="selectedIds.length > 0" class="flex items-center gap-3 animate-in fade-in slide-in-from-right-4 duration-300">
<span class="text-sm font-bold text-slate-500">{{ selectedIds.length }} sélectionné(s)</span> <span class="text-xs font-black uppercase tracking-widest text-primary/50">{{ selectedIds.length }} sélectionné(s)</span>
<PrimaryButton <PrimaryButton
@click="batchAnalyze" @click="batchAnalyze"
:disabled="isBatchAnalyzing" :disabled="isBatchAnalyzing"
class="!bg-purple-600 hover:!bg-purple-500 !border-none flex items-center gap-2" class="!bg-primary hover:!bg-primary/90 !text-white flex items-center gap-2 shadow-primary/20"
> >
<svg v-if="isBatchAnalyzing" class="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg v-if="isBatchAnalyzing" class="animate-spin h-4 w-4" 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> <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> <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> </svg>
@@ -214,7 +216,7 @@ const batchAnalyze = async () => {
</svg> </svg>
{{ isBatchAnalyzing ? `Analyse ${analysisProgress.current}/${analysisProgress.total}...` : 'Analyse IA groupée' }} {{ isBatchAnalyzing ? `Analyse ${analysisProgress.current}/${analysisProgress.total}...` : 'Analyse IA groupée' }}
</PrimaryButton> </PrimaryButton>
<div class="h-8 w-px bg-slate-200 dark:bg-slate-700 mx-2"></div> <div class="h-8 w-px bg-anthracite/10 mx-2 hidden sm:block"></div>
</div> </div>
<PrimaryButton @click="isModalOpen = true"> <PrimaryButton @click="isModalOpen = true">
Ajouter un Candidat Ajouter un Candidat
@@ -223,211 +225,184 @@ const batchAnalyze = async () => {
</div> </div>
<!-- Flash Messages --> <!-- Flash Messages -->
<div v-if="flashSuccess" class="mb-8 p-6 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-2xl flex items-center gap-4 animate-in fade-in slide-in-from-top-4 duration-500"> <div v-if="flashSuccess" class="mb-8 p-6 bg-emerald-50 border border-emerald-200 rounded-2xl flex items-center gap-4 animate-in fade-in slide-in-from-top-4 duration-500 shadow-sm">
<div class="p-2 bg-emerald-500 rounded-lg text-white"> <div class="p-2 bg-emerald-500 rounded-lg text-white shadow-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg> </svg>
</div> </div>
<div> <div>
<p class="font-bold text-emerald-800 dark:text-emerald-400">Succès !</p> <p class="font-bold text-emerald-800">Succès !</p>
<p class="text-emerald-700 dark:text-emerald-500 text-sm">{{ flashSuccess }}</p> <p class="text-emerald-700 text-sm font-medium">{{ flashSuccess }}</p>
</div> </div>
</div> </div>
<!-- Candidates Table --> <!-- Candidates Table -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden"> <div class="bg-white rounded-3xl shadow-sm border border-anthracite/5 overflow-hidden">
<table class="w-full text-left"> <div class="overflow-x-auto">
<thead class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700"> <table class="w-full text-left border-collapse">
<tr> <thead class="bg-neutral/50 border-b border-anthracite/5">
<th class="w-12 px-6 py-4"> <tr>
<input <th class="w-12 px-8 py-5">
type="checkbox" <input
:checked="selectedIds.length === sortedCandidates.length && sortedCandidates.length > 0" type="checkbox"
@change="toggleSelectAll" :checked="selectedIds.length === sortedCandidates.length && sortedCandidates.length > 0"
class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500/20 cursor-pointer" @change="toggleSelectAll"
> class="rounded border-anthracite/20 text-primary focus:ring-primary/20 cursor-pointer"
</th> >
<th class="w-12 px-6 py-4"></th> </th>
<th @click="sortBy('user.name')" class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-700/50 transition-colors"> <th class="w-12 px-4 py-5"></th>
<div class="flex items-center gap-2"> <th @click="sortBy('user.name')" class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40 cursor-pointer hover:text-primary transition-colors">
Nom <div class="flex items-center gap-2">
<svg v-show="sortKey === 'user.name'" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor"> Nom
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /> <svg v-show="sortKey === 'user.name'" xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
</svg>
</div>
</th>
<th @click="sortBy('user.email')" class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-700/50 transition-colors">
<div class="flex items-center gap-2">
Email
<svg v-show="sortKey === 'user.email'" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</th>
<th @click="sortBy('tenant.name')" v-if="$page.props.auth.user.role === 'super_admin'" class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-700/50 transition-colors">
<div class="flex items-center gap-2">
Structure
<svg v-show="sortKey === 'tenant.name'" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</th>
<th @click="sortBy('job_position.title')" class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-700/50 transition-colors">
<div class="flex items-center gap-2">
Fiche de Poste
<svg v-show="sortKey === 'job_position.title'" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</th>
<th @click="sortBy('status')" class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-700/50 transition-colors">
<div class="flex items-center gap-2">
Statut
<svg v-show="sortKey === 'status'" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</th>
<th @click="sortBy('weighted_score')" class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-700/50 transition-colors">
<div class="flex items-center gap-2">
Score /20
<svg v-show="sortKey === 'weighted_score'" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</th>
<th @click="sortBy('ai_analysis.match_score')" class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-700/50 transition-colors">
<div class="flex items-center gap-2">
Adéquation IA
<svg v-show="sortKey === 'ai_analysis.match_score'" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</th>
<th class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300">Documents</th>
<th class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
<tr v-for="candidate in sortedCandidates" :key="candidate.id" class="hover:bg-slate-50/50 dark:hover:bg-slate-700/30 transition-colors" :class="{ 'bg-indigo-50/30 dark:bg-indigo-900/10': selectedIds.includes(candidate.id) }">
<td class="px-6 py-4">
<input
type="checkbox"
:value="candidate.id"
v-model="selectedIds"
class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500/20 cursor-pointer"
>
</td>
<td class="px-6 py-4">
<button @click="toggleSelection(candidate.id)" class="text-amber-400 hover:text-amber-500 hover:scale-110 transition-transform focus:outline-none" :title="candidate.is_selected ? 'Retirer des retenus' : 'Marquer comme retenu'">
<svg v-if="candidate.is_selected" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-300 hover:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
</td>
<td class="px-6 py-4">
<div class="font-bold text-slate-900 dark:text-white">{{ candidate.user.name }}</div>
<div class="text-[10px] text-slate-500 font-bold uppercase tracking-tight">{{ candidate.phone }}</div>
</td>
<td class="px-6 py-4 text-sm text-slate-600 dark:text-slate-400">
{{ candidate.user.email }}
</td>
<td class="px-6 py-4 text-xs font-bold uppercase tracking-widest text-indigo-600 dark:text-indigo-400" v-if="$page.props.auth.user.role === 'super_admin'">
{{ candidate.tenant ? candidate.tenant.name : 'Aucun' }}
</td>
<td class="px-6 py-4 text-sm font-semibold text-slate-700 dark:text-slate-300">
{{ candidate.job_position ? candidate.job_position.title : 'Non assigné' }}
</td>
<td class="px-6 py-4 text-xs font-bold uppercase tracking-widest">
<span
class="px-3 py-1 rounded-lg"
:class="{
'bg-amber-50 text-amber-700 border border-amber-200 dark:bg-amber-900/20 dark:border-amber-800 dark:text-amber-400': candidate.status === 'en_attente',
'bg-indigo-50 text-indigo-700 border border-indigo-200 dark:bg-indigo-900/20 dark:border-indigo-800 dark:text-indigo-400': candidate.status === 'en_cours',
'bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-900/20 dark:border-emerald-800 dark:text-emerald-400': candidate.status === 'termine'
}"
>
{{ candidate.status }}
</span>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<div class="w-12 h-1.5 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:style="{ width: (candidate.weighted_score / 20) * 100 + '%' }"
:class="{
'bg-emerald-500': candidate.weighted_score >= 14,
'bg-amber-500': candidate.weighted_score >= 10 && candidate.weighted_score < 14,
'bg-rose-500': candidate.weighted_score < 10
}"
></div>
</div> </div>
<span class="font-black text-sm" :class="{ </th>
'text-emerald-600': candidate.weighted_score >= 14, <th @click="sortBy('user.email')" class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40 cursor-pointer hover:text-primary transition-colors">
'text-amber-600': candidate.weighted_score >= 10 && candidate.weighted_score < 14, <div class="flex items-center gap-2">
'text-rose-600': candidate.weighted_score < 10 Contact
}"> <svg v-show="sortKey === 'user.email'" xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
{{ candidate.weighted_score }} </div>
</th>
<th @click="sortBy('tenant.name')" v-if="$page.props.auth.user.role === 'super_admin'" class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40 cursor-pointer hover:text-primary transition-colors">
<div class="flex items-center gap-2">
Structure
<svg v-show="sortKey === 'tenant.name'" xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
</div>
</th>
<th @click="sortBy('job_position.title')" class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40 cursor-pointer hover:text-primary transition-colors">
<div class="flex items-center gap-2">
Poste Ciblé
<svg v-show="sortKey === 'job_position.title'" xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
</div>
</th>
<th @click="sortBy('status')" class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40 cursor-pointer hover:text-primary transition-colors">
<div class="flex items-center gap-2">
Statut
<svg v-show="sortKey === 'status'" xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
</div>
</th>
<th @click="sortBy('weighted_score')" class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40 cursor-pointer hover:text-primary transition-colors">
<div class="flex items-center gap-2">
Score
<svg v-show="sortKey === 'weighted_score'" xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
</div>
</th>
<th @click="sortBy('ai_analysis.match_score')" class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40 cursor-pointer hover:text-primary transition-colors">
<div class="flex items-center gap-2">
IA Match
<svg v-show="sortKey === 'ai_analysis.match_score'" xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
</div>
</th>
<th class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40">Docs</th>
<th class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-anthracite/5">
<tr v-for="candidate in sortedCandidates" :key="candidate.id" class="hover:bg-sand/30 transition-colors group" :class="{ 'bg-primary/5': selectedIds.includes(candidate.id) }">
<td class="px-8 py-5">
<input
type="checkbox"
:value="candidate.id"
v-model="selectedIds"
class="rounded border-anthracite/20 text-primary focus:ring-primary/20 cursor-pointer"
>
</td>
<td class="px-4 py-5 text-center">
<button @click="toggleSelection(candidate.id)" class="text-anthracite/20 hover:text-highlight hover:-translate-y-0.5 transition-all focus:outline-none" :class="{ '!text-highlight drop-shadow-sm scale-110': candidate.is_selected }" :title="candidate.is_selected ? 'Retirer des retenus' : 'Marquer comme retenu'">
<svg v-if="candidate.is_selected" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<svg v-else 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="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
</td>
<td class="px-8 py-5">
<Link :href="route('admin.candidates.show', candidate.id)" class="font-black text-primary group-hover:text-highlight transition-colors block">
{{ candidate.user.name }}
</Link>
<div class="text-[10px] text-anthracite/50 font-bold uppercase tracking-tight mt-0.5">{{ candidate.phone || 'Pas de numéro' }}</div>
</td>
<td class="px-8 py-5 text-xs text-anthracite/70 font-medium">
{{ candidate.user.email }}
</td>
<td class="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-primary/60" v-if="$page.props.auth.user.role === 'super_admin'">
{{ candidate.tenant ? candidate.tenant.name : 'Aucune' }}
</td>
<td class="px-8 py-5 text-xs font-bold text-anthracite">
{{ candidate.job_position ? candidate.job_position.title : 'Non assigné' }}
</td>
<td class="px-8 py-5">
<span
class="px-3 py-1 text-[10px] font-black uppercase tracking-[0.15em] rounded-full"
:class="{
'bg-anthracite/5 text-anthracite/60 border border-anthracite/10': candidate.status === 'en_attente',
'bg-sky/10 text-sky border border-sky/20': candidate.status === 'en_cours',
'bg-emerald-50 text-emerald-700 border border-emerald-200': candidate.status === 'termine',
'bg-accent/10 text-accent border border-accent/20': candidate.status === 'refuse'
}"
>
{{ candidate.status }}
</span> </span>
</div> </td>
</td> <td class="px-8 py-5">
<td class="px-6 py-4"> <div class="inline-flex items-center gap-2 px-3 py-1 bg-primary/5 text-primary rounded-xl font-black text-sm border border-primary/10 shadow-sm">
<div v-if="candidate.ai_analysis" class="flex items-center gap-2"> {{ candidate.weighted_score }} <span class="opacity-50 text-[10px]">/ 20</span>
<div
class="px-2 py-0.5 rounded text-[10px] font-black"
:class="[
candidate.ai_analysis.match_score >= 80 ? 'bg-emerald-100 text-emerald-700' :
candidate.ai_analysis.match_score >= 60 ? 'bg-amber-100 text-amber-700' :
'bg-red-100 text-red-700'
]"
>
{{ candidate.ai_analysis.match_score }}%
</div> </div>
<span class="text-[10px] font-bold text-slate-400 uppercase truncate max-w-[60px]">{{ candidate.ai_analysis.verdict }}</span> </td>
</div> <td class="px-8 py-5">
<span v-else class="text-[10px] text-slate-300 italic">Non analysé</span> <div v-if="candidate.ai_analysis" class="flex items-center gap-2">
</td> <div
<td class="px-6 py-4"> class="px-2 py-0.5 rounded-lg text-[10px] font-black shadow-sm"
<div class="flex gap-2"> :class="[
<button candidate.ai_analysis.match_score >= 80 ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' :
v-for="doc in candidate.documents" candidate.ai_analysis.match_score >= 60 ? 'bg-highlight/10 text-[#3a2800] border border-highlight/30' :
:key="doc.id" 'bg-accent/10 text-accent border border-accent/20'
@click="openPreview(doc)" ]"
class="p-2 bg-slate-100 dark:bg-slate-700 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors" >
:title="doc.type.toUpperCase()" {{ candidate.ai_analysis.match_score }}%
> </div>
<svg v-if="doc.type === 'cv'" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-indigo-600 dark:text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <span class="text-[9px] font-bold text-anthracite/40 uppercase truncate max-w-[60px]" :title="candidate.ai_analysis.verdict">{{ candidate.ai_analysis.verdict }}</span>
<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" /> </div>
</svg> <span v-else class="text-[9px] font-bold uppercase tracking-widest text-anthracite/30 italic">Non analysé</span>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> </td>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> <td class="px-8 py-5">
</svg> <div class="flex gap-1.5">
</button> <button
</div> v-for="doc in candidate.documents"
</td> :key="doc.id"
<td class="px-6 py-4 text-right"> @click="openPreview(doc)"
<div class="flex items-center justify-end gap-3"> class="p-1.5 bg-neutral text-anthracite/40 rounded-lg hover:bg-primary/10 hover:text-primary transition-colors"
<Link :href="route('admin.candidates.show', candidate.id)" class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium">Détails</Link> :title="doc.type.toUpperCase()"
<button @click="deleteCandidate(candidate.id)" class="p-2 text-slate-400 hover:text-red-600 transition-colors" title="Supprimer"> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg v-if="doc.type === 'cv'" 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 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>
<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 v-else 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="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
</svg> </button>
</button> <span v-if="candidate.documents.length === 0" class="text-anthracite/20 text-xs">-</span>
</div> </div>
</td> </td>
</tr> <td class="px-8 py-5 text-right">
<tr v-if="candidates.length === 0"> <div class="flex items-center justify-end gap-2">
<td colspan="8" class="px-6 py-12 text-center text-slate-500 italic"> <Link :href="route('admin.candidates.show', candidate.id)" class="p-2 text-primary/40 hover:text-highlight hover:bg-highlight/10 rounded-xl transition-all" title="Détails">
Aucun candidat trouvé. <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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
</td> </Link>
</tr> <button @click="deleteCandidate(candidate.id)" class="p-2 text-anthracite/20 hover:text-accent hover:bg-accent/10 rounded-xl transition-all" title="Supprimer">
</tbody> <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.5" 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>
</table> </button>
</div>
</td>
</tr>
<tr v-if="candidates.length === 0">
<td colspan="11" class="px-8 py-16 text-center">
<div class="text-anthracite/40 italic font-medium font-subtitle">
Aucun candidat trouvé.
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div> </div>
<!-- Add Candidate Modal --> <!-- Add Candidate Modal -->

View File

@@ -21,10 +21,29 @@ const props = defineProps({
const page = usePage(); const page = usePage();
const flashSuccess = computed(() => page.props.flash?.success); const flashSuccess = computed(() => page.props.flash?.success);
const activeTab = ref('overview');
const positionForm = useForm({ const positionForm = useForm({
job_position_id: props.candidate.job_position_id || '' 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 = () => { const updatePosition = () => {
positionForm.patch(route('admin.candidates.update-position', props.candidate.id), { positionForm.patch(route('admin.candidates.update-position', props.candidate.id), {
preserveScroll: true, preserveScroll: true,
@@ -53,8 +72,20 @@ const docForm = useForm({
_method: 'PUT' // For file upload via PUT in Laravel _method: 'PUT' // For file upload via PUT in Laravel
}); });
const rawInterviewDetails = props.candidate.interview_details || {};
const notesForm = useForm({ 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({ 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 = () => { const saveScores = () => {
scoreForm.patch(route('admin.candidates.update-scores', props.candidate.id), { 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)); 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) // Données radar normalisées en % (chaque axe / son max)
const radarData = computed(() => ([ const radarData = computed(() => ([
Math.round((parseFloat(scoreForm.cv_score) / 20) * 100), Math.round((parseFloat(scoreForm.cv_score) / 20) * 100),
Math.round((parseFloat(scoreForm.motivation_score) / 10) * 100), Math.round((parseFloat(scoreForm.motivation_score) / 10) * 100),
Math.round((parseFloat(scoreForm.interview_score) / 30) * 100), Math.round((parseFloat(scoreForm.interview_score) / 30) * 100),
Math.round((bestTestScore.value / 20) * 100), Math.round((bestTestScore.value / 20) * 100),
Math.round((softSkillsScore.value / 10) * 100), // Max is 10 for avg soft skills
])); ]));
const buildRadarChart = () => { const buildRadarChart = () => {
@@ -172,7 +207,7 @@ const buildRadarChart = () => {
radarChartInstance = new Chart(radarCanvasRef.value, { radarChartInstance = new Chart(radarCanvasRef.value, {
type: 'radar', type: 'radar',
data: { data: {
labels: ['Analyse CV', 'Lettre Motiv.', 'Entretien', 'Test Technique'], labels: ['Analyse CV', 'Lettre Motiv.', 'Entretien', 'Test Technique', 'Soft Skills'],
datasets: [{ datasets: [{
label: 'Profil Candidat (%)', label: 'Profil Candidat (%)',
data: radarData.value, data: radarData.value,
@@ -239,7 +274,7 @@ onUnmounted(() => {
// Mise à jour du radar quand les scores changent // Mise à jour du radar quand les scores changent
watch( 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) { if (radarChartInstance) {
radarChartInstance.data.datasets[0].data = radarData.value; 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); 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 selectedProvider = ref(props.ai_config?.default || 'ollama');
const forceAnalysis = ref(false); 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 // Error Modal state
const showErrorModal = ref(false); const showErrorModal = ref(false);
const modalErrorMessage = ref(""); const modalErrorMessage = ref("");
@@ -310,156 +389,161 @@ const runAI = async () => {
</div> </div>
</div> </div>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-8"> <div class="space-y-8">
<!-- Sidebar: Profile & Docs --> <!-- Hero Header (En-tête de Profil) -->
<div class="space-y-8"> <div class="bg-white rounded-3xl shadow-sm border border-anthracite/5 overflow-visible z-10 sticky top-0 md:top-4">
<!-- Profile Card --> <div class="h-16 md:h-20 bg-primary rounded-t-3xl relative overflow-hidden flex items-center px-8 relative">
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden"> <div class="absolute inset-0 bg-[url('https://www.mediterranee-agglo.fr/sites/default/files/images/banniere-CABM-3.jpg')] opacity-10 bg-cover bg-center mix-blend-overlay"></div>
<div class="h-24 bg-gradient-to-r from-indigo-500 to-purple-600"></div> <!-- Actions globales alignées à droite et stylées Or du midi -->
<div class="px-6 pb-6 text-center -mt-12 relative"> <div class="ml-auto relative z-10 flex flex-wrap items-center justify-end gap-3 pt-2">
<div class="absolute right-6 top-16 right-0 text-center w-full max-w-[50px] ml-auto mr-auto sm:right-6 sm:top-14 sm:w-auto"> <a :href="route('admin.candidates.export-dossier', candidate.id)" class="px-4 py-1.5 bg-[#e0b04c] text-[#3a2800] rounded-xl text-[10px] uppercase font-black font-subtitle flex items-center gap-2 hover:bg-[#e0b04c]/80 transition-all shadow-lg active:scale-95" title="Télécharger le rapport de synthèse">
<button <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@click="toggleSelection" <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" />
class="flex flex-col items-center gap-1 group focus:outline-none" </svg>
:title="candidate.is_selected ? 'Retirer des retenus' : 'Marquer pour entretien'" Rapport (PDF)
> </a>
<div <a :href="route('admin.candidates.export-zip', candidate.id)" class="px-4 py-1.5 bg-[#e0b04c] text-[#3a2800] rounded-xl text-[10px] uppercase font-black font-subtitle flex items-center gap-2 hover:bg-[#e0b04c]/80 transition-all shadow-lg active:scale-95" title="Télécharger le dossier complet avec originaux">
class="p-2 rounded-full transition-all" <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
:class="candidate.is_selected ? 'bg-amber-100 text-amber-500 shadow-sm' : 'bg-slate-100 text-slate-400 group-hover:bg-amber-50 group-hover:text-amber-400'" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
Dossier Complet (ZIP)
</a>
<SecondaryButton @click="resetPassword" class="!px-3 !py-1 text-[10px] uppercase font-bold tracking-widest !bg-white/10 !border-none !text-white hover:!bg-white/20">Réinitialiser MDP</SecondaryButton>
<DangerButton @click="deleteCandidate" class="!px-3 !py-1 text-[10px] uppercase font-bold tracking-widest !bg-accent hover:!bg-accent/80 !border-none">Supprimer</DangerButton>
</div>
</div>
<div class="px-6 md:px-8 pb-6 flex flex-col md:flex-row gap-6 relative">
<!-- Avatar flottant -->
<div class="w-24 h-24 md:w-32 md:h-32 bg-white rounded-3xl shadow-xl border-4 border-white flex items-center justify-center text-4xl md:text-5xl font-serif font-black text-primary -mt-12 md:-mt-16 relative z-10 shrink-0">
{{ candidate.user.name.charAt(0) }}
</div>
<!-- Infos Principales -->
<div class="flex-1 pt-2 md:pt-4 flex flex-col md:flex-row justify-between gap-6">
<div class="space-y-2">
<div class="flex items-center gap-3">
<h3 class="text-2xl md:text-3xl font-serif font-black text-primary">{{ candidate.user.name }}</h3>
<button @click="showEditDetailsModal = true" class="text-anthracite/20 hover:text-highlight transition-colors bg-neutral/50 p-1.5 rounded-lg">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
@click="toggleSelection"
class="flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-subtitle uppercase tracking-[0.2em] transition-all ml-2 border"
:class="candidate.is_selected ? 'bg-highlight/10 text-[#3a2800] border-highlight/30' : 'bg-neutral text-anthracite/40 border-anthracite/5 hover:border-highlight hover:text-highlight'"
:title="candidate.is_selected ? 'Retirer des retenus' : 'Marquer pour entretien'"
> >
<svg v-if="candidate.is_selected" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor"> <svg v-if="candidate.is_selected" xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /> <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg> </svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg v-else xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg> </svg>
</div> {{ candidate.is_selected ? 'Retenu' : 'Sélectionner' }}
<span class="text-[9px] font-black uppercase tracking-widest hidden sm:block" :class="candidate.is_selected ? 'text-amber-500' : 'text-slate-400 group-hover:text-amber-400'">Retenu</span> </button>
</button> </div>
</div>
<div class="w-24 h-24 bg-white dark:bg-slate-900 rounded-2xl shadow-xl border-4 border-white dark:border-slate-800 flex items-center justify-center text-4xl font-black text-indigo-600 mx-auto mb-4"> <div class="flex flex-wrap items-center gap-4 text-xs font-medium text-anthracite/60 font-subtitle">
{{ candidate.user.name.charAt(0) }} <span class="flex items-center gap-1.5">
</div> <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
<h3 class="text-xl font-bold">{{ candidate.user.name }}</h3> {{ candidate.user.email }}
<p class="text-slate-500 text-sm mb-4">{{ candidate.user.email }}</p> </span>
<span v-if="candidate.phone" class="flex items-center gap-1.5 relative before:content-['•'] before:absolute before:-left-3 before:text-anthracite/20 ml-2">
<div class="mb-6"> <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" /></svg>
<label class="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2 block text-left">Poste Cible</label> {{ candidate.phone }}
<select </span>
v-model="positionForm.job_position_id" <a v-if="candidate.linkedin_url" :href="candidate.linkedin_url" target="_blank" class="flex items-center gap-1.5 hover:text-primary transition-colors relative before:content-['•'] before:absolute before:-left-3 before:text-anthracite/20 ml-2">
@change="updatePosition" <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 opacity-50 m-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/></svg>
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" LinkedIn
> </a>
<option value="">Non assigné</option> </div>
<option v-for="pos in jobPositions" :key="pos.id" :value="pos.id"> </div>
{{ pos.title }}
</option> <!-- Sélecteurs (Poste & Structure) -->
</select> <div class="flex flex-col sm:flex-row gap-4 lg:min-w-[400px]">
</div> <div class="flex-1 space-y-1.5">
<label class="text-[9px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40">Fiche de Poste ciblée</label>
<!-- Structure de rattachement (Super Admin only) --> <select
<div v-if="page.props.auth.user.role === 'super_admin'" class="mb-6"> v-model="positionForm.job_position_id"
<label class="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2 block text-left">Structure de Rattachement</label> @change="updatePosition"
<select class="w-full bg-neutral/50 border border-anthracite/5 rounded-xl py-2 px-3 text-xs font-bold text-primary focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all cursor-pointer shadow-sm"
v-model="tenantForm.tenant_id" >
@change="updateTenant" <option value="">Non assigné (Candidature spontanée)</option>
class="w-full bg-slate-50 dark:bg-slate-900 border-none rounded-xl py-2 px-3 text-xs font-bold text-emerald-600 focus:ring-2 focus:ring-emerald-500/20 transition-all cursor-pointer" <option v-for="pos in jobPositions" :key="pos.id" :value="pos.id">
> {{ pos.title }}
<option value="">Aucune structure</option> </option>
<option v-for="tenant in tenants" :key="tenant.id" :value="tenant.id"> </select>
{{ tenant.name }} </div>
</option>
</select> <div v-if="page.props.auth.user.role === 'super_admin'" class="flex-1 space-y-1.5">
<p class="text-[9px] text-slate-400 mt-1 italic text-left">Note: modifie aussi le rattachement de l'utilisateur.</p> <label class="text-[9px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40">Structure (Tenant)</label>
</div> <select
v-model="tenantForm.tenant_id"
<div class="flex flex-col gap-3 text-left"> @change="updateTenant"
<div class="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-900 rounded-xl"> class="w-full bg-neutral/50 border border-anthracite/5 rounded-xl py-2 px-3 text-xs font-bold text-primary focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all cursor-pointer shadow-sm"
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> >
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" /> <option value="">Aucune structure</option>
</svg> <option v-for="tenant in tenants" :key="tenant.id" :value="tenant.id">
<span class="text-sm font-medium">{{ candidate.phone || 'Non renseigné' }}</span> {{ tenant.name }}
</option>
</select>
</div> </div>
<a v-if="candidate.linkedin_url" :href="candidate.linkedin_url" target="_blank" class="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-900 rounded-xl hover:text-indigo-600 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/>
</svg>
<span class="text-sm font-medium">LinkedIn Profile</span>
</a>
</div> </div>
</div>
<div class="px-6 py-4 bg-slate-50 dark:bg-slate-900 border-t border-slate-200 dark:border-slate-700 flex justify-between items-center gap-2">
<SecondaryButton @click="resetPassword" class="!px-3 !py-1 text-[10px] uppercase font-bold tracking-widest">Réinitialiser MDP</SecondaryButton>
<DangerButton @click="deleteCandidate" class="!px-3 !py-1 text-[10px] uppercase font-bold tracking-widest">Supprimer Compte</DangerButton>
</div> </div>
</div> </div>
</div> <!-- Fin Hero Header (387) -->
<!-- Documents Card --> <!-- Tabs Navigation -->
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-6"> <div class="border-t border-anthracite/5 px-6 md:px-8 bg-neutral/30 rounded-b-3xl">
<h4 class="font-bold mb-4 flex items-center gap-2"> <div class="flex items-center gap-8 overflow-x-auto no-scrollbar">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
Documents joints
</h4>
<div class="space-y-3">
<button <button
v-for="doc in candidate.documents" @click="activeTab = 'overview'"
:key="doc.id" class="px-4 py-4 text-xs font-subtitle uppercase tracking-widest transition-all relative whitespace-nowrap"
@click="openPreview(doc)" :class="activeTab === 'overview' ? 'text-primary font-black' : 'text-anthracite/40 hover:text-primary/70 font-bold'"
class="w-full flex items-center justify-between p-4 bg-slate-100 dark:bg-slate-900 rounded-xl hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors group"
> >
<div class="flex items-center gap-3"> Vue d'ensemble
<div class="p-2 bg-white dark:bg-slate-800 rounded-lg group-hover:bg-indigo-500 group-hover:text-white transition-colors"> <div v-if="activeTab === 'overview'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full"></div>
<svg v-if="doc.type === 'cv'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> </button>
<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" /> <button
</svg> @click="activeTab = 'ai_analysis'"
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> class="px-4 py-4 text-xs font-subtitle uppercase tracking-widest transition-all relative whitespace-nowrap"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> :class="activeTab === 'ai_analysis' ? 'text-primary font-black' : 'text-anthracite/40 hover:text-primary/70 font-bold'"
</svg> >
</div> Analyse IA
<div class="text-left"> <div v-if="activeTab === 'ai_analysis'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full"></div>
<div class="text-sm font-bold uppercase tracking-tight">{{ doc.type }}</div> </button>
<div class="text-[10px] text-slate-500 truncate max-w-[150px]">{{ doc.original_name }}</div> <button
</div> @click="activeTab = 'interview'"
</div> class="px-4 py-4 text-xs font-subtitle uppercase tracking-widest transition-all relative whitespace-nowrap"
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-400 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor"> :class="activeTab === 'interview' ? 'text-primary font-black' : 'text-anthracite/40 hover:text-primary/70 font-bold'"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> >
</svg> Évaluation & Entretien
<div v-if="activeTab === 'interview'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full"></div>
</button>
<button
@click="activeTab = 'documents'"
class="px-4 py-4 text-xs font-subtitle uppercase tracking-widest transition-all relative whitespace-nowrap flex items-center gap-2"
:class="activeTab === 'documents' ? 'text-primary font-black' : 'text-anthracite/40 hover:text-primary/70 font-bold'"
>
Documents
<span class="px-1.5 py-0.5 bg-anthracite/5 rounded text-[9px] min-w-[20px] text-center" :class="{ 'bg-primary/10 text-primary': activeTab === 'documents' }">{{ candidate.documents?.length || 0 }}</span>
<div v-if="activeTab === 'documents'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full"></div>
</button>
<button
@click="activeTab = 'tests'"
class="px-4 py-4 text-xs font-subtitle uppercase tracking-widest transition-all relative whitespace-nowrap"
:class="activeTab === 'tests' ? 'text-primary font-black' : 'text-anthracite/40 hover:text-primary/70 font-bold'"
>
Tests
<div v-if="activeTab === 'tests'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full"></div>
</button> </button>
<div class="pt-6 mt-6 border-t border-slate-100 dark:border-slate-700">
<h5 class="text-xs font-black uppercase text-slate-400 tracking-widest mb-4">Ajouter / Remplacer</h5>
<form @submit.prevent="updateDocuments" class="space-y-4">
<div class="grid grid-cols-2 gap-2">
<div class="relative group/file">
<label class="flex flex-col items-center justify-center p-3 bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl cursor-pointer hover:border-indigo-500 transition-colors">
<span class="text-[10px] font-bold uppercase tracking-tight text-slate-500">CV (PDF)</span>
<span class="text-[9px] text-slate-400 truncate w-full text-center mt-1">{{ docForm.cv ? docForm.cv.name : 'Choisir...' }}</span>
<input type="file" class="hidden" @input="docForm.cv = $event.target.files[0]" accept="application/pdf" />
</label>
</div>
<div class="relative group/file">
<label class="flex flex-col items-center justify-center p-3 bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl cursor-pointer hover:border-emerald-500 transition-colors">
<span class="text-[10px] font-bold uppercase tracking-tight text-slate-500">Lettre (PDF)</span>
<span class="text-[9px] text-slate-400 truncate w-full text-center mt-1">{{ docForm.cover_letter ? docForm.cover_letter.name : 'Choisir...' }}</span>
<input type="file" class="hidden" @input="docForm.cover_letter = $event.target.files[0]" accept="application/pdf" />
</label>
</div>
</div>
<InputError :message="docForm.errors.cv" />
<InputError :message="docForm.errors.cover_letter" />
<PrimaryButton class="w-full !justify-center !py-2 text-xs" :disabled="docForm.processing || (!docForm.cv && !docForm.cover_letter)">
Mettre à jour les fichiers
</PrimaryButton>
</form>
</div>
</div> </div>
</div> </div>
</div>
<!-- Main: Content --> <!-- Tab Content: Overview -->
<div class="xl:col-span-2 space-y-8"> <div v-if="activeTab === 'overview'" class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<!-- Scores Dashboard --> <!-- Scores Dashboard -->
<div class="bg-white dark:bg-slate-800 rounded-3xl shadow-xl border border-slate-200 dark:border-slate-700 overflow-hidden"> <div class="bg-white dark:bg-slate-800 rounded-3xl shadow-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="p-8 bg-gradient-to-br from-slate-900 to-slate-800 text-white flex flex-col md:flex-row md:items-center justify-between gap-8"> <div class="p-8 bg-gradient-to-br from-slate-900 to-slate-800 text-white flex flex-col md:flex-row md:items-center justify-between gap-8">
@@ -611,21 +695,20 @@ const runAI = async () => {
:style="{ width: (bestTestScore / 20 * 100) + '%' }" :style="{ width: (bestTestScore / 20 * 100) + '%' }"
></div> ></div>
</div> </div>
</div>
</div> </div>
<!-- Score footer note -->
<p class="text-[10px] text-slate-400 italic pt-2 border-t border-slate-100 dark:border-slate-700">
Chaque axe est normalisé sur 100% par rapport à son barème maximum.
</p>
</div> </div>
<!-- Score footer note -->
<p class="text-[10px] text-slate-400 italic pt-6 border-t border-slate-100 dark:border-slate-700">
Chaque axe est normalisé sur 100% par rapport à son barème maximum.
</p>
</div> </div>
</div> </div>
</div> <!-- Fin Overview Tab Content -->
<!-- Tab Content: AI Analysis -->
</div> <div v-if="activeTab === 'ai_analysis'" class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<!-- AI Analysis Section (Full Width) -->
<div class="xl:col-span-3 space-y-8">
<div class="bg-white dark:bg-slate-800 rounded-3xl shadow-lg border border-slate-200 dark:border-slate-700 p-10 overflow-hidden relative"> <div class="bg-white dark:bg-slate-800 rounded-3xl shadow-lg border border-slate-200 dark:border-slate-700 p-10 overflow-hidden relative">
<div class="flex flex-col xl:flex-row xl:items-center justify-between gap-8 mb-10 border-b border-slate-100 dark:border-slate-700 pb-8"> <div class="flex flex-col xl:flex-row xl:items-center justify-between gap-8 mb-10 border-b border-slate-100 dark:border-slate-700 pb-8">
<div> <div>
@@ -798,18 +881,6 @@ const runAI = async () => {
</div> </div>
</div> </div>
<!-- Interview Questions -->
<div v-if="aiAnalysis.questions_entretien_suggerees?.length > 0" class="space-y-8">
<h5 class="text-sm font-black uppercase tracking-widest text-indigo-500 border-l-4 border-indigo-500 pl-4">Préparation d'entretien : Questions suggérées</h5>
<div class="grid grid-cols-1 gap-4">
<div v-for="(q, idx) in aiAnalysis.questions_entretien_suggerees" :key="idx" class="flex items-center gap-6 p-6 bg-slate-50 dark:bg-slate-900/30 border border-slate-100 dark:border-slate-800 rounded-3xl group hover:bg-white dark:hover:bg-slate-800 hover:shadow-xl hover:border-indigo-300 transition-all duration-300">
<div class="w-12 h-12 shrink-0 rounded-[1.25rem] bg-white dark:bg-slate-800 flex items-center justify-center text-lg font-black text-indigo-500 shadow-md group-hover:bg-indigo-600 group-hover:text-white transition-all">
{{ idx + 1 }}
</div>
<p class="text-xl font-bold text-slate-700 dark:text-slate-200 leading-snug">{{ q }}</p>
</div>
</div>
</div>
</div> </div>
<div v-else-if="!isAnalyzing" class="py-24 border-4 border-dashed border-slate-100 dark:border-slate-800 rounded-[3rem] text-center bg-slate-50/50 dark:bg-slate-900/20"> <div v-else-if="!isAnalyzing" class="py-24 border-4 border-dashed border-slate-100 dark:border-slate-800 rounded-[3rem] text-center bg-slate-50/50 dark:bg-slate-900/20">
<div class="w-20 h-20 bg-white dark:bg-slate-800 rounded-3xl flex items-center justify-center mx-auto mb-6 shadow-lg"> <div class="w-20 h-20 bg-white dark:bg-slate-800 rounded-3xl flex items-center justify-center mx-auto mb-6 shadow-lg">
@@ -834,8 +905,157 @@ const runAI = async () => {
</div> </div>
</div> </div>
</div> </div>
<!-- Notes Section (Full Width) -->
<div class="xl:col-span-3 space-y-8"> <!-- Tab Content: Interview -->
<div v-if="activeTab === 'interview'" class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<!-- Interview Questions & Interactive Evaluation -->
<div v-if="notesForm.interview_details.questions?.length > 0" class="space-y-10">
<div class="flex items-center justify-between gap-4">
<h5 class="text-sm font-black uppercase tracking-widest text-[#004f82] border-l-4 border-[#004f82] pl-4">Évaluation de l'entretien</h5>
<div class="flex items-center gap-6">
<div class="px-5 py-2 bg-[#004f82]/5 dark:bg-[#004f82]/30 rounded-2xl border border-[#004f82]/20 dark:border-[#004f82]/50 hidden sm:block">
<span class="text-[10px] font-black uppercase text-[#004f82]/80 block tracking-[0.2em]">Score Questions</span>
<span class="text-xl font-black text-[#004f82]">{{ (notesForm.interview_details.questions || []).reduce((acc, q) => acc + (parseFloat(q.score) || 0), 0) }} / 20</span>
</div>
<div class="px-5 py-2 bg-[#e0b04c]/10 dark:bg-[#e0b04c]/30 rounded-2xl border border-[#e0b04c]/30 dark:border-[#e0b04c]/50">
<span class="text-[10px] font-black uppercase text-[#8b6508] block tracking-[0.2em]">Total Entretien</span>
<span class="text-xl font-black text-[#8b6508]">{{ calculatedInterviewScore }} / 30</span>
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-6">
<div v-for="(q, idx) in notesForm.interview_details.questions" :key="idx" class="p-8 bg-neutral/30 dark:bg-slate-900/40 border border-anthracite/5 dark:border-slate-800 rounded-[2.5rem] group hover:bg-white dark:hover:bg-slate-800 hover:shadow-2xl hover:border-primary/20 transition-all duration-500">
<div class="flex items-start gap-6 mb-8 text-left">
<div class="w-12 h-12 shrink-0 rounded-2xl bg-white dark:bg-slate-700 shadow-lg flex items-center justify-center text-primary group-hover:bg-primary group-hover:text-white transition-all duration-300">
<span class="text-xl font-black">{{ idx + 1 }}</span>
</div>
<div class="flex-1">
<input
v-if="!candidate.interview_details"
v-model="q.question"
class="w-full bg-transparent border-none p-0 text-2xl font-black text-anthracite dark:text-slate-100 tracking-tight leading-snug focus:ring-0 placeholder:text-slate-300"
placeholder="Saisissez votre question personnalisée..."
/>
<p v-else class="text-2xl font-black text-anthracite dark:text-slate-100 tracking-tight leading-snug">{{ q.question }}</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
<div class="lg:col-span-3">
<label class="text-[10px] font-black uppercase tracking-[0.2em] text-anthracite/50 mb-4 block">Note Qualité / 4</label>
<div class="flex gap-2">
<button
v-for="score in [0, 1, 2, 3, 4]"
:key="score"
@click="q.score = score"
type="button"
class="w-full h-12 rounded-xl border-2 font-black transition-all"
:class="[
q.score === score
? 'bg-primary border-primary text-white shadow-lg shadow-primary/30 dark:shadow-none'
: 'bg-white dark:bg-slate-900 border-anthracite/10 dark:border-slate-800 text-anthracite/50 hover:border-primary/50'
]"
>
{{ score }}
</button>
</div>
</div>
<div class="lg:col-span-9">
<label class="text-[10px] font-black uppercase tracking-[0.2em] text-anthracite/50 mb-4 block">Commentaires & Détails de l'échange</label>
<textarea
v-model="q.comment"
rows="2"
class="w-full bg-white dark:bg-slate-900/50 border-2 border-transparent focus:border-primary/30 focus:ring-0 rounded-3xl p-6 text-base font-medium transition-all placeholder:text-slate-300"
:placeholder="'Analyse de la réponse pour la question ' + (idx + 1) + '...'"
></textarea>
</div>
</div>
</div>
</div>
<!-- Soft Skills Grid -->
<div class="space-y-10 mt-16 pt-10 border-t-2 border-dashed border-anthracite/10 dark:border-slate-800">
<div class="flex items-center justify-between gap-4 mb-8">
<h5 class="text-sm font-black uppercase tracking-widest text-[#004f82] border-l-4 border-[#004f82] pl-4">Évaluation des Soft Skills</h5>
<div class="px-5 py-2 bg-[#004f82]/5 dark:bg-[#004f82]/30 rounded-2xl border border-[#004f82]/20 dark:border-[#004f82]/50">
<span class="text-[10px] font-black uppercase text-[#004f82]/70 block tracking-[0.2em]">Moyenne Soft Skills</span>
<span class="text-xl font-black text-[#004f82]">{{ (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</span>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div v-for="(skill, idx) in notesForm.interview_details.soft_skills" :key="idx" class="p-6 bg-neutral/30 dark:bg-slate-900/40 border border-anthracite/5 dark:border-slate-800 rounded-[2rem] hover:shadow-xl hover:border-primary/20 transition-all duration-300">
<div class="flex justify-between items-center mb-5">
<span class="font-bold text-anthracite dark:text-slate-200 flex-1">{{ skill.name }}</span>
<span class="text-sm font-black px-3 py-1 rounded-xl"
:class="skill.score >= 8 ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30' : skill.score >= 5 ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30' : skill.score > 0 ? 'bg-red-100 text-red-700 dark:bg-red-900/30' : 'bg-anthracite/10 text-anthracite/60 dark:bg-slate-800'">
{{ skill.score }} / 10
</span>
</div>
<div class="flex gap-1.5 h-10 w-full group/slider">
<button
v-for="val in 10"
:key="val"
@click="skill.score = val"
type="button"
class="flex-1 rounded-lg transition-all border border-transparent hover:scale-110 relative"
:class="[
skill.score >= val
? (skill.score >= 8 ? 'bg-[#004f82]' : skill.score >= 5 ? 'bg-[#e0b04c]' : 'bg-[#a30000]')
: 'bg-anthracite/10 dark:bg-slate-800 hover:bg-anthracite/20 dark:hover:bg-slate-700'
]"
>
<!-- Affichage du chiffre au survol sur PC -->
<span class="absolute inset-0 flex items-center justify-center text-xs font-black opacity-0 group-hover/slider:opacity-100 hover:!opacity-100"
:class="skill.score >= val ? 'text-white/80' : 'text-anthracite/50'">
{{ val }}
</span>
</button>
</div>
</div>
</div>
</div>
<!-- Overall Appreciation -->
<div class="relative p-10 bg-primary/5 dark:bg-primary/20 border-4 border-dashed border-primary/20 dark:border-primary/30 rounded-[3rem] mt-16 group hover:border-primary/40 transition-all duration-500">
<div class="grid grid-cols-1 md:grid-cols-4 gap-12 items-center">
<div class="md:col-span-1 text-center md:text-left">
<label class="text-[10px] font-black uppercase tracking-[0.2em] text-primary/70 mb-6 block">Note Appréciation / 10</label>
<div class="relative inline-flex items-center">
<input
type="number"
v-model="notesForm.interview_details.appreciation"
min="0" max="10" step="0.5"
class="w-40 bg-white dark:bg-slate-800 border-none rounded-[2rem] p-6 font-black text-4xl text-primary text-center shadow-2xl focus:ring-4 focus:ring-primary/10 transition-all"
/>
<span class="ml-4 text-2xl font-black text-anthracite/40">/ 10</span>
</div>
</div>
<div class="md:col-span-3">
<div class="flex flex-col md:flex-row items-center justify-end gap-10">
<div class="text-right">
<span class="text-[10px] font-black uppercase tracking-[0.2em] text-anthracite/60 block mb-2">Pondération Totale Entretien</span>
<div class="text-6xl font-black text-highlight tracking-tighter">
{{ calculatedInterviewScore }}<span class="text-2xl text-anthracite/30 font-normal ml-3">/ 30</span>
</div>
</div>
<PrimaryButton
@click="saveNotes"
:disabled="notesForm.processing"
class="!px-12 !py-6 !rounded-[2rem] shadow-2xl transition-all hover:-translate-y-1 active:scale-95 flex flex-col items-center gap-1 group"
>
<span class="text-lg font-black tracking-tight">Enregistrer l'évaluation</span>
<span class="text-[10px] font-black uppercase tracking-widest opacity-60">Calcul des scores & profil radar</span>
</PrimaryButton>
</div>
</div>
</div>
</div>
</div>
<!-- Notes Section (Full Width) -->
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8"> <div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h4 class="text-xl font-bold flex items-center gap-2"> <h4 class="text-xl font-bold flex items-center gap-2">
@@ -913,7 +1133,10 @@ const runAI = async () => {
</div> </div>
</div> </div>
</div> </div>
</div> <!-- Fin Interview Tab -->
<!-- Tab Content: Tests -->
<div v-if="activeTab === 'tests'" class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<!-- Historique des Tests (Full Width) --> <!-- Historique des Tests (Full Width) -->
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8"> <div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8">
<h3 class="text-xl font-bold mb-8 flex items-center justify-between"> <h3 class="text-xl font-bold mb-8 flex items-center justify-between">
@@ -1027,8 +1250,84 @@ const runAI = async () => {
Ce candidat n'a pas encore terminé de test. Ce candidat n'a pas encore terminé de test.
</div> </div>
</div> </div>
</div> </div> <!-- Fin Tests Tab -->
</div>
<!-- Tab Content: Documents -->
<div v-if="activeTab === 'documents'" class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8">
<h4 class="font-bold mb-6 flex items-center gap-2 text-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-[#004f82]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
Gestion des Documents
</h4>
<div v-if="candidate.documents?.length === 0" class="py-8 text-center text-slate-400 italic bg-neutral/10 rounded-xl border border-dashed border-anthracite/10">
Aucun document disponible pour ce candidat.
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<button
v-for="doc in candidate.documents"
:key="doc.id"
@click="openPreview(doc)"
class="flex items-center justify-between p-5 bg-neutral/20 border border-anthracite/5 rounded-2xl hover:bg-neutral/50 transition-colors group text-left"
>
<div class="flex items-center gap-4">
<div class="p-3 bg-white rounded-xl shadow-sm group-hover:bg-primary group-hover:text-white transition-colors">
<svg v-if="doc.type === 'cv'" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" 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>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<div class="text-sm font-black uppercase tracking-tight text-primary">{{ doc.type === 'cv' ? 'Curriculum Vitae' : 'Lettre de Motivation' }}</div>
<div class="text-[11px] text-slate-500 font-medium mt-1 truncate">{{ doc.original_name }}</div>
</div>
</div>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-400 group-hover:translate-x-1 group-hover:text-primary transition-all" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<div class="pt-8 border-t border-anthracite/5">
<h5 class="text-sm font-black uppercase text-anthracite/60 tracking-widest mb-6">Ajouter ou Remplacer les documents</h5>
<form @submit.prevent="updateDocuments" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="relative group/file">
<label class="flex flex-col items-center justify-center p-6 bg-neutral/20 border-2 border-dashed border-anthracite/10 rounded-2xl cursor-pointer hover:border-primary hover:bg-neutral/40 transition-all">
<div class="mb-3 p-2 bg-white rounded-lg shadow-sm group-hover/file:bg-primary group-hover/file:text-white transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-400 group-hover/file:text-white transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>
</div>
<span class="text-xs font-black uppercase tracking-tight text-primary mb-1">Nouveau CV (PDF)</span>
<span class="text-[10px] text-slate-400 truncate w-full text-center">{{ docForm.cv ? docForm.cv.name : 'Cliquer pour parcourir...' }}</span>
<input type="file" class="hidden" @input="docForm.cv = $event.target.files[0]" accept="application/pdf" />
</label>
<InputError :message="docForm.errors.cv" class="mt-2" />
</div>
<div class="relative group/file">
<label class="flex flex-col items-center justify-center p-6 bg-neutral/20 border-2 border-dashed border-anthracite/10 rounded-2xl cursor-pointer hover:border-accent hover:bg-neutral/40 transition-all">
<div class="mb-3 p-2 bg-white rounded-lg shadow-sm group-hover/file:bg-accent group-hover/file:text-white transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-400 group-hover/file:text-white transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>
</div>
<span class="text-xs font-black uppercase tracking-tight text-accent mb-1">Nouvelle Lettre (PDF)</span>
<span class="text-[10px] text-slate-400 truncate w-full text-center">{{ docForm.cover_letter ? docForm.cover_letter.name : 'Cliquer pour parcourir...' }}</span>
<input type="file" class="hidden" @input="docForm.cover_letter = $event.target.files[0]" accept="application/pdf" />
</label>
<InputError :message="docForm.errors.cover_letter" class="mt-2" />
</div>
</div>
<PrimaryButton class="!px-8 py-3 text-sm" :disabled="docForm.processing || (!docForm.cv && !docForm.cover_letter)">
Enregistrer les nouveaux documents
</PrimaryButton>
</form>
</div>
</div>
</div> <!-- Fin Documents Tab Content (1241) -->
<!-- Document Preview Modal --> <!-- Document Preview Modal -->
<Modal :show="!!selectedDocument" @close="selectedDocument = null" max-width="4xl"> <Modal :show="!!selectedDocument" @close="selectedDocument = null" max-width="4xl">
@@ -1048,6 +1347,68 @@ const runAI = async () => {
</Modal> </Modal>
</AdminLayout> </AdminLayout>
<!-- Edit Details Modal -->
<Modal :show="showEditDetailsModal" @close="showEditDetailsModal = false">
<div class="p-6">
<h2 class="text-lg font-black uppercase tracking-tight text-slate-900 dark:text-white mb-6">Modifier les informations</h2>
<form @submit.prevent="updateDetails" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Nom complet</label>
<input
v-model="detailsForm.name"
type="text"
class="w-full bg-slate-50 dark:bg-slate-900 border-slate-200 dark:border-slate-700 rounded-xl focus:ring-indigo-500/20 focus:border-indigo-500 transition-all font-bold text-sm"
placeholder="Ex: Jean Dupont"
/>
<InputError :message="detailsForm.errors.name" class="mt-1" />
</div>
<div>
<label class="block text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Email</label>
<input
v-model="detailsForm.email"
type="email"
class="w-full bg-slate-50 dark:bg-slate-900 border-slate-200 dark:border-slate-700 rounded-xl focus:ring-indigo-500/20 focus:border-indigo-500 transition-all font-bold text-sm"
placeholder="Email candidat"
/>
<InputError :message="detailsForm.errors.email" class="mt-1" />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">Téléphone</label>
<input
v-model="detailsForm.phone"
type="text"
class="w-full bg-slate-50 dark:bg-slate-900 border-slate-200 dark:border-slate-700 rounded-xl focus:ring-indigo-500/20 focus:border-indigo-500 transition-all font-bold text-sm"
placeholder="Ex: 06 12 34 56 78"
/>
<InputError :message="detailsForm.errors.phone" class="mt-1" />
</div>
<div>
<label class="block text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1">URL LinkedIn</label>
<input
v-model="detailsForm.linkedin_url"
type="url"
class="w-full bg-slate-50 dark:bg-slate-900 border-slate-200 dark:border-slate-700 rounded-xl focus:ring-indigo-500/20 focus:border-indigo-500 transition-all font-bold text-sm"
placeholder="https://linkedin.com/in/..."
/>
<InputError :message="detailsForm.errors.linkedin_url" class="mt-1" />
</div>
</div>
<div class="flex justify-end gap-3 pt-4">
<SecondaryButton @click="showEditDetailsModal = false" type="button">Annuler</SecondaryButton>
<PrimaryButton :class="{ 'opacity-25': detailsForm.processing }" :disabled="detailsForm.processing">
Enregistrer les modifications
</PrimaryButton>
</div>
</form>
</div>
</Modal>
<!-- Error Modal --> <!-- Error Modal -->
<Modal :show="showErrorModal" @close="showErrorModal = false" maxWidth="md"> <Modal :show="showErrorModal" @close="showErrorModal = false" maxWidth="md">
<div class="p-6"> <div class="p-6">

View File

@@ -31,69 +31,77 @@ const submit = () => {
<template> <template>
<GuestLayout> <GuestLayout>
<Head title="Log in" /> <Head title="Connexion" />
<div v-if="status" class="mb-4 text-sm font-medium text-green-600"> <div v-if="status" class="mb-4 text-sm font-medium text-emerald-600 bg-emerald-50 p-3 rounded-lg">
{{ status }} {{ status }}
</div> </div>
<form @submit.prevent="submit"> <div class="mb-8 text-center space-y-1">
<h2 class="text-2xl font-serif font-black text-primary">Bon retour !</h2>
<p class="text-anthracite/60 text-sm">Veuillez entrer vos identifiants.</p>
</div>
<form @submit.prevent="submit" class="space-y-5">
<div> <div>
<InputLabel for="email" value="Email" /> <InputLabel for="email" value="Adresse Email" class="!font-subtitle !text-xs !uppercase !tracking-widest !text-anthracite/60 !mb-1" />
<TextInput <TextInput
id="email" id="email"
type="email" type="email"
class="mt-1 block w-full" class="mt-1 block w-full !rounded-xl !border-anthracite/10 focus:!border-primary focus:!ring-primary/20 shadow-sm transition-colors text-sm"
v-model="form.email" v-model="form.email"
required required
autofocus autofocus
autocomplete="username" autocomplete="username"
placeholder="prenom.nom@exemple.com"
/> />
<InputError class="mt-2" :message="form.errors.email" /> <InputError class="mt-2" :message="form.errors.email" />
</div> </div>
<div class="mt-4"> <div>
<InputLabel for="password" value="Password" /> <div class="flex justify-between items-center mb-1">
<InputLabel for="password" value="Mot de passe" class="!font-subtitle !text-xs !uppercase !tracking-widest !text-anthracite/60 !mb-0" />
<Link
v-if="canResetPassword"
:href="route('password.request')"
class="text-[10px] font-bold text-accent hover:text-accent/80 transition-colors uppercase tracking-wider"
>
Oublié ?
</Link>
</div>
<TextInput <TextInput
id="password" id="password"
type="password" type="password"
class="mt-1 block w-full" class="mt-1 block w-full !rounded-xl !border-anthracite/10 focus:!border-primary focus:!ring-primary/20 shadow-sm transition-colors text-sm"
v-model="form.password" v-model="form.password"
required required
autocomplete="current-password" autocomplete="current-password"
placeholder="••••••••"
/> />
<InputError class="mt-2" :message="form.errors.password" /> <InputError class="mt-2" :message="form.errors.password" />
</div> </div>
<div class="mt-4 block"> <div class="block pt-2">
<label class="flex items-center"> <label class="flex items-center group cursor-pointer w-max">
<Checkbox name="remember" v-model:checked="form.remember" /> <Checkbox name="remember" v-model:checked="form.remember" class="!rounded !border-anthracite/20 text-primary focus:ring-primary shadow-sm group-hover:border-primary transition-colors" />
<span class="ms-2 text-sm text-gray-600" <span class="ms-2 text-sm text-anthracite/60 group-hover:text-anthracite transition-colors">Rester connecté</span>
>Remember me</span
>
</label> </label>
</div> </div>
<div class="mt-4 flex items-center justify-end"> <div class="pt-4">
<Link <button
v-if="canResetPassword" type="submit"
:href="route('password.request')" :class="{ 'opacity-50 cursor-not-allowed': form.processing }"
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Forgot your password?
</Link>
<PrimaryButton
class="ms-4"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing" :disabled="form.processing"
class="w-full flex justify-center py-3.5 px-4 bg-highlight text-[#3a2800] rounded-xl font-subtitle font-bold shadow-md shadow-highlight/20 hover:brightness-110 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-highlight/30 transition-all text-sm uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-highlight/50 focus:ring-offset-2"
> >
Log in Se connecter
</PrimaryButton> </button>
</div> </div>
</form> </form>
</GuestLayout> </GuestLayout>

View File

@@ -44,88 +44,109 @@ const getStatusColor = (status) => {
</div> </div>
</template> </template>
<div v-if="isAdmin" class="p-8 space-y-8"> <div v-if="isAdmin" class="space-y-8 font-sans text-anthracite">
<!-- KPI Cards --> <!-- KPI Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
<div class="bg-white dark:bg-slate-800 p-6 rounded-3xl shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-xl transition-all duration-300"> <!-- Total Candidats -->
<div class="text-slate-500 dark:text-slate-400 text-[10px] font-black uppercase tracking-widest">Total Candidats</div> <div class="bg-white p-6 rounded-3xl shadow-sm border border-anthracite/5 hover:-translate-y-1 hover:shadow-xl hover:shadow-primary/5 transition-all duration-300 relative overflow-hidden group">
<div class="text-4xl font-black mt-2 text-indigo-600 dark:text-indigo-400">{{ stats.total_candidates }}</div> <div class="text-[10px] font-subtitle font-black uppercase tracking-widest text-anthracite/40">Total Candidats</div>
<div class="text-4xl font-black mt-3 text-primary">{{ stats.total_candidates }}</div>
<div class="absolute bottom-0 right-0 w-24 h-24 bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-primary/10 to-transparent"></div>
</div> </div>
<div class="bg-white dark:bg-slate-800 p-6 rounded-3xl shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-xl transition-all duration-300 relative overflow-hidden group">
<div class="absolute inset-0 bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div> <!-- Candidats Retenus -->
<div class="bg-white p-6 rounded-3xl shadow-sm border border-anthracite/5 hover:-translate-y-1 hover:shadow-xl hover:shadow-highlight/20 transition-all duration-300 relative overflow-hidden group">
<div class="absolute inset-0 bg-gradient-to-br from-highlight/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<div class="relative z-10"> <div class="relative z-10">
<div class="text-amber-600 dark:text-amber-500 text-[10px] font-black uppercase tracking-widest flex items-center gap-1.5"> <div class="text-highlight text-[10px] font-subtitle font-black uppercase tracking-widest flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /> <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg> </svg>
Retenus Retenus
</div> </div>
<div class="text-4xl font-black mt-2 text-amber-600 dark:text-amber-400">{{ stats.selected_candidates }}</div> <div class="text-4xl font-black mt-3 text-highlight drop-shadow-sm">{{ stats.selected_candidates }}</div>
</div> </div>
</div> </div>
<div class="bg-white dark:bg-slate-800 p-6 rounded-3xl shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-xl transition-all duration-300">
<div class="text-slate-500 dark:text-slate-400 text-[10px] font-black uppercase tracking-widest">Tests terminés</div> <!-- Tests terminés -->
<div class="text-4xl font-black mt-2 text-emerald-600 dark:text-emerald-400">{{ stats.finished_tests }}</div> <div class="bg-white p-6 rounded-3xl shadow-sm border border-anthracite/5 hover:-translate-y-1 hover:shadow-xl hover:shadow-emerald-500/10 transition-all duration-300 relative overflow-hidden group">
<div class="text-[10px] font-subtitle font-black uppercase tracking-widest text-anthracite/40">Tests terminés</div>
<div class="text-4xl font-black mt-3 text-emerald-500">{{ stats.finished_tests }}</div>
<div class="absolute bottom-0 right-0 w-24 h-24 bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-emerald-500/10 to-transparent"></div>
</div> </div>
<div class="bg-white dark:bg-slate-800 p-6 rounded-3xl shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-xl transition-all duration-300">
<div class="text-slate-500 dark:text-slate-400 text-[10px] font-black uppercase tracking-widest">Moyenne Générale</div> <!-- Moyenne Générale -->
<div class="text-4xl font-black mt-2 text-blue-600 dark:text-blue-400">{{ stats.average_score }} / 20</div> <div class="bg-white p-6 rounded-3xl shadow-sm border border-anthracite/5 hover:-translate-y-1 hover:shadow-xl hover:shadow-sky/10 transition-all duration-300 relative overflow-hidden group">
<div class="text-[10px] font-subtitle font-black uppercase tracking-widest text-anthracite/40">Moyenne Générale</div>
<div class="text-4xl font-black mt-3 text-sky">{{ stats.average_score }} <span class="text-lg opacity-50 font-bold">/ 20</span></div>
<div class="absolute bottom-0 right-0 w-24 h-24 bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-sky/10 to-transparent"></div>
</div> </div>
<div class="bg-white dark:bg-slate-800 p-6 rounded-3xl shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-xl transition-all duration-300">
<div class="text-slate-500 dark:text-slate-400 text-[10px] font-black uppercase tracking-widest">Meilleur Score</div> <!-- Meilleur Score -->
<div class="text-4xl font-black mt-2 text-purple-600 dark:text-purple-400">{{ stats.best_score }} / 20</div> <div class="bg-white p-6 rounded-3xl shadow-sm border border-anthracite/5 hover:-translate-y-1 hover:shadow-xl hover:shadow-accent/10 transition-all duration-300 relative overflow-hidden group">
<div class="text-[10px] font-subtitle font-black uppercase tracking-widest text-anthracite/40">Meilleur Score</div>
<div class="text-4xl font-black mt-3 text-accent">{{ stats.best_score }} <span class="text-lg opacity-50 font-bold">/ 20</span></div>
<div class="absolute bottom-0 right-0 w-24 h-24 bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-accent/10 to-transparent"></div>
</div> </div>
</div> </div>
<!-- Top Candidates Table --> <!-- Top Candidates Table -->
<div class="bg-white dark:bg-slate-800 shadow-sm border border-slate-200 dark:border-slate-700 rounded-3xl overflow-hidden"> <div class="bg-white shadow-sm border border-anthracite/5 rounded-3xl overflow-hidden mt-8">
<div class="px-8 py-6 border-b border-slate-100 dark:border-slate-700 flex justify-between items-center bg-slate-50/50 dark:bg-slate-900/50"> <div class="px-8 py-6 border-b border-anthracite/5 flex justify-between items-center bg-sand/30">
<h3 class="text-xl font-black uppercase tracking-tight">Top 10 Candidats</h3> <h3 class="text-xl font-serif font-black text-primary capitalize tracking-tight flex items-center gap-3">
<Link :href="route('admin.candidates.index')" class="text-xs font-bold uppercase tracking-widest text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 transition-colors"> <div class="w-1.5 h-6 bg-highlight rounded-full hidden md:block"></div>
Voir tous les candidats Top 10 Candidats
</h3>
<Link :href="route('admin.candidates.index')" class="text-xs font-subtitle font-bold uppercase tracking-widest text-primary hover:text-highlight transition-colors flex items-center gap-1">
Voir tous <span class="hidden sm:inline">les candidats</span> &rarr;
</Link> </Link>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-left border-collapse"> <table class="w-full text-left border-collapse">
<thead> <thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/30"> <tr class="bg-neutral/50">
<th class="px-8 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Candidat</th> <th class="px-8 py-4 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40">Candidat</th>
<th class="px-8 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Score Pondéré</th> <th class="px-8 py-4 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40">Score Pondéré</th>
<th class="px-8 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Adéquation IA</th> <th class="px-8 py-4 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40">Adéquation IA</th>
<th class="px-8 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Statut</th> <th class="px-8 py-4 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40">Statut</th>
<th class="px-8 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 text-right">Actions</th> <th class="px-8 py-4 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40 text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800"> <tbody class="divide-y divide-anthracite/5">
<tr v-for="candidate in top_candidates" :key="candidate.id" class="hover:bg-slate-50 dark:hover:bg-slate-900/50 transition-colors group"> <tr v-for="candidate in top_candidates" :key="candidate.id" class="hover:bg-sand/30 transition-colors group">
<td class="px-8 py-5"> <td class="px-8 py-5">
<div class="font-bold text-slate-900 dark:text-slate-100 group-hover:text-indigo-600 transition-colors">{{ candidate.name }}</div> <div class="font-bold text-primary group-hover:text-highlight transition-colors block">{{ candidate.name }}</div>
<div class="text-xs text-slate-500 dark:text-slate-400">{{ candidate.email }}</div> <div class="text-xs text-anthracite/50 font-subtitle tracking-wide mt-0.5">{{ candidate.email }}</div>
</td> </td>
<td class="px-8 py-5"> <td class="px-8 py-5">
<div class="inline-flex items-center gap-2 px-4 py-1.5 bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 rounded-full font-black text-sm border border-indigo-100 dark:border-indigo-800"> <div class="inline-flex items-center gap-2 px-4 py-1.5 bg-primary/5 text-primary rounded-xl font-black text-sm border border-primary/10 shadow-sm">
{{ candidate.weighted_score }} / 20 {{ candidate.weighted_score }} <span class="opacity-50 text-xs">/ 20</span>
</div> </div>
</td> </td>
<td class="px-8 py-5"> <td class="px-8 py-5">
<div v-if="candidate.ai_analysis" class="flex items-center gap-2"> <div v-if="candidate.ai_analysis" class="flex items-center gap-2">
<div <div
class="px-2 py-0.5 rounded text-[10px] font-black" class="px-3 py-1 rounded-lg text-xs font-black shadow-sm"
:class="[ :class="[
candidate.ai_analysis.match_score >= 80 ? 'bg-emerald-100 text-emerald-700' : candidate.ai_analysis.match_score >= 80 ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' :
candidate.ai_analysis.match_score >= 60 ? 'bg-amber-100 text-amber-700' : candidate.ai_analysis.match_score >= 60 ? 'bg-highlight/10 text-[#3a2800] border border-highlight/30' :
'bg-red-100 text-red-700' 'bg-accent/10 text-accent border border-accent/20'
]" ]"
> >
{{ candidate.ai_analysis.match_score }}% {{ candidate.ai_analysis.match_score }}%
</div> </div>
</div> </div>
<span v-else class="text-[10px] text-slate-300 italic font-medium">Non analysé</span> <span v-else class="text-[10px] uppercase tracking-widest text-anthracite/30 italic font-bold">Non analysé</span>
</td> </td>
<td class="px-8 py-5"> <td class="px-8 py-5">
<span <span
class="px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-full" class="px-3 py-1 text-[10px] font-black uppercase tracking-[0.15em] rounded-full"
:class="getStatusColor(candidate.status)" :class="{
'bg-anthracite/5 text-anthracite/60 border border-anthracite/10': candidate.status === 'en_attente',
'bg-sky/10 text-sky border border-sky/20': candidate.status === 'en_cours',
'bg-emerald-50 text-emerald-700 border border-emerald-200': candidate.status === 'termine',
'bg-accent/10 text-accent border border-accent/20': candidate.status === 'refuse'
}"
> >
{{ candidate.status }} {{ candidate.status }}
</span> </span>
@@ -133,19 +154,20 @@ const getStatusColor = (status) => {
<td class="px-8 py-5 text-right"> <td class="px-8 py-5 text-right">
<Link <Link
:href="route('admin.candidates.show', candidate.id)" :href="route('admin.candidates.show', candidate.id)"
class="inline-flex items-center justify-center p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded-xl transition-all" class="inline-flex items-center justify-center p-2 text-primary/40 hover:text-highlight hover:bg-highlight/10 rounded-xl transition-all"
title="Détails" title="Détails"
> >
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M14 5l7 7m0 0l-7 7m7-7H3" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg> </svg>
</Link> </Link>
</td> </td>
</tr> </tr>
<tr v-if="top_candidates.length === 0"> <tr v-if="top_candidates.length === 0">
<td colspan="4" class="px-8 py-12 text-center text-slate-400 italic font-medium"> <td colspan="5" class="px-8 py-16 text-center">
Aucun candidat pour le moment. <div class="text-anthracite/40 italic font-medium font-subtitle">
Aucun candidat pour le moment.
</div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -154,19 +176,19 @@ const getStatusColor = (status) => {
</div> </div>
</div> </div>
<!-- Candidate Dashboard: LIGHT ONLY, high contrast, no dark: classes --> <!-- Candidate Dashboard: LIGHT ONLY, matched with new graphic charter -->
<div v-else style="background: linear-gradient(135deg, #f8faff 0%, #eef2ff 100%); min-height: calc(100vh - 4rem);" class="flex flex-col items-center justify-center px-4 py-16"> <div v-else class="flex flex-col items-center justify-center px-4 py-16 bg-neutral min-h-[calc(100vh-4rem)] font-sans text-anthracite selection:bg-highlight selection:text-anthracite">
<div class="w-full max-w-4xl"> <div class="w-full max-w-4xl">
<!-- Welcome Section --> <!-- Welcome Section -->
<div class="mb-12 text-center"> <div class="mb-12 text-center">
<div class="inline-flex items-center gap-2 px-4 py-1.5 rounded-full text-[10px] font-black uppercase tracking-widest mb-6 border" style="background:#eef2ff; color:#4f46e5; border-color:#c7d2fe;"> <div class="inline-flex items-center gap-2 px-5 py-2 rounded-full text-xs font-subtitle font-bold uppercase tracking-widest mb-6 bg-primary/10 text-primary border border-primary/20">
Espace Candidat Espace Candidat
</div> </div>
<h3 class="font-black mb-5 tracking-tight" style="font-size: clamp(2rem, 5vw, 3.5rem); color: #1e1b4b; line-height: 1.1;"> <h3 class="text-4xl md:text-5xl font-serif font-black mb-5 tracking-tight text-primary leading-tight">
Bienvenue, <span style="color:#4f46e5;">{{ user.name }}</span> ! Bienvenue, <span class="text-accent">{{ user.name }}</span> !
</h3> </h3>
<p style="color:#6b7280; font-size:1.1rem; max-width:40rem; margin:0 auto; line-height:1.7;"> <p class="text-anthracite/70 text-lg max-w-2xl mx-auto leading-relaxed">
Voici les tests techniques préparés pour votre candidature. Installez-vous confortablement avant de commencer. Voici les tests techniques préparés pour votre candidature. Installez-vous confortablement avant de commencer.
</p> </p>
</div> </div>
@@ -176,34 +198,31 @@ const getStatusColor = (status) => {
<div <div
v-for="quiz in quizzes" v-for="quiz in quizzes"
:key="quiz.id" :key="quiz.id"
class="group" class="group bg-white rounded-3xl p-8 shadow-sm border-b-4 border-transparent hover:border-highlight hover:-translate-y-2 hover:shadow-xl hover:shadow-highlight/10 transition-all duration-300 relative overflow-hidden"
style="background: white; border-radius: 2rem; padding: 2.5rem; box-shadow: 0 4px 24px rgba(79,70,229,0.08); border: 1.5px solid #e0e7ff; transition: all 0.4s ease; position: relative; overflow: hidden;"
@mouseenter="$event.currentTarget.style.borderColor='#6366f1'; $event.currentTarget.style.boxShadow='0 12px 40px rgba(79,70,229,0.15)'; $event.currentTarget.style.transform='translateY(-4px)'"
@mouseleave="$event.currentTarget.style.borderColor='#e0e7ff'; $event.currentTarget.style.boxShadow='0 4px 24px rgba(79,70,229,0.08)'; $event.currentTarget.style.transform='translateY(0)'"
> >
<!-- Decorative blob --> <!-- Decorative blob -->
<div style="position:absolute; top:-2rem; right:-2rem; width:8rem; height:8rem; background:radial-gradient(circle, #818cf820 0%, transparent 70%); border-radius:50%;"></div> <div class="absolute -top-8 -right-8 w-32 h-32 bg-[radial-gradient(circle,_#1a4b8c20_0%,_transparent_70%)] rounded-full"></div>
<!-- Icon badge --> <!-- Icon badge -->
<div style="display:inline-flex; padding:0.75rem; background:#eef2ff; border-radius:1rem; margin-bottom:1.5rem;"> <div class="inline-flex p-3 bg-sky/15 rounded-xl mb-6">
<svg xmlns="http://www.w3.org/2000/svg" style="width:1.75rem;height:1.75rem;color:#4f46e5;stroke:#4f46e5;" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="w-7 h-7 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg> </svg>
</div> </div>
<h4 style="font-size:1.25rem; font-weight:800; color:#1e1b4b; margin-bottom:0.75rem; line-height:1.3;">{{ quiz.title }}</h4> <h4 class="text-xl font-subtitle font-bold text-primary mb-3 leading-tight">{{ quiz.title }}</h4>
<p style="color:#6b7280; font-size:0.875rem; line-height:1.6; margin-bottom:2rem; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;"> <p class="text-anthracite/70 text-sm leading-relaxed mb-8 line-clamp-2">
{{ quiz.description }} {{ quiz.description }}
</p> </p>
<div style="border-top:1.5px solid #f1f5f9; padding-top:1.5rem; display:flex; align-items:center; justify-content:space-between; gap:1rem;"> <div class="pt-6 border-t border-anthracite/10 flex items-center justify-between gap-4 relative z-10">
<div> <div>
<div style="font-size:0.65rem; font-weight:900; text-transform:uppercase; letter-spacing:0.1em; color:#9ca3af; margin-bottom:0.2rem;">Durée</div> <div class="text-[10px] font-black uppercase tracking-[0.1em] text-anthracite/40 mb-1">Durée</div>
<div style="font-size:0.95rem; font-weight:800; color:#374151;">{{ quiz.duration_minutes }} min</div> <div class="text-base font-bold text-anthracite">{{ quiz.duration_minutes }} min</div>
</div> </div>
<div v-if="quiz.has_finished_attempt" style="display:flex; align-items:center; gap:0.5rem; background:#ecfdf5; color:#059669; font-weight:800; font-size:0.75rem; text-transform:uppercase; letter-spacing:0.08em; padding:0.625rem 1.25rem; border-radius:0.75rem; border:1.5px solid #a7f3d0;"> <div v-if="quiz.has_finished_attempt" class="flex items-center gap-2 bg-[#ecfdf5] text-[#059669] font-bold text-xs uppercase tracking-wider px-5 py-2.5 rounded-xl border-2 border-[#a7f3d0]">
<svg xmlns="http://www.w3.org/2000/svg" style="width:1rem;height:1rem;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"> <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /> <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg> </svg>
Terminé Terminé
@@ -211,9 +230,7 @@ const getStatusColor = (status) => {
<Link <Link
v-else v-else
:href="route('quizzes.take', quiz.id)" :href="route('quizzes.take', quiz.id)"
style="display:inline-flex; align-items:center; justify-content:center; padding:0.75rem 2rem; background:#4f46e5; color:white; border-radius:0.875rem; font-weight:800; font-size:0.875rem; text-decoration:none; box-shadow:0 4px 14px rgba(79,70,229,0.35); transition:all 0.2s ease; white-space:nowrap;" class="inline-flex items-center justify-center px-8 py-3 bg-highlight text-[#3a2800] rounded-xl font-subtitle font-bold text-sm shadow-md shadow-highlight/20 hover:brightness-110 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-highlight/30 transition-all whitespace-nowrap"
@mouseenter="$event.currentTarget.style.background='#4338ca'; $event.currentTarget.style.transform='scale(0.98)'"
@mouseleave="$event.currentTarget.style.background='#4f46e5'; $event.currentTarget.style.transform='scale(1)'"
> >
Démarrer Démarrer
</Link> </Link>
@@ -222,21 +239,21 @@ const getStatusColor = (status) => {
</div> </div>
<!-- Empty State --> <!-- Empty State -->
<div v-else style="text-align:center; padding:5rem 2rem; background:white; border-radius:2rem; box-shadow:0 4px 24px rgba(0,0,0,0.06); border:1.5px solid #e0e7ff;"> <div v-else class="text-center p-20 bg-white rounded-3xl shadow-sm border border-anthracite/5">
<div style="display:inline-flex; padding:1.5rem; background:#fff7ed; border-radius:9999px; margin-bottom:1.5rem;"> <div class="inline-flex p-6 bg-accent/10 rounded-full mb-6">
<svg xmlns="http://www.w3.org/2000/svg" style="width:3rem;height:3rem;stroke:#f97316;" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 text-accent" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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> </svg>
</div> </div>
<h4 style="font-size:1.5rem; font-weight:900; color:#1e1b4b; margin-bottom:0.75rem;">Aucun test assigné</h4> <h4 class="text-2xl font-serif font-black text-primary mb-3">Aucun test assigné</h4>
<p style="color:#6b7280; max-width:28rem; margin:0 auto; line-height:1.7; font-size:0.95rem;"> <p class="text-anthracite/70 max-w-lg mx-auto leading-relaxed text-sm">
Votre dossier est en cours de traitement. Un administrateur vous assignera bientôt vos tests techniques. Votre dossier est en cours de traitement. Un administrateur vous assignera bientôt vos tests techniques.
</p> </p>
</div> </div>
<!-- Footer --> <!-- Footer -->
<div style="margin-top:3rem; text-align:center;"> <div class="mt-12 text-center text-primary/50 text-[10px] font-subtitle font-bold uppercase tracking-widest">
<p style="font-size:0.65rem; font-weight:900; text-transform:uppercase; letter-spacing:0.15em; color:#d1d5db;">RecruitQuizz Platform v{{ $page.props.app_version }}</p> &copy; {{ new Date().getFullYear() }} Communauté d'Agglomération Béziers Méditerranée
</div> </div>
</div> </div>

View File

@@ -2,184 +2,185 @@
import { Head, Link } from '@inertiajs/vue3'; import { Head, Link } from '@inertiajs/vue3';
defineProps({ defineProps({
canLogin: Boolean, canLogin: {
canRegister: Boolean, type: Boolean,
},
}); });
</script> </script>
<template> <template>
<Head title="Bienvenue sur RecruitQuizz" /> <Head title="Recru IT" />
<div class="min-h-screen bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100 selection:bg-indigo-500 selection:text-white font-sans overflow-x-hidden"> <div class="min-h-screen bg-neutral text-anthracite font-sans overflow-x-hidden selection:bg-highlight selection:text-anthracite">
<!-- Animated Background Blobs -->
<div class="fixed inset-0 pointer-events-none overflow-hidden">
<div class="absolute -top-[10%] -left-[10%] w-[40%] h-[40%] bg-indigo-500/10 dark:bg-indigo-500/5 rounded-full blur-[120px] animate-pulse"></div>
<div class="absolute top-[20%] -right-[10%] w-[35%] h-[35%] bg-purple-500/10 dark:bg-purple-500/5 rounded-full blur-[120px] animate-pulse" style="animation-delay: 2s;"></div>
<div class="absolute -bottom-[10%] left-[20%] w-[30%] h-[30%] bg-emerald-500/10 dark:bg-emerald-500/5 rounded-full blur-[120px] animate-pulse" style="animation-delay: 4s;"></div>
</div>
<!-- Navigation --> <!-- Navigation -->
<nav class="relative z-50 flex items-center justify-between px-6 py-8 md:px-12 max-w-7xl mx-auto"> <nav class="relative z-50 flex items-center justify-between px-6 py-8 md:px-12 max-w-7xl mx-auto">
<div class="flex items-center gap-2 group cursor-default"> <div class="flex items-center gap-3 group cursor-default">
<div class="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-indigo-600/20 group-hover:scale-110 transition-transform duration-300"> <div class="w-12 h-12 bg-primary rounded-lg flex items-center justify-center shadow-lg shadow-primary/30 group-hover:scale-105 transition-transform duration-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9l-.707.707M12 18v3m4.95-4.95l.707.707M12 3c-4.418 0-8 3.582-8 8 0 2.209.895 4.209 2.343 5.657L12 21l5.657-5.343A7.994 7.994 0 0020 11c0-4.418-3.582-8-8-8z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9l-.707.707M12 18v3m4.95-4.95l.707.707M12 3c-4.418 0-8 3.582-8 8 0 2.209.895 4.209 2.343 5.657L12 21l5.657-5.343A7.994 7.994 0 0020 11c0-4.418-3.582-8-8-8z" />
</svg> </svg>
</div> </div>
<span class="text-2xl font-black tracking-tighter uppercase italic text-slate-900 dark:text-white">RECRU<span class="text-indigo-600">IT</span></span> <span class="text-3xl font-serif font-bold text-primary">RECRU<span class="text-accent italic px-1">IT</span></span>
</div> </div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<template v-if="$page.props.auth.user"> <template v-if="$page.props.auth.user">
<Link :href="route('dashboard')" class="px-6 py-2.5 bg-slate-900 dark:bg-white text-white dark:text-slate-900 rounded-full font-bold text-sm hover:scale-105 transition-all shadow-xl shadow-slate-900/10 dark:shadow-none"> <Link :href="route('dashboard')" class="px-8 py-3 bg-highlight text-[#3a2800] rounded-lg font-subtitle font-bold text-sm hover:brightness-110 hover:shadow-xl hover:shadow-highlight/20 transition-all duration-300">
Aller au Dashboard Accéder au Tableau de bord
</Link> </Link>
</template> </template>
<template v-else> <template v-else>
<Link :href="route('login')" class="text-slate-600 dark:text-slate-400 font-bold text-sm hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors hidden md:block px-4"> <Link :href="route('login')" class="px-8 py-3 bg-highlight text-[#3a2800] rounded-lg font-subtitle font-bold text-sm hover:brightness-110 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-highlight/20 transition-all duration-300">
Connexion Connexion
</Link> </Link>
<Link
v-if="canRegister"
:href="route('register')"
class="px-8 py-3 bg-indigo-600 text-white rounded-full font-bold text-sm hover:bg-indigo-700 hover:scale-105 hover:shadow-2xl hover:shadow-indigo-600/30 transition-all duration-300"
>
Créer un compte
</Link>
</template> </template>
</div> </div>
</nav> </nav>
<!-- Hero Section --> <!-- Fond Institutionnel Hero Section -->
<main class="relative z-10 max-w-7xl mx-auto px-6 pt-20 pb-32 md:px-12 md:pt-32"> <main class="relative z-10 w-full mt-4">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-16 items-center"> <div class="max-w-[95%] mx-auto bg-primary rounded-[2.5rem] overflow-hidden shadow-2xl relative px-8 py-20 pb-32 md:px-16 md:py-32">
<!-- Hero Content --> <!-- Graphic Elements -->
<div class="lg:col-span-7 space-y-10"> <div class="absolute top-0 right-0 w-[50%] h-full bg-gradient-to-l from-sky/40 to-transparent pointer-events-none"></div>
<div class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-100 dark:border-indigo-800 rounded-full text-indigo-600 dark:text-indigo-400 text-xs font-black uppercase tracking-widest animate-bounce"> <div class="absolute -bottom-24 -left-24 w-96 h-96 bg-accent/20 rounded-full blur-[100px] pointer-events-none"></div>
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-indigo-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-indigo-500"></span>
</span>
Tests de recrutements
</div>
<h1 class="text-6xl md:text-8xl font-black tracking-tight leading-[0.9] text-slate-900 dark:text-white"> <div class="relative z-10 grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
Evualuation <br>
<span class="text-transparent bg-clip-text bg-gradient-to-r from-indigo-600 to-purple-600">des candidats.</span>
</h1>
<p class="text-xl text-slate-600 dark:text-slate-400 max-w-xl leading-relaxed"> <!-- Hero Content -->
Recru.IT transforme le processus de sélection technique. Testez les candidats avec des parcours personnalisés et des évaluations précises en quelques minutes. <div class="space-y-10 text-white">
</p> <div class="inline-flex items-center gap-3 px-5 py-2.5 bg-white/10 backdrop-blur-md border border-white/20 rounded-full text-white text-sm font-subtitle font-bold uppercase tracking-widest shadow-inner">
<span class="relative flex h-3 w-3">
<div class="flex flex-col sm:flex-row items-center gap-6"> <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-highlight opacity-75"></span>
<Link <span class="relative inline-flex rounded-full h-3 w-3 bg-highlight"></span>
:href="route('login')" </span>
class="group relative w-full sm:w-auto px-10 py-5 bg-slate-900 dark:bg-white text-white dark:text-slate-900 rounded-3xl font-black uppercase tracking-widest text-sm text-center overflow-hidden hover:scale-105 transition-all duration-300" Évaluation des candidats
>
<span class="relative z-10">Démarrer maintenant</span>
<div class="absolute inset-0 bg-gradient-to-r from-indigo-600 to-purple-600 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
</Link>
<div class="flex -space-x-4">
<img class="w-12 h-12 rounded-full border-4 border-white dark:border-slate-950 shadow-xl" src="https://i.pravatar.cc/150?u=1" alt="User 1">
<img class="w-12 h-12 rounded-full border-4 border-white dark:border-slate-950 shadow-xl" src="https://i.pravatar.cc/150?u=2" alt="User 2">
<img class="w-12 h-12 rounded-full border-4 border-white dark:border-slate-950 shadow-xl" src="https://i.pravatar.cc/150?u=3" alt="User 3">
<div class="w-12 h-12 rounded-full border-4 border-white dark:border-slate-950 bg-indigo-600 flex items-center justify-center text-white text-xs font-bold shadow-xl">
:)
</div>
</div> </div>
<p class="text-xs font-bold text-slate-400"></p>
</div>
</div>
<!-- Hero Illustration / Mockup --> <h1 class="text-5xl md:text-7xl font-serif leading-[1.1] text-white">
<div class="lg:col-span-5 relative hidden lg:block"> Découvrez le potentiel de vos <span class="text-highlight">futures équipes</span>.
<div class="absolute -inset-4 bg-gradient-to-tr from-indigo-600 to-purple-600 rounded-[4rem] blur-3xl opacity-20 animate-pulse"></div> </h1>
<div class="relative bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-[3rem] shadow-2xl overflow-hidden aspect-[4/5] p-2">
<div class="bg-slate-50 dark:bg-slate-950 rounded-[2.5rem] h-full w-full p-8 border border-slate-100 dark:border-slate-800 flex flex-col justify-center gap-12 text-center"> <p class="text-lg md:text-xl text-sand font-sans font-light max-w-xl leading-relaxed">
<div class="w-24 h-24 bg-indigo-600/10 rounded-3xl mx-auto flex items-center justify-center"> Recru.IT simplifie le processus d'évaluation.
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> Générez des tests sur-mesure pour chaque poste et accédez à une analyse de compétences claire, précise et équitable.
<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" /> </p>
</svg>
</div> <div class="flex flex-col sm:flex-row gap-4 pt-4">
<div class="space-y-4"> <Link
<div class="text-3xl font-black uppercase tracking-tighter">Félicitations !</div> v-if="!$page.props.auth.user"
<p class="text-slate-500 text-sm">Votre score est de 95%. <br> Vous êtes prêt pour la suite.</p> :href="route('login')"
</div> class="inline-flex items-center justify-center px-10 py-4 bg-highlight text-[#3a2800] rounded-xl font-subtitle font-bold uppercase tracking-wider text-sm hover:brightness-110 hover:-translate-y-1 hover:shadow-2xl hover:shadow-highlight/40 transition-all duration-300"
<div class="flex flex-col gap-3"> >
<div class="h-4 w-full bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden"> S'identifier
<div class="h-full w-[95%] bg-indigo-600 rounded-full shadow-lg shadow-indigo-600/30"></div> </Link>
</div>
</div>
<!-- Right illustration (abstract or UI mockup) -->
<div class="hidden lg:flex justify-end relative h-full">
<div class="w-full max-w-md bg-sand rounded-3xl p-8 shadow-2xl relative transform rotate-2 hover:rotate-0 transition-transform duration-500 border border-white/10">
<!-- Fake dashboard element -->
<div class="space-y-6">
<div class="flex justify-between items-center border-b border-anthracite/10 pb-4">
<div class="h-6 w-32 bg-primary/20 rounded"></div>
<div class="h-6 w-12 bg-accent/20 rounded-full"></div>
</div> </div>
<div class="flex justify-between text-[10px] font-black uppercase text-slate-400"> <div class="grid grid-cols-2 gap-4">
<span>Rang S+</span> <div class="h-24 bg-white rounded-xl shadow-sm border border-anthracite/5 p-4 flex flex-col justify-between">
<span>Recruté</span> <div class="h-3 w-20 bg-neutral rounded"></div>
<div class="h-8 w-16 bg-primary/10 rounded"></div>
</div>
<div class="h-24 bg-white rounded-xl shadow-sm border border-anthracite/5 p-4 flex flex-col justify-between">
<div class="h-3 w-24 bg-neutral rounded"></div>
<div class="h-8 w-full bg-highlight/20 rounded"></div>
</div>
</div>
<div class="h-32 bg-white rounded-xl shadow-sm border border-anthracite/5 p-4 flex gap-4">
<div class="w-16 h-16 bg-sky/20 rounded-full shrink-0"></div>
<div class="flex-1 space-y-3 py-2">
<div class="h-3 w-[60%] bg-anthracite/20 rounded"></div>
<div class="h-2 w-[80%] bg-neutral rounded"></div>
<div class="h-2 w-[40%] bg-neutral rounded"></div>
</div>
</div>
</div>
<!-- Floating badge -->
<div class="absolute -left-12 bottom-12 bg-white p-5 rounded-2xl shadow-xl flex items-center gap-4 border border-sand/50 animate-bounce" style="animation-duration: 3s">
<div class="w-12 h-12 bg-accent rounded-full flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<p class="text-xs text-anthracite/50 font-subtitle font-bold uppercase tracking-wider">Candidat</p>
<p class="text-anthracite font-bold">Approuvé</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</main> </main>
<!-- Features Grid --> <!-- Features -->
<section class="relative z-10 bg-white dark:bg-slate-900 border-y border-slate-200 dark:border-slate-800 px-6 py-24 md:px-12"> <section class="relative z-10 bg-neutral px-6 py-24 md:px-12 -mt-16 pt-32">
<div class="max-w-7xl mx-auto"> <div class="max-w-7xl mx-auto">
<div class="text-center mb-20 space-y-4"> <div class="text-center mb-16 space-y-4">
<h2 class="text-4xl font-black uppercase tracking-tight text-slate-900 dark:text-white">Conçu pour les recruteurs</h2> <h2 class="text-3xl md:text-5xl font-serif text-primary">Un processus optimisé</h2>
<p class="text-slate-500 dark:text-slate-400 max-w-xl mx-auto">Une plateforme intuitive pour automatiser vos entretiens techniques et valoriser le potentiel de chaque candidat.</p> <p class="text-anthracite/70 font-sans max-w-2xl mx-auto text-lg pt-2">Pensé pour offrir la meilleure expérience d'évaluation technique aux communautés d'agglomération et leurs candidats.</p>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8"> <div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<!-- Feature 1 --> <!-- Feature 1 -->
<div class="p-10 bg-slate-50 dark:bg-slate-950 rounded-[2.5rem] border border-slate-100 dark:border-slate-800 hover:border-indigo-500 transition-colors group"> <div class="p-8 bg-white rounded-2xl shadow-md shadow-anthracite/5 border-b-4 border-primary hover:-translate-y-2 transition-transform duration-300">
<div class="w-14 h-14 bg-indigo-600/10 rounded-2xl flex items-center justify-center mb-8 group-hover:scale-110 transition-transform"> <div class="w-14 h-14 bg-sky/10 rounded-xl flex items-center justify-center mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg> </svg>
</div> </div>
<h3 class="text-xl font-bold mb-4">Quiz Dynamiques</h3> <h3 class="text-xl font-subtitle font-bold text-primary mb-3">Quiz Dynamiques</h3>
<p class="text-slate-500 dark:text-slate-400 text-sm leading-relaxed">Questions à choix multiples ou réponses ouvertes, adaptez vos tests au poste visé en quelques clics.</p> <p class="text-anthracite/70 font-sans text-sm leading-relaxed">Une génération intelligente de questions basées sur l'Intelligence Artificielle pour cibler les attentes du poste.</p>
</div> </div>
<!-- Feature 2 --> <!-- Feature 2 -->
<div class="p-10 bg-slate-50 dark:bg-slate-950 rounded-[2.5rem] border border-slate-100 dark:border-slate-800 hover:border-indigo-500 transition-colors group"> <div class="p-8 bg-white rounded-2xl shadow-md shadow-anthracite/5 border-b-4 border-highlight hover:-translate-y-2 transition-transform duration-300">
<div class="w-14 h-14 bg-emerald-600/10 rounded-2xl flex items-center justify-center mb-8 group-hover:scale-110 transition-transform"> <div class="w-14 h-14 bg-highlight/10 rounded-xl flex items-center justify-center mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-highlight" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg> </svg>
</div> </div>
<h3 class="text-xl font-bold mb-4">Audit & Sécurité</h3> <h3 class="text-xl font-subtitle font-bold text-anthracite mb-3">Sécurisé & Traçable</h3>
<p class="text-slate-500 dark:text-slate-400 text-sm leading-relaxed">Chaque action critique est journalisée pour une transparence totale sur vos recrutements.</p> <p class="text-anthracite/70 font-sans text-sm leading-relaxed">Respect de l'intégrité des compétences et de la RGPD, sans biais lors de l'analyse des profils.</p>
</div> </div>
<!-- Feature 3 --> <!-- Feature 3 -->
<div class="p-10 bg-slate-50 dark:bg-slate-950 rounded-[2.5rem] border border-slate-100 dark:border-slate-800 hover:border-indigo-500 transition-colors group"> <div class="p-8 bg-white rounded-2xl shadow-md shadow-anthracite/5 border-b-4 border-accent hover:-translate-y-2 transition-transform duration-300">
<div class="w-14 h-14 bg-purple-600/10 rounded-2xl flex items-center justify-center mb-8 group-hover:scale-110 transition-transform"> <div class="w-14 h-14 bg-accent/10 rounded-xl flex items-center justify-center mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-accent" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg> </svg>
</div> </div>
<h3 class="text-xl font-bold mb-4">Mobile First</h3> <h3 class="text-xl font-subtitle font-bold text-accent mb-3">Expérience fluide</h3>
<p class="text-slate-500 dark:text-slate-400 text-sm leading-relaxed">Les candidats passent leurs tests sur mobile ou desktop avec un confort inégalé.</p> <p class="text-anthracite/70 font-sans text-sm leading-relaxed">Une interface candidate repensée pour valoriser la marque employeur et simplifier la passation d'examens.</p>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<!-- Footer --> <!-- Footer -->
<footer class="relative z-10 px-6 py-20 text-center text-slate-400 text-xs font-black uppercase tracking-[0.2em]"> <footer class="relative bg-sand px-6 py-12 text-center border-t border-anthracite/10">
&copy; 2026 RecruitQuizz Advanced Recruitment Intelligence <p class="text-primary font-subtitle font-bold text-xs uppercase tracking-[0.1em]">
&copy; {{ new Date().getFullYear() }} Communauté d'Agglomération Béziers Méditerranée — Tous droits réservés
</p>
</footer> </footer>
</div> </div>
</template> </template>
<style> <style>
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;700;900&display=swap'); /* Import custom typographies */
@import url('https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,400;0,700;1,400;1,700&family=Nunito:ital,wght@0,400;0,600;0,700;1,400&display=swap');
.font-sans {
font-family: 'Outfit', sans-serif;
}
</style> </style>

View File

@@ -0,0 +1,366 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<title>Dossier Candidat - {{ $candidate->user->name }}</title>
<style>
@page {
margin: 0cm;
}
body {
font-family: 'Helvetica', 'Arial', sans-serif;
color: #2d3748;
line-height: 1.5;
margin: 0;
padding: 0;
background-color: #ffffff;
}
.header-stripe {
height: 15px;
background: linear-gradient(to right, #004f82, #e0b04c);
}
.container {
padding: 40px;
}
.page-break {
page-break-after: always;
}
/* Typography */
h1, h2, h3, h4 {
color: #004f82;
margin-bottom: 15px;
text-transform: uppercase;
letter-spacing: 1px;
}
h1 { font-size: 28px; border-bottom: 3px solid #e0b04c; padding-bottom: 10px; }
h2 { font-size: 20px; border-left: 5px solid #004f82; padding-left: 15px; margin-top: 30px; }
h3 { font-size: 16px; color: #4a5568; }
/* Candidate Details Card */
.card {
background-color: #f8f9fa;
border-radius: 15px;
padding: 20px;
margin-bottom: 30px;
border: 1px solid #e2e8f0;
}
.detail-row {
display: table;
width: 100%;
margin-bottom: 8px;
}
.detail-label {
display: table-cell;
width: 200px;
font-weight: bold;
color: #718096;
text-transform: uppercase;
font-size: 10px;
}
.detail-value {
display: table-cell;
font-weight: 500;
}
/* AI Section */
.ai-verdict {
display: inline-block;
padding: 8px 15px;
border-radius: 8px;
font-weight: bold;
font-size: 14px;
margin-bottom: 20px;
}
.verdict-positive { background-color: #def7ec; color: #03543f; }
.verdict-neutral { background-color: #fef3c7; color: #92400e; }
.verdict-negative { background-color: #fde8e8; color: #9b1c1c; }
.ai-box {
background-color: #f0f7ff;
border-left: 5px solid #004f82;
padding: 15px;
margin-bottom: 20px;
font-style: italic;
}
/* Tables & Lists */
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
th {
background-color: #004f82;
color: white;
text-align: left;
padding: 10px;
font-size: 12px;
text-transform: uppercase;
}
td {
padding: 10px;
border-bottom: 1px solid #e2e8f0;
font-size: 12px;
}
.badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: bold;
}
/* Evaluation Grid */
.grid-cell {
height: 30px;
border: 1px solid #cbd5e0;
}
.notes-box {
height: 100px;
border: 1px solid #cbd5e0;
margin-top: 5px;
}
/* Footer */
.footer {
position: fixed;
bottom: 0px;
left: 0px;
right: 0px;
height: 50px;
text-align: center;
font-size: 10px;
color: #a0aec0;
border-top: 1px solid #e2e8f0;
padding-top: 10px;
}
</style>
</head>
<body>
<div class="header-stripe"></div>
<div class="container">
<!-- HEADER SECTION -->
<div style="text-align: center; margin-bottom: 40px;">
<div style="font-size: 12px; font-weight: bold; color: #e0b04c; margin-bottom: 5px; text-transform: uppercase; letter-spacing: 2px;">CABM - Dossier de Synthèse</div>
<h1>{{ $candidate->user->name }}</h1>
<div style="font-size: 14px; color: #4a5568;">Candidature au poste de : <strong>{{ $candidate->jobPosition->title ?? 'Poste non défini' }}</strong></div>
</div>
<div class="card">
<div class="detail-row">
<div class="detail-label">Score Global Pondéré</div>
<div class="detail-value" style="font-size: 24px; color: #004f82; font-weight: 900;">{{ $candidate->weighted_score }}/20</div>
</div>
<div class="detail-row">
<div class="detail-label">Contact</div>
<div class="detail-value">{{ $candidate->user->email }} @if($candidate->phone) | {{ $candidate->phone }} @endif</div>
</div>
<div class="detail-row">
<div class="detail-label">Structure</div>
<div class="detail-value">{{ $candidate->tenant->name ?? 'N/A' }}</div>
</div>
@if($candidate->linkedin_url)
<div class="detail-row">
<div class="detail-label">LinkedIn</div>
<div class="detail-value">{{ $candidate->linkedin_url }}</div>
</div>
@endif
</div>
<!-- AI ANALYSIS -->
@if($candidate->ai_analysis)
<h2>Analyse Decisionnelle</h2>
<div class="ai-verdict {{ $candidate->ai_analysis['match_score'] >= 75 ? 'verdict-positive' : ($candidate->ai_analysis['match_score'] >= 50 ? 'verdict-neutral' : 'verdict-negative') }}">
Verdict : {{ $candidate->ai_analysis['verdict'] ?? 'N/A' }} ({{ $candidate->ai_analysis['match_score'] }}%)
</div>
<div class="ai-box">
{{ $candidate->ai_analysis['summary'] }}
</div>
<div style="display: table; width: 100%; margin-top: 20px;">
<div style="display: table-cell; width: 48%; padding-right: 2%;">
<h4 style="color: #057a55; font-size: 12px;">Points Forts</h4>
<ul style="font-size: 11px; padding-left: 20px;">
@foreach($candidate->ai_analysis['strengths'] ?? [] as $s)
<li>{{ $s }}</li>
@endforeach
</ul>
</div>
<div style="display: table-cell; width: 48%;">
<h4 style="color: #9b1c1c; font-size: 12px;">Points de Vigilance</h4>
<ul style="font-size: 11px; padding-left: 20px;">
@foreach($candidate->ai_analysis['gaps'] ?? [] as $g)
<li>{{ $g }}</li>
@endforeach
</ul>
</div>
</div>
@endif
<div class="page-break"></div>
<!-- TEST RESULTS -->
<h2>Résultats des Tests Techniques</h2>
@forelse($candidate->attempts as $attempt)
<div style="background-color: #f7fafc; padding: 15px; border-radius: 10px; margin-bottom: 25px;">
<h3 style="margin-top: 0;">{{ $attempt->quiz->title }} <span style="font-weight: normal; color: #a0aec0; font-size: 12px;">(Fait le {{ $attempt->finished_at->format('d/m/Y H:i') }})</span></h3>
<div style="font-size: 14px; font-weight: bold; margin-bottom: 15px;">Score : {{ $attempt->score }} / {{ $attempt->max_score }}</div>
<table style="background: white;">
<thead>
<tr>
<th style="width: 60%">Question</th>
<th style="width: 40%">Réponse / Score</th>
</tr>
</thead>
<tbody>
@php
$quizQuestions = $attempt->quiz->questions;
$answers = $attempt->answers->keyBy('question_id');
@endphp
@foreach($quizQuestions as $question)
@php $answer = $answers->get($question->id); @endphp
<tr>
<td>
<strong>{{ $question->label }}</strong>
@if($question->description)
<div style="font-size: 9px; color: #718096; margin-top: 4px;">{{ $question->description }}</div>
@endif
</td>
<td>
@if($answer)
@if($question->type === 'qcm')
<div style="color: {{ $answer->option?->is_correct ? '#057a55' : '#9b1c1c' }}; font-weight: bold;">
{{ $answer->option?->option_text ?? 'N/A' }}
({{ $answer->option?->is_correct ? 'Correct' : 'Incorrect' }})
</div>
@else
<div style="font-style: italic; color: #4a5568;">"{{ $answer->text_content }}"</div>
<div style="margin-top: 5px; font-weight: bold;">Note : {{ $answer->score }} / {{ $question->points }}</div>
@endif
@else
<span style="color: #a0aec0; font-style: italic;">Pas de réponse</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@empty
<p style="font-style: italic; color: #a0aec0;">Aucun test technique effectué.</p>
@endforelse
<div class="page-break"></div>
<!-- GRILLE D'EVALUATION PAPIER -->
<h2 style="background-color: #004f82; color: white; padding: 10px; border: none;">Grille d'Évaluation (Entretien)</h2>
<p style="font-size: 10px; color: #718096; margin-bottom: 20px;">Support pour prise de notes manuelle durant l'échange. Échelle de 0 à 10.</p>
<h3>1. Compétences Métier & Pré-requis</h3>
<table>
<thead>
<tr>
<th style="width: 40%">Critères</th>
@for($i=0; $i<=10; $i++) <th style="width: 4%; font-size: 8px; text-align: center;">{{ $i }}</th> @endfor
</tr>
</thead>
<tbody>
@php
$requirements = $candidate->jobPosition->requirements ?? ['Compétences techniques générales', 'Expérience domaine', 'Outils & Méthodes'];
@endphp
@foreach($requirements as $req)
<tr>
<td><strong>{{ $req }}</strong></td>
@for($i=0; $i<=10; $i++) <td style="border: 1px solid #e2e8f0; text-align: center;"></td> @endfor
</tr>
@endforeach
</tbody>
</table>
<div style="margin-bottom: 30px;">
<label style="font-size: 10px; font-weight: bold;">Commentaires Compétences :</label>
<div class="notes-box"></div>
</div>
<h3>2. Savoir être & Adaptabilité</h3>
<table>
<thead>
<tr>
<th style="width: 40%">Personnalité</th>
@for($i=0; $i<=10; $i++) <th style="width: 4%; font-size: 8px; text-align: center;">{{ $i }}</th> @endfor
</tr>
</thead>
<tbody>
@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)
<tr>
<td><strong>{{ $skill }}</strong></td>
@for($i=0; $i<=10; $i++) <td style="border: 1px solid #e2e8f0; text-align: center;"></td> @endfor
</tr>
@endforeach
</tbody>
</table>
<div style="margin-bottom: 30px;">
<label style="font-size: 10px; font-weight: bold;">Commentaires Savoir être :</label>
<div class="notes-box"></div>
</div>
<div class="page-break"></div>
<h3>3. Questions d'Entretien (Guide)</h3>
@if($candidate->ai_analysis && !empty($candidate->ai_analysis['questions_entretien_suggerees']))
@foreach($candidate->ai_analysis['questions_entretien_suggerees'] as $idx => $question)
<div style="margin-bottom: 20px;">
<div style="font-size: 12px; font-weight: bold; color: #004f82;">Q{{ $idx + 1 }}. {{ $question }}</div>
<div style="height: 60px; border-bottom: 1px dashed #cbd5e0; margin-top: 10px;"></div>
</div>
@endforeach
@else
<p style="font-style: italic; color: #a0aec0;">Aucune question suggérée. Utilisez vos questions standards.</p>
@for($i=1; $i<=5; $i++)
<div style="margin-bottom: 25px;">
<div style="height: 1px; background-color: #e2e8f0; margin-bottom: 5px;"></div>
<div style="height: 40px; border: 1px solid #f7fafc;"></div>
</div>
@endfor
@endif
<div style="margin-top: 40px; border: 2px solid #004f82; padding: 20px; border-radius: 15px;">
<div style="font-weight: bold; text-transform: uppercase; color: #004f82; margin-bottom: 10px; text-align: center;">Verdict Final & Avis Client</div>
<div style="display: table; width: 100%;">
<div style="display: table-cell; width: 33%; text-align: center; border-right: 1px solid #e2e8f0;">
<span style="display: inline-block; width: 15px; height: 15px; border: 2px solid #057a55;"></span>
<span style="font-size: 14px; font-weight: bold; color: #057a55; margin-left: 10px;">FAVORABLE</span>
</div>
<div style="display: table-cell; width: 33%; text-align: center; border-right: 1px solid #e2e8f0;">
<span style="display: inline-block; width: 15px; height: 15px; border: 2px solid #e0b04c;"></span>
<span style="font-size: 14px; font-weight: bold; color: #92400e; margin-left: 10px;">A REVOIR</span>
</div>
<div style="display: table-cell; width: 33%; text-align: center;">
<span style="display: inline-block; width: 15px; height: 15px; border: 2px solid #9b1c1c;"></span>
<span style="font-size: 14px; font-weight: bold; color: #9b1c1c; margin-left: 10px;">DEFAVORABLE</span>
</div>
</div>
<div style="margin-top: 20px;">
<label style="font-size: 10px; font-weight: bold;">Motivations du choix / Points à éclaircir :</label>
<div style="height: 150px;"></div>
</div>
</div>
</div>
<div class="footer">
Généré le {{ date('d/m/Y H:i') }} par CABM - Dossier Confidentiel
</div>
</body>
</html>

View File

@@ -12,10 +12,6 @@ use App\Http\Controllers\Auth\VerifyEmailController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware('guest')->group(function () { 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']) Route::get('login', [AuthenticatedSessionController::class, 'create'])
->name('login'); ->name('login');

View File

@@ -8,9 +8,6 @@ use Inertia\Inertia;
Route::get('/', function () { Route::get('/', function () {
return Inertia::render('Welcome', [ return Inertia::render('Welcome', [
'canLogin' => Route::has('login'), '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}/analyze', [\App\Http\Controllers\AIAnalysisController::class, 'analyze'])->name('candidates.analyze');
Route::post('/candidates/{candidate}/reset-password', [\App\Http\Controllers\CandidateController::class, 'resetPassword'])->name('candidates.reset-password'); Route::post('/candidates/{candidate}/reset-password', [\App\Http\Controllers\CandidateController::class, 'resetPassword'])->name('candidates.reset-password');
Route::get('/documents/{document}', [\App\Http\Controllers\DocumentController::class, 'show'])->name('documents.show'); Route::get('/documents/{document}', [\App\Http\Controllers\DocumentController::class, 'show'])->name('documents.show');
Route::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('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('job-positions', \App\Http\Controllers\JobPositionController::class)->only(['index', 'store', 'update', 'destroy']);

View File

@@ -14,8 +14,19 @@ export default {
theme: { theme: {
extend: { 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: { fontFamily: {
sans: ['Figtree', ...defaultTheme.fontFamily.sans], sans: ['Helvetica Neue', 'Arial', 'sans-serif'],
serif: ['Merriweather', 'Georgia', 'serif'],
subtitle: ['Nunito', 'Gill Sans', 'sans-serif'],
}, },
}, },
}, },