feat: implementation des dossiers candidats PDF, gestion des entretiens et optimisation de l'analyse IA
This commit is contained in:
11
.env.example
11
.env.example
@@ -1,7 +1,8 @@
|
||||
APP_NAME=Laravel
|
||||
APP_NAME=Recru.IT
|
||||
# PRODUCTION: Set to 'production' and set APP_DEBUG=false
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
|
||||
APP_LOCALE=en
|
||||
@@ -18,7 +19,8 @@ BCRYPT_ROUNDS=12
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
# PRODUCTION: Use 'error' to avoid exposing sensitive data in logs
|
||||
LOG_LEVEL=error
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
@@ -29,7 +31,8 @@ DB_CONNECTION=sqlite
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
# SECURITY: Must be 'true' in production to encrypt session data at rest
|
||||
SESSION_ENCRYPT=true
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -22,3 +22,8 @@
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
|
||||
# Debug & temporary scripts (never commit these)
|
||||
fix_*.php
|
||||
test-*.php
|
||||
scratch/
|
||||
|
||||
131
app/Http/Controllers/Admin/CandidateExportController.php
Normal file
131
app/Http/Controllers/Admin/CandidateExportController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -90,12 +90,23 @@ class AttemptController extends Controller
|
||||
|
||||
public function saveAnswer(Request $request, Attempt $attempt)
|
||||
{
|
||||
// Security: Verify the authenticated user owns this attempt
|
||||
$candidate = auth()->user()->candidate;
|
||||
if (!$candidate || $attempt->candidate_id !== $candidate->id) {
|
||||
abort(403, 'You are not authorized to submit answers for this attempt.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'question_id' => 'required|exists:questions,id',
|
||||
'option_id' => 'nullable|exists:options,id',
|
||||
'text_content' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// Extra guard: prevent answering a finished attempt
|
||||
if ($attempt->finished_at) {
|
||||
return response()->json(['error' => 'This attempt is already finished.'], 403);
|
||||
}
|
||||
|
||||
Answer::updateOrCreate(
|
||||
[
|
||||
'attempt_id' => $attempt->id,
|
||||
@@ -112,6 +123,12 @@ class AttemptController extends Controller
|
||||
|
||||
public function finish(Attempt $attempt)
|
||||
{
|
||||
// Security: Verify the authenticated user owns this attempt
|
||||
$candidate = auth()->user()->candidate;
|
||||
if (!$candidate || $attempt->candidate_id !== $candidate->id) {
|
||||
abort(403, 'You are not authorized to finish this attempt.');
|
||||
}
|
||||
|
||||
if ($attempt->finished_at) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ class BackupController extends Controller
|
||||
{
|
||||
public function download()
|
||||
{
|
||||
// Security: Only super admins can download backups containing all tenant data
|
||||
if (!auth()->user()->isSuperAdmin()) {
|
||||
abort(403, 'Seuls les super administrateurs peuvent télécharger des sauvegardes.');
|
||||
}
|
||||
|
||||
$databaseName = env('DB_DATABASE');
|
||||
$userName = env('DB_USERNAME');
|
||||
$password = env('DB_PASSWORD');
|
||||
|
||||
@@ -139,8 +139,20 @@ class CandidateController extends Controller
|
||||
$request->validate([
|
||||
'cv' => 'nullable|file|mimes:pdf|max:5120',
|
||||
'cover_letter' => 'nullable|file|mimes:pdf|max:5120',
|
||||
'name' => 'nullable|string|max:255',
|
||||
'email' => 'nullable|string|email|max:255|unique:users,email,' . $candidate->user_id,
|
||||
'phone' => 'nullable|string|max:255',
|
||||
'linkedin_url' => 'nullable|url|max:255',
|
||||
]);
|
||||
|
||||
// Update User info if name or email present
|
||||
if ($request->has('name') || $request->has('email')) {
|
||||
$candidate->user->update($request->only(['name', 'email']));
|
||||
}
|
||||
|
||||
// Update Candidate info
|
||||
$candidate->update($request->only(['phone', 'linkedin_url']));
|
||||
|
||||
if ($request->hasFile('cv')) {
|
||||
$this->replaceDocument($candidate, $request->file('cv'), 'cv');
|
||||
}
|
||||
@@ -149,20 +161,24 @@ class CandidateController extends Controller
|
||||
$this->replaceDocument($candidate, $request->file('cover_letter'), 'cover_letter');
|
||||
}
|
||||
|
||||
return back()->with('success', 'Documents mis à jour avec succès.');
|
||||
return back()->with('success', 'Profil mis à jour avec succès.');
|
||||
}
|
||||
|
||||
public function updateNotes(Request $request, Candidate $candidate)
|
||||
{
|
||||
$request->validate([
|
||||
'notes' => 'nullable|string',
|
||||
'interview_details' => 'nullable|array',
|
||||
'interview_score' => 'nullable|numeric|min:0|max:30',
|
||||
]);
|
||||
|
||||
$candidate->update([
|
||||
'notes' => $request->notes,
|
||||
'interview_details' => $request->interview_details,
|
||||
'interview_score' => $request->has('interview_score') ? $request->interview_score : $candidate->interview_score,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Notes mises à jour avec succès.');
|
||||
return back()->with('success', 'Entretien mis à jour avec succès.');
|
||||
}
|
||||
|
||||
public function updateScores(Request $request, Candidate $candidate)
|
||||
|
||||
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
|
||||
#[Fillable(['user_id', 'job_position_id', 'phone', 'linkedin_url', 'status', 'is_selected', 'notes', 'cv_score', 'motivation_score', 'interview_score', 'ai_analysis', 'tenant_id'])]
|
||||
#[Fillable(['user_id', 'job_position_id', 'phone', 'linkedin_url', 'status', 'is_selected', 'notes', 'cv_score', 'motivation_score', 'interview_score', 'interview_details', 'ai_analysis', 'tenant_id'])]
|
||||
class Candidate extends Model
|
||||
{
|
||||
use HasFactory, BelongsToTenant;
|
||||
@@ -31,6 +31,7 @@ class Candidate extends Model
|
||||
protected $casts = [
|
||||
'ai_analysis' => 'array',
|
||||
'is_selected' => 'boolean',
|
||||
'interview_details' => 'array',
|
||||
];
|
||||
|
||||
public function jobPosition(): BelongsTo
|
||||
|
||||
@@ -9,13 +9,14 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
|
||||
#[Fillable(['title', 'description', 'requirements', 'ai_prompt', 'tenant_id'])]
|
||||
#[Fillable(['title', 'description', 'requirements', 'ai_prompt', 'gemini_cache_id', 'gemini_cache_expires_at', 'tenant_id'])]
|
||||
class JobPosition extends Model
|
||||
{
|
||||
use HasFactory, BelongsToTenant;
|
||||
|
||||
protected $casts = [
|
||||
'requirements' => 'array',
|
||||
'gemini_cache_expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function candidates(): HasMany
|
||||
|
||||
@@ -79,14 +79,16 @@ class AIAnalysisService
|
||||
{
|
||||
$provider = $provider ?: env('AI_DEFAULT_PROVIDER', 'ollama');
|
||||
|
||||
$jobTitle = $candidate->jobPosition->title;
|
||||
$jobDesc = $candidate->jobPosition->description;
|
||||
$requirements = implode(", ", $candidate->jobPosition->requirements ?? []);
|
||||
$job = $candidate->jobPosition;
|
||||
$jobTitle = $job->title;
|
||||
$jobDesc = $job->description;
|
||||
$requirements = implode(", ", $job->requirements ?? []);
|
||||
|
||||
$prompt = "Tu es un expert en recrutement technique. Ton rôle est d'analyser le profil d'un candidat pour le poste de '{$jobTitle}'.";
|
||||
// Static Part: The job context and instructions (Cacheable)
|
||||
$staticPrompt = "Tu es un expert en recrutement technique spécialisé dans l'infrastructure et la cybersécurité. Ton rôle est d'analyser le profil d'un candidat pour le poste de '{$jobTitle}'.";
|
||||
|
||||
if (!$candidate->jobPosition->ai_prompt) {
|
||||
$prompt .= " Attache une grande importance aux compétences techniques et à l'expérience, mais aussi à la capacité d'intégration et à la motivation.
|
||||
if (!$job->ai_prompt) {
|
||||
$staticPrompt .= " Attache une grande importance aux compétences techniques et à l'expérience, mais aussi à la capacité d'intégration et à la motivation.
|
||||
|
||||
DESCRIPTION DU POSTE:
|
||||
{$jobDesc}
|
||||
@@ -94,11 +96,6 @@ class AIAnalysisService
|
||||
COMPÉTENCES REQUISES:
|
||||
{$requirements}
|
||||
|
||||
CONTENU DU CV:
|
||||
{$cvText}
|
||||
CONTENU DE LA LETTRE DE MOTIVATION:
|
||||
" . ($letterText ?? "Non fournie") . "
|
||||
|
||||
Fournis une analyse structurée en JSON avec les clés suivantes:
|
||||
- match_score: note de 0 à 100
|
||||
- summary: résumé de 3-4 phrases sur le profil
|
||||
@@ -106,8 +103,7 @@ class AIAnalysisService
|
||||
- gaps: liste des compétences manquantes ou points de vigilance
|
||||
- verdict: une conclusion (Favorable, Très Favorable, Réservé, Défavorable)";
|
||||
} else {
|
||||
// Context injection for the custom prompt
|
||||
$prompt .= "
|
||||
$staticPrompt .= "
|
||||
|
||||
CONTEXTE DU POSTE:
|
||||
{$jobDesc}
|
||||
@@ -115,23 +111,23 @@ class AIAnalysisService
|
||||
COMPÉTENCES REQUISES:
|
||||
{$requirements}
|
||||
|
||||
CONTENU DU CV DU CANDIDAT:
|
||||
{$cvText}
|
||||
|
||||
CONTENU DE LA LETTRE DE MOTIVATION:
|
||||
" . ($letterText ?? "Non fournie") . "
|
||||
|
||||
CONSIGNES D'ANALYSE SPÉCIFIQUES:
|
||||
" . $candidate->jobPosition->ai_prompt;
|
||||
" . $job->ai_prompt;
|
||||
}
|
||||
|
||||
$prompt .= "\n\nRéponds UNIQUEMENT en JSON pur, sans texte avant ou après. Assure-toi que le JSON est valide.";
|
||||
$staticPrompt .= "\n\nRéponds UNIQUEMENT en JSON pur, sans texte avant ou après. Assure-toi que le JSON est valide.";
|
||||
|
||||
// Dynamic Part: The candidate data (Not cached)
|
||||
$dynamicPrompt = "CONTENU DU CV DU CANDIDAT:\n{$cvText}\n\nCONTENU DE LA LETTRE DE MOTIVATION:\n" . ($letterText ?? "Non fournie");
|
||||
|
||||
// Full prompt for providers not using context caching
|
||||
$fullPrompt = $staticPrompt . "\n\n" . $dynamicPrompt;
|
||||
|
||||
$analysis = match ($provider) {
|
||||
'openai' => $this->callOpenAI($prompt),
|
||||
'anthropic' => $this->callAnthropic($prompt),
|
||||
'gemini' => $this->callGemini($prompt),
|
||||
default => $this->callOllama($prompt),
|
||||
'openai' => $this->callOpenAI($fullPrompt),
|
||||
'anthropic' => $this->callAnthropic($fullPrompt),
|
||||
'gemini' => $this->callGemini($dynamicPrompt, $staticPrompt, $job),
|
||||
default => $this->callOllama($fullPrompt),
|
||||
};
|
||||
|
||||
// Normalize keys for frontend compatibility
|
||||
@@ -257,29 +253,122 @@ class AIAnalysisService
|
||||
return $this->getSimulatedAnalysis();
|
||||
}
|
||||
|
||||
protected function callGemini(string $prompt)
|
||||
protected function callGemini(string $dynamicPrompt, ?string $staticPrompt = null, ?\App\Models\JobPosition $job = null)
|
||||
{
|
||||
$apiKey = env('GEMINI_API_KEY');
|
||||
if (!$apiKey) return $this->getSimulatedAnalysis();
|
||||
|
||||
try {
|
||||
$response = Http::timeout(60)->post("https://generativelanguage.googleapis.com/v1/models/gemini-2.5-flash:generateContent?key=" . $apiKey, [
|
||||
'contents' => [['parts' => [['text' => $prompt]]]]
|
||||
]);
|
||||
// Models to try in order (Updated for 2026 models)
|
||||
$models = [
|
||||
'gemini-3.1-flash-lite-preview',
|
||||
'gemini-3-flash-preview',
|
||||
'gemini-1.5-flash-latest'
|
||||
];
|
||||
|
||||
if ($response->successful()) {
|
||||
$text = $response->json('candidates.0.content.parts.0.text');
|
||||
return json_decode($this->extractJson($text), true);
|
||||
} else {
|
||||
Log::error("Gemini API Error: " . $response->status() . " - " . $response->body());
|
||||
foreach ($models as $model) {
|
||||
try {
|
||||
$version = (str_contains($model, '2.0') || str_contains($model, '3.')) ? 'v1beta' : 'v1';
|
||||
$url = "https://generativelanguage.googleapis.com/{$version}/models/{$model}:generateContent?key=" . $apiKey;
|
||||
|
||||
$generationConfig = [
|
||||
'temperature' => 0.2,
|
||||
'responseMimeType' => 'application/json'
|
||||
];
|
||||
|
||||
$payload = [
|
||||
'generationConfig' => $generationConfig,
|
||||
'contents' => [
|
||||
['role' => 'user', 'parts' => [['text' => $dynamicPrompt]]]
|
||||
]
|
||||
];
|
||||
|
||||
// Attempt to use Context Caching if static prompt and job are provided
|
||||
if ($staticPrompt && $job && $version === 'v1beta') {
|
||||
$cacheId = $this->getOrCreateContextCache($job, $staticPrompt, $model);
|
||||
if ($cacheId) {
|
||||
$payload['cachedContent'] = $cacheId;
|
||||
// When using cache, the static part is already in the cache
|
||||
} else {
|
||||
// Fallback: prepend static part if cache fails
|
||||
$payload['contents'][0]['parts'][0]['text'] = $staticPrompt . "\n\n" . $dynamicPrompt;
|
||||
}
|
||||
} else if ($staticPrompt) {
|
||||
// Non-cached fallback
|
||||
$payload['contents'][0]['parts'][0]['text'] = $staticPrompt . "\n\n" . $dynamicPrompt;
|
||||
}
|
||||
|
||||
$response = Http::timeout(60)->post($url, $payload);
|
||||
|
||||
if ($response->successful()) {
|
||||
$candidate = $response->json('candidates.0');
|
||||
if (isset($candidate['finishReason']) && $candidate['finishReason'] !== 'STOP') {
|
||||
Log::warning("Gemini warning: Analysis finished with reason " . $candidate['finishReason']);
|
||||
}
|
||||
|
||||
$text = $candidate['content']['parts'][0]['text'] ?? null;
|
||||
if ($text) {
|
||||
$json = $this->extractJson($text);
|
||||
$decoded = json_decode($json, true);
|
||||
if ($decoded) return $decoded;
|
||||
}
|
||||
} else {
|
||||
Log::error("Gemini API Error ($model): " . $response->status() . " - " . $response->body());
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Gemini Connection Failed ($model): " . $e->getMessage());
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Gemini Connection Failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->getSimulatedAnalysis();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a Gemini Context Cache for a specific Job Position.
|
||||
*/
|
||||
protected function getOrCreateContextCache(\App\Models\JobPosition $job, string $staticPrompt, string $model)
|
||||
{
|
||||
|
||||
if (strlen($staticPrompt) < 120000) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we already have a valid cache for this job
|
||||
if ($job->gemini_cache_id && $job->gemini_cache_expires_at && $job->gemini_cache_expires_at->isFuture()) {
|
||||
// Basic verification: the cache is tied to a specific model
|
||||
// We assume the stored cache is for the primary model
|
||||
return $job->gemini_cache_id;
|
||||
}
|
||||
|
||||
$apiKey = env('GEMINI_API_KEY');
|
||||
try {
|
||||
// Create Context Cache (TTL of 1 hour)
|
||||
$response = Http::timeout(30)->post("https://generativelanguage.googleapis.com/v1beta/cachedContents?key=" . $apiKey, [
|
||||
'model' => "models/{$model}",
|
||||
'contents' => [
|
||||
['role' => 'user', 'parts' => [['text' => $staticPrompt]]]
|
||||
],
|
||||
'ttl' => '3600s'
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$cacheId = $response->json('name');
|
||||
$job->update([
|
||||
'gemini_cache_id' => $cacheId,
|
||||
'gemini_cache_expires_at' => now()->addHour()
|
||||
]);
|
||||
return $cacheId;
|
||||
}
|
||||
|
||||
// Log l'erreur pour comprendre pourquoi le cache a été refusé
|
||||
Log::warning("Gemini Cache Refused: " . $response->body());
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Gemini Cache Lifecycle Error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function extractJson($string)
|
||||
{
|
||||
preg_match('/\{.*\}/s', $string, $matches);
|
||||
|
||||
@@ -18,12 +18,9 @@ trait BelongsToTenant
|
||||
return;
|
||||
}
|
||||
|
||||
// Candidates don't have a tenant_id but must access
|
||||
// quizzes/job positions linked to their position
|
||||
if ($user->role === 'candidate') {
|
||||
return;
|
||||
}
|
||||
|
||||
// All other users (admins and candidates) are filtered by their tenant.
|
||||
// This includes candidates, who must only see data from their own organization.
|
||||
// Resources with a null tenant_id are considered global and always visible.
|
||||
if ($user->tenant_id) {
|
||||
$builder->where(function ($query) use ($user) {
|
||||
$query->where('tenant_id', $user->tenant_id)
|
||||
|
||||
@@ -7,11 +7,15 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"fpdf/fpdf": "^1.86",
|
||||
"inertiajs/inertia-laravel": "^2.0",
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/tinker": "^3.0",
|
||||
"setasign/fpdi": "2.6",
|
||||
"smalot/pdfparser": "^2.12",
|
||||
"tecnickcom/tcpdf": "^6.11",
|
||||
"tightenco/ziggy": "^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
|
||||
720
composer.lock
generated
720
composer.lock
generated
@@ -4,8 +4,85 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "6b34d5dd0c12bcfc3d1253f72a392749",
|
||||
"content-hash": "d92de938914aa91aa69bd500464d10d5",
|
||||
"packages": [
|
||||
{
|
||||
"name": "barryvdh/laravel-dompdf",
|
||||
"version": "v3.1.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/barryvdh/laravel-dompdf.git",
|
||||
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/ee3b72b19ccdf57d0243116ecb2b90261344dedc",
|
||||
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dompdf/dompdf": "^3.0",
|
||||
"illuminate/support": "^9|^10|^11|^12|^13.0",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"larastan/larastan": "^2.7|^3.0",
|
||||
"orchestra/testbench": "^7|^8|^9.16|^10|^11.0",
|
||||
"phpro/grumphp": "^2.5",
|
||||
"squizlabs/php_codesniffer": "^3.5"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"PDF": "Barryvdh\\DomPDF\\Facade\\Pdf",
|
||||
"Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf"
|
||||
},
|
||||
"providers": [
|
||||
"Barryvdh\\DomPDF\\ServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Barryvdh\\DomPDF\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Barry vd. Heuvel",
|
||||
"email": "barryvdh@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A DOMPDF Wrapper for Laravel",
|
||||
"keywords": [
|
||||
"dompdf",
|
||||
"laravel",
|
||||
"pdf"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/barryvdh/laravel-dompdf/issues",
|
||||
"source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://fruitcake.nl",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/barryvdh",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-21T08:51:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.14.8",
|
||||
@@ -377,6 +454,161 @@
|
||||
],
|
||||
"time": "2024-02-05T11:56:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/dompdf",
|
||||
"version": "v3.1.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/dompdf.git",
|
||||
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
|
||||
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dompdf/php-font-lib": "^1.0.0",
|
||||
"dompdf/php-svg-lib": "^1.0.0",
|
||||
"ext-dom": "*",
|
||||
"ext-mbstring": "*",
|
||||
"masterminds/html5": "^2.0",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-gd": "*",
|
||||
"ext-json": "*",
|
||||
"ext-zip": "*",
|
||||
"mockery/mockery": "^1.3",
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
|
||||
"squizlabs/php_codesniffer": "^3.5",
|
||||
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-gd": "Needed to process images",
|
||||
"ext-gmagick": "Improves image processing performance",
|
||||
"ext-imagick": "Improves image processing performance",
|
||||
"ext-zlib": "Needed for pdf stream compression"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Dompdf\\": "src/"
|
||||
},
|
||||
"classmap": [
|
||||
"lib/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-2.1"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The Dompdf Community",
|
||||
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
|
||||
"homepage": "https://github.com/dompdf/dompdf",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/dompdf/issues",
|
||||
"source": "https://github.com/dompdf/dompdf/tree/v3.1.5"
|
||||
},
|
||||
"time": "2026-03-03T13:54:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/php-font-lib",
|
||||
"version": "1.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/php-font-lib.git",
|
||||
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
|
||||
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"FontLib\\": "src/FontLib"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-2.1-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The FontLib Community",
|
||||
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "A library to read, parse, export and make subsets of different types of font files.",
|
||||
"homepage": "https://github.com/dompdf/php-font-lib",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/php-font-lib/issues",
|
||||
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
|
||||
},
|
||||
"time": "2026-01-20T14:10:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/php-svg-lib",
|
||||
"version": "1.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/php-svg-lib.git",
|
||||
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
|
||||
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0",
|
||||
"sabberworm/php-css-parser": "^8.4 || ^9.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Svg\\": "src/Svg"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-3.0-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The SvgLib Community",
|
||||
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "A library to read, parse and export to PDF SVG files.",
|
||||
"homepage": "https://github.com/dompdf/php-svg-lib",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/php-svg-lib/issues",
|
||||
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
|
||||
},
|
||||
"time": "2026-01-02T16:01:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dragonmantank/cron-expression",
|
||||
"version": "v3.6.0",
|
||||
@@ -508,6 +740,59 @@
|
||||
],
|
||||
"time": "2025-03-06T22:45:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "fpdf/fpdf",
|
||||
"version": "1.86.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/coreydoughty/Fpdf.git",
|
||||
"reference": "2034ab9f7b03b8294933d7fd27828d13963368e5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/coreydoughty/Fpdf/zipball/2034ab9f7b03b8294933d7fd27828d13963368e5",
|
||||
"reference": "2034ab9f7b03b8294933d7fd27828d13963368e5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.6.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"FPDF": "Fpdf\\Fpdf"
|
||||
}
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Fpdf\\": "src/Fpdf"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Corey Doughty",
|
||||
"email": "corey@doughty.ca"
|
||||
}
|
||||
],
|
||||
"description": "FPDF Composer Wrapper",
|
||||
"homepage": "https://github.com/coreydoughty/Fpdf",
|
||||
"keywords": [
|
||||
"fpdf",
|
||||
"pdf",
|
||||
"wrapper"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/coreydoughty/Fpdf/issues",
|
||||
"source": "https://github.com/coreydoughty/Fpdf/tree/1.86.1"
|
||||
},
|
||||
"time": "2025-12-08T14:03:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "fruitcake/php-cors",
|
||||
"version": "v1.4.0",
|
||||
@@ -2158,6 +2443,73 @@
|
||||
],
|
||||
"time": "2026-03-08T20:05:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "masterminds/html5",
|
||||
"version": "2.10.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Masterminds/html5-php.git",
|
||||
"reference": "fcf91eb64359852f00d921887b219479b4f21251"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
|
||||
"reference": "fcf91eb64359852f00d921887b219479b4f21251",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"php": ">=5.3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.7-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Masterminds\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Matt Butcher",
|
||||
"email": "technosophos@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Matt Farina",
|
||||
"email": "matt@mattfarina.com"
|
||||
},
|
||||
{
|
||||
"name": "Asmir Mustafic",
|
||||
"email": "goetas@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "An HTML5 parser and serializer.",
|
||||
"homepage": "http://masterminds.github.io/html5-php",
|
||||
"keywords": [
|
||||
"HTML5",
|
||||
"dom",
|
||||
"html",
|
||||
"parser",
|
||||
"querypath",
|
||||
"serializer",
|
||||
"xml"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Masterminds/html5-php/issues",
|
||||
"source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
|
||||
},
|
||||
"time": "2025-07-25T09:04:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
"version": "3.10.0",
|
||||
@@ -3433,6 +3785,158 @@
|
||||
},
|
||||
"time": "2025-12-14T04:43:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabberworm/php-css-parser",
|
||||
"version": "v9.3.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
|
||||
"reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949",
|
||||
"reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-iconv": "*",
|
||||
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
|
||||
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-parallel-lint/php-parallel-lint": "1.4.0",
|
||||
"phpstan/extension-installer": "1.4.3",
|
||||
"phpstan/phpstan": "1.12.32 || 2.1.32",
|
||||
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.8",
|
||||
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7",
|
||||
"phpunit/phpunit": "8.5.52",
|
||||
"rawr/phpunit-data-provider": "3.3.1",
|
||||
"rector/rector": "1.2.10 || 2.2.8",
|
||||
"rector/type-perfect": "1.0.0 || 2.1.0",
|
||||
"squizlabs/php_codesniffer": "4.0.1",
|
||||
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-mbstring": "for parsing UTF-8 CSS"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "9.4.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/Rule/Rule.php",
|
||||
"src/RuleSet/RuleContainer.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sabberworm\\CSS\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Raphael Schweikert"
|
||||
},
|
||||
{
|
||||
"name": "Oliver Klee",
|
||||
"email": "github@oliverklee.de"
|
||||
},
|
||||
{
|
||||
"name": "Jake Hotson",
|
||||
"email": "jake.github@qzdesign.co.uk"
|
||||
}
|
||||
],
|
||||
"description": "Parser for CSS Files written in PHP",
|
||||
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
|
||||
"keywords": [
|
||||
"css",
|
||||
"parser",
|
||||
"stylesheet"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
|
||||
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0"
|
||||
},
|
||||
"time": "2026-03-03T17:31:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "setasign/fpdi",
|
||||
"version": "v2.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Setasign/FPDI.git",
|
||||
"reference": "a6db878129ec6c7e141316ee71872923e7f1b7ad"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Setasign/FPDI/zipball/a6db878129ec6c7e141316ee71872923e7f1b7ad",
|
||||
"reference": "a6db878129ec6c7e141316ee71872923e7f1b7ad",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-zlib": "*",
|
||||
"php": "^5.6 || ^7.0 || ^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"setasign/tfpdf": "<1.31"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "~5.7",
|
||||
"setasign/fpdf": "~1.8.6",
|
||||
"setasign/tfpdf": "~1.33",
|
||||
"squizlabs/php_codesniffer": "^3.5",
|
||||
"tecnickcom/tcpdf": "~6.2"
|
||||
},
|
||||
"suggest": {
|
||||
"setasign/fpdf": "FPDI will extend this class but as it is also possible to use TCPDF or tFPDF as an alternative. There's no fixed dependency configured."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"setasign\\Fpdi\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jan Slabon",
|
||||
"email": "jan.slabon@setasign.com",
|
||||
"homepage": "https://www.setasign.com"
|
||||
},
|
||||
{
|
||||
"name": "Maximilian Kresse",
|
||||
"email": "maximilian.kresse@setasign.com",
|
||||
"homepage": "https://www.setasign.com"
|
||||
}
|
||||
],
|
||||
"description": "FPDI is a collection of PHP classes facilitating developers to read pages from existing PDF documents and use them as templates in FPDF. Because it is also possible to use FPDI with TCPDF, there are no fixed dependencies defined. Please see suggestions for packages which evaluates the dependencies automatically.",
|
||||
"homepage": "https://www.setasign.com/fpdi",
|
||||
"keywords": [
|
||||
"fpdf",
|
||||
"fpdi",
|
||||
"pdf"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Setasign/FPDI/issues",
|
||||
"source": "https://github.com/Setasign/FPDI/tree/v2.6.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/setasign/fpdi",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2023-12-11T16:03:32+00:00"
|
||||
},
|
||||
{
|
||||
"name": "smalot/pdfparser",
|
||||
"version": "v2.12.4",
|
||||
@@ -5985,6 +6489,220 @@
|
||||
],
|
||||
"time": "2026-02-15T10:53:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "tecnickcom/tcpdf",
|
||||
"version": "6.11.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tecnickcom/TCPDF.git",
|
||||
"reference": "e1e2ade18e574e963473f53271591edd8c0033ec"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/tecnickcom/TCPDF/zipball/e1e2ade18e574e963473f53271591edd8c0033ec",
|
||||
"reference": "e1e2ade18e574e963473f53271591edd8c0033ec",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-curl": "*",
|
||||
"php": ">=7.1.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"config",
|
||||
"include",
|
||||
"tcpdf.php",
|
||||
"tcpdf_barcodes_1d.php",
|
||||
"tcpdf_barcodes_2d.php",
|
||||
"include/tcpdf_colors.php",
|
||||
"include/tcpdf_filters.php",
|
||||
"include/tcpdf_font_data.php",
|
||||
"include/tcpdf_fonts.php",
|
||||
"include/tcpdf_images.php",
|
||||
"include/tcpdf_static.php",
|
||||
"include/barcodes/datamatrix.php",
|
||||
"include/barcodes/pdf417.php",
|
||||
"include/barcodes/qrcode.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-3.0-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicola Asuni",
|
||||
"email": "info@tecnick.com",
|
||||
"role": "lead"
|
||||
}
|
||||
],
|
||||
"description": "TCPDF is a PHP class for generating PDF documents and barcodes.",
|
||||
"homepage": "http://www.tcpdf.org/",
|
||||
"keywords": [
|
||||
"PDFD32000-2008",
|
||||
"TCPDF",
|
||||
"barcodes",
|
||||
"datamatrix",
|
||||
"pdf",
|
||||
"pdf417",
|
||||
"qrcode"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/tecnickcom/TCPDF/issues",
|
||||
"source": "https://github.com/tecnickcom/TCPDF/tree/6.11.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://www.paypal.com/donate/?hosted_button_id=NZUEC5XS8MFBJ",
|
||||
"type": "custom"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-03T08:58:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "thecodingmachine/safe",
|
||||
"version": "v3.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thecodingmachine/safe.git",
|
||||
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
|
||||
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-parallel-lint/php-parallel-lint": "^1.4",
|
||||
"phpstan/phpstan": "^2",
|
||||
"phpunit/phpunit": "^10",
|
||||
"squizlabs/php_codesniffer": "^3.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"lib/special_cases.php",
|
||||
"generated/apache.php",
|
||||
"generated/apcu.php",
|
||||
"generated/array.php",
|
||||
"generated/bzip2.php",
|
||||
"generated/calendar.php",
|
||||
"generated/classobj.php",
|
||||
"generated/com.php",
|
||||
"generated/cubrid.php",
|
||||
"generated/curl.php",
|
||||
"generated/datetime.php",
|
||||
"generated/dir.php",
|
||||
"generated/eio.php",
|
||||
"generated/errorfunc.php",
|
||||
"generated/exec.php",
|
||||
"generated/fileinfo.php",
|
||||
"generated/filesystem.php",
|
||||
"generated/filter.php",
|
||||
"generated/fpm.php",
|
||||
"generated/ftp.php",
|
||||
"generated/funchand.php",
|
||||
"generated/gettext.php",
|
||||
"generated/gmp.php",
|
||||
"generated/gnupg.php",
|
||||
"generated/hash.php",
|
||||
"generated/ibase.php",
|
||||
"generated/ibmDb2.php",
|
||||
"generated/iconv.php",
|
||||
"generated/image.php",
|
||||
"generated/imap.php",
|
||||
"generated/info.php",
|
||||
"generated/inotify.php",
|
||||
"generated/json.php",
|
||||
"generated/ldap.php",
|
||||
"generated/libxml.php",
|
||||
"generated/lzf.php",
|
||||
"generated/mailparse.php",
|
||||
"generated/mbstring.php",
|
||||
"generated/misc.php",
|
||||
"generated/mysql.php",
|
||||
"generated/mysqli.php",
|
||||
"generated/network.php",
|
||||
"generated/oci8.php",
|
||||
"generated/opcache.php",
|
||||
"generated/openssl.php",
|
||||
"generated/outcontrol.php",
|
||||
"generated/pcntl.php",
|
||||
"generated/pcre.php",
|
||||
"generated/pgsql.php",
|
||||
"generated/posix.php",
|
||||
"generated/ps.php",
|
||||
"generated/pspell.php",
|
||||
"generated/readline.php",
|
||||
"generated/rnp.php",
|
||||
"generated/rpminfo.php",
|
||||
"generated/rrd.php",
|
||||
"generated/sem.php",
|
||||
"generated/session.php",
|
||||
"generated/shmop.php",
|
||||
"generated/sockets.php",
|
||||
"generated/sodium.php",
|
||||
"generated/solr.php",
|
||||
"generated/spl.php",
|
||||
"generated/sqlsrv.php",
|
||||
"generated/ssdeep.php",
|
||||
"generated/ssh2.php",
|
||||
"generated/stream.php",
|
||||
"generated/strings.php",
|
||||
"generated/swoole.php",
|
||||
"generated/uodbc.php",
|
||||
"generated/uopz.php",
|
||||
"generated/url.php",
|
||||
"generated/var.php",
|
||||
"generated/xdiff.php",
|
||||
"generated/xml.php",
|
||||
"generated/xmlrpc.php",
|
||||
"generated/yaml.php",
|
||||
"generated/yaz.php",
|
||||
"generated/zip.php",
|
||||
"generated/zlib.php"
|
||||
],
|
||||
"classmap": [
|
||||
"lib/DateTime.php",
|
||||
"lib/DateTimeImmutable.php",
|
||||
"lib/Exceptions/",
|
||||
"generated/Exceptions/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
|
||||
"support": {
|
||||
"issues": "https://github.com/thecodingmachine/safe/issues",
|
||||
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/OskarStark",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/shish",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/silasjoisten",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/staabm",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-04T18:08:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "tightenco/ziggy",
|
||||
"version": "v2.6.2",
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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 />
|
||||
</button>
|
||||
|
||||
@@ -11,121 +11,133 @@ const isSidebarOpen = ref(true);
|
||||
|
||||
<template>
|
||||
<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 -->
|
||||
<aside
|
||||
: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">
|
||||
<Link :href="route('dashboard')" class="flex items-center gap-3 overflow-hidden">
|
||||
<ApplicationLogo class="h-8 w-8 fill-indigo-600" />
|
||||
<span v-if="isSidebarOpen" class="font-bold text-xl tracking-tight whitespace-nowrap">Recrut.IT</span>
|
||||
<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 ml-1">
|
||||
<div class="w-8 h-8 bg-highlight rounded flex items-center justify-center shrink-0 shadow-sm shadow-highlight/20">
|
||||
<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>
|
||||
</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
|
||||
:href="route('dashboard')"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors"
|
||||
: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="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-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" />
|
||||
</svg>
|
||||
<span v-if="isSidebarOpen">Tableau de bord</span>
|
||||
<span v-if="isSidebarOpen" class="truncate">Tableau de bord</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
:href="route('admin.candidates.index')"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors"
|
||||
: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="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-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" />
|
||||
</svg>
|
||||
<span v-if="isSidebarOpen">Candidats</span>
|
||||
<span v-if="isSidebarOpen" class="truncate">Candidats</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
:href="route('admin.quizzes.index')"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors"
|
||||
: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="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-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" />
|
||||
</svg>
|
||||
<span v-if="isSidebarOpen">Quiz</span>
|
||||
<span v-if="isSidebarOpen" class="truncate">Quiz</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
:href="route('admin.job-positions.index')"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors"
|
||||
:class="[route().current('admin.job-positions.*') ? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400' : 'hover:bg-slate-50 dark:hover:bg-slate-700/50']"
|
||||
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-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" />
|
||||
</svg>
|
||||
<span v-if="isSidebarOpen">Fiches de Poste</span>
|
||||
<span v-if="isSidebarOpen" class="truncate">Fiches de Poste</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
:href="route('admin.comparative')"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors"
|
||||
: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="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-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" />
|
||||
</svg>
|
||||
<span v-if="isSidebarOpen">Comparateur</span>
|
||||
<span v-if="isSidebarOpen" class="truncate">Comparateur</span>
|
||||
</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
|
||||
v-if="$page.props.auth.user.role === 'super_admin'"
|
||||
:href="route('admin.tenants.index')"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors"
|
||||
: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="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-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" />
|
||||
</svg>
|
||||
<span v-if="isSidebarOpen">Structures</span>
|
||||
<span v-if="isSidebarOpen" class="truncate">Structures</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
v-if="$page.props.auth.user.role === 'super_admin'"
|
||||
:href="route('admin.users.index')"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors"
|
||||
: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="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-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" />
|
||||
</svg>
|
||||
<span v-if="isSidebarOpen">Équipe SaaS</span>
|
||||
<span v-if="isSidebarOpen" class="truncate">Équipe SaaS</span>
|
||||
</Link>
|
||||
</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
|
||||
@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" />
|
||||
</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" />
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="isSidebarOpen" class="mt-4 text-[10px] text-slate-400 text-center font-mono">
|
||||
v{{ $page.props.app_version }}
|
||||
<div v-show="isSidebarOpen" class="mt-4 text-[9px] font-bold uppercase tracking-widest text-[#3a7abf] text-center">
|
||||
App v{{ $page.props.app_version }}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<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">
|
||||
<div class="flex-1 flex flex-col min-w-0 overflow-hidden bg-neutral">
|
||||
<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>
|
||||
<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" />
|
||||
</h2>
|
||||
</div>
|
||||
@@ -133,25 +145,47 @@ const isSidebarOpen = ref(true);
|
||||
<div class="flex items-center gap-4">
|
||||
<Dropdown align="right" width="48">
|
||||
<template #trigger>
|
||||
<button class="flex items-center gap-2 text-sm font-medium hover:text-indigo-600 transition-colors">
|
||||
{{ $page.props.auth.user.name }}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<button class="flex items-center gap-2 text-sm font-subtitle font-bold hover:text-primary transition-colors cursor-pointer py-2">
|
||||
<div class="w-8 h-8 rounded-full bg-sand flex items-center justify-center text-primary border border-primary/10">
|
||||
{{ $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" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<DropdownLink :href="route('profile.edit')">Profil</DropdownLink>
|
||||
<DropdownLink :href="route('admin.backup')" as="a">Sauvegarde App</DropdownLink>
|
||||
<DropdownLink :href="route('logout')" method="post" as="button">Déconnexion</DropdownLink>
|
||||
<DropdownLink :href="route('profile.edit')">Paramètres du profil</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>
|
||||
<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>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 overflow-y-auto p-8">
|
||||
<main class="flex-1 overflow-y-auto p-4 md:p-8">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -6,19 +6,33 @@ import EnvironmentBanner from '@/Components/EnvironmentBanner.vue';
|
||||
|
||||
<template>
|
||||
<EnvironmentBanner />
|
||||
<div
|
||||
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 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">
|
||||
|
||||
<div class="w-full max-w-md px-6">
|
||||
<!-- Header and Logo -->
|
||||
<div class="mb-8 flex flex-col justify-center items-center gap-4">
|
||||
<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>
|
||||
|
||||
<div
|
||||
class="mt-6 w-full overflow-hidden bg-white px-6 py-4 shadow-md sm:max-w-md sm:rounded-lg"
|
||||
>
|
||||
<slot />
|
||||
<!-- 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">
|
||||
← Retour à l'accueil
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -172,24 +172,26 @@ const batchAnalyze = async () => {
|
||||
Gestion des Candidats
|
||||
</template>
|
||||
|
||||
<div class="flex justify-between items-end mb-8">
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-2xl font-bold">Liste des Candidats</h3>
|
||||
<div class="flex items-center gap-6">
|
||||
<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">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-end mb-8 gap-6">
|
||||
<div class="space-y-4 w-full md:w-auto">
|
||||
<h3 class="text-3xl font-serif font-black text-primary capitalize tracking-tight flex items-center gap-3">
|
||||
<div class="w-1.5 h-8 bg-highlight rounded-full"></div>
|
||||
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">
|
||||
<input type="checkbox" v-model="showOnlySelected" class="rounded border-amber-300 text-amber-500 focus:ring-amber-500/20 cursor-pointer">
|
||||
<span class="text-sm font-bold text-slate-700 dark:text-slate-300">Retenus uniquement</span>
|
||||
<input type="checkbox" v-model="showOnlySelected" class="rounded border-highlight/50 text-highlight focus:ring-highlight/20 cursor-pointer">
|
||||
<span class="text-xs font-bold text-primary uppercase tracking-widest">Retenus uniquement</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">Filtrer par fiche de poste :</label>
|
||||
<div class="flex items-center gap-3 w-full sm:w-auto">
|
||||
<select
|
||||
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="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">
|
||||
{{ jp.title }}
|
||||
</option>
|
||||
@@ -197,15 +199,15 @@ const batchAnalyze = async () => {
|
||||
</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">
|
||||
<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
|
||||
@click="batchAnalyze"
|
||||
: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>
|
||||
<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>
|
||||
@@ -214,7 +216,7 @@ const batchAnalyze = async () => {
|
||||
</svg>
|
||||
{{ isBatchAnalyzing ? `Analyse ${analysisProgress.current}/${analysisProgress.total}...` : 'Analyse IA groupée' }}
|
||||
</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>
|
||||
<PrimaryButton @click="isModalOpen = true">
|
||||
Ajouter un Candidat
|
||||
@@ -223,211 +225,184 @@ const batchAnalyze = async () => {
|
||||
</div>
|
||||
|
||||
<!-- 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 class="p-2 bg-emerald-500 rounded-lg text-white">
|
||||
<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 shadow-sm">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-emerald-800 dark:text-emerald-400">Succès !</p>
|
||||
<p class="text-emerald-700 dark:text-emerald-500 text-sm">{{ flashSuccess }}</p>
|
||||
<p class="font-bold text-emerald-800">Succès !</p>
|
||||
<p class="text-emerald-700 text-sm font-medium">{{ flashSuccess }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Candidates Table -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<table class="w-full text-left">
|
||||
<thead class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
|
||||
<tr>
|
||||
<th class="w-12 px-6 py-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedIds.length === sortedCandidates.length && sortedCandidates.length > 0"
|
||||
@change="toggleSelectAll"
|
||||
class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500/20 cursor-pointer"
|
||||
>
|
||||
</th>
|
||||
<th class="w-12 px-6 py-4"></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">
|
||||
<div class="flex items-center gap-2">
|
||||
Nom
|
||||
<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">
|
||||
<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('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 class="bg-white rounded-3xl shadow-sm border border-anthracite/5 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead class="bg-neutral/50 border-b border-anthracite/5">
|
||||
<tr>
|
||||
<th class="w-12 px-8 py-5">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedIds.length === sortedCandidates.length && sortedCandidates.length > 0"
|
||||
@change="toggleSelectAll"
|
||||
class="rounded border-anthracite/20 text-primary focus:ring-primary/20 cursor-pointer"
|
||||
>
|
||||
</th>
|
||||
<th class="w-12 px-4 py-5"></th>
|
||||
<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">
|
||||
<div class="flex items-center gap-2">
|
||||
Nom
|
||||
<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>
|
||||
</div>
|
||||
<span class="font-black text-sm" :class="{
|
||||
'text-emerald-600': candidate.weighted_score >= 14,
|
||||
'text-amber-600': candidate.weighted_score >= 10 && candidate.weighted_score < 14,
|
||||
'text-rose-600': candidate.weighted_score < 10
|
||||
}">
|
||||
{{ candidate.weighted_score }}
|
||||
</th>
|
||||
<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">
|
||||
<div class="flex items-center gap-2">
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div v-if="candidate.ai_analysis" class="flex items-center gap-2">
|
||||
<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 }}%
|
||||
</td>
|
||||
<td class="px-8 py-5">
|
||||
<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">
|
||||
{{ candidate.weighted_score }} <span class="opacity-50 text-[10px]">/ 20</span>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold text-slate-400 uppercase truncate max-w-[60px]">{{ candidate.ai_analysis.verdict }}</span>
|
||||
</div>
|
||||
<span v-else class="text-[10px] text-slate-300 italic">Non analysé</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="doc in candidate.documents"
|
||||
:key="doc.id"
|
||||
@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()"
|
||||
>
|
||||
<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">
|
||||
<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-4 w-4 text-emerald-600 dark:text-emerald-400" 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>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<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>
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="candidates.length === 0">
|
||||
<td colspan="8" class="px-6 py-12 text-center text-slate-500 italic">
|
||||
Aucun candidat trouvé.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
<td class="px-8 py-5">
|
||||
<div v-if="candidate.ai_analysis" class="flex items-center gap-2">
|
||||
<div
|
||||
class="px-2 py-0.5 rounded-lg text-[10px] font-black shadow-sm"
|
||||
:class="[
|
||||
candidate.ai_analysis.match_score >= 80 ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' :
|
||||
candidate.ai_analysis.match_score >= 60 ? 'bg-highlight/10 text-[#3a2800] border border-highlight/30' :
|
||||
'bg-accent/10 text-accent border border-accent/20'
|
||||
]"
|
||||
>
|
||||
{{ candidate.ai_analysis.match_score }}%
|
||||
</div>
|
||||
<span class="text-[9px] font-bold text-anthracite/40 uppercase truncate max-w-[60px]" :title="candidate.ai_analysis.verdict">{{ candidate.ai_analysis.verdict }}</span>
|
||||
</div>
|
||||
<span v-else class="text-[9px] font-bold uppercase tracking-widest text-anthracite/30 italic">Non analysé</span>
|
||||
</td>
|
||||
<td class="px-8 py-5">
|
||||
<div class="flex gap-1.5">
|
||||
<button
|
||||
v-for="doc in candidate.documents"
|
||||
:key="doc.id"
|
||||
@click="openPreview(doc)"
|
||||
class="p-1.5 bg-neutral text-anthracite/40 rounded-lg hover:bg-primary/10 hover:text-primary transition-colors"
|
||||
:title="doc.type.toUpperCase()"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</button>
|
||||
<span v-if="candidate.documents.length === 0" class="text-anthracite/20 text-xs">-</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-8 py-5 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<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">
|
||||
<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>
|
||||
</Link>
|
||||
<button @click="deleteCandidate(candidate.id)" class="p-2 text-anthracite/20 hover:text-accent hover:bg-accent/10 rounded-xl transition-all" title="Supprimer">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- Add Candidate Modal -->
|
||||
|
||||
@@ -21,10 +21,29 @@ const props = defineProps({
|
||||
const page = usePage();
|
||||
const flashSuccess = computed(() => page.props.flash?.success);
|
||||
|
||||
const activeTab = ref('overview');
|
||||
|
||||
const positionForm = useForm({
|
||||
job_position_id: props.candidate.job_position_id || ''
|
||||
});
|
||||
|
||||
const showEditDetailsModal = ref(false);
|
||||
const detailsForm = useForm({
|
||||
name: props.candidate.user.name,
|
||||
email: props.candidate.user.email,
|
||||
phone: props.candidate.phone || '',
|
||||
linkedin_url: props.candidate.linkedin_url || '',
|
||||
});
|
||||
|
||||
const updateDetails = () => {
|
||||
detailsForm.put(route('admin.candidates.update', props.candidate.id), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
showEditDetailsModal.value = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updatePosition = () => {
|
||||
positionForm.patch(route('admin.candidates.update-position', props.candidate.id), {
|
||||
preserveScroll: true,
|
||||
@@ -53,8 +72,20 @@ const docForm = useForm({
|
||||
_method: 'PUT' // For file upload via PUT in Laravel
|
||||
});
|
||||
|
||||
const rawInterviewDetails = props.candidate.interview_details || {};
|
||||
const notesForm = useForm({
|
||||
notes: props.candidate.notes || ''
|
||||
notes: props.candidate.notes || '',
|
||||
interview_details: {
|
||||
questions: rawInterviewDetails.questions || [],
|
||||
appreciation: rawInterviewDetails.appreciation || 0,
|
||||
soft_skills: rawInterviewDetails.soft_skills || [
|
||||
{ name: 'Communication & Pédagogie', score: 0 },
|
||||
{ name: 'Esprit d\'équipe & Collaboration', score: 0 },
|
||||
{ name: 'Résolution de problèmes & Logique', score: 0 },
|
||||
{ name: 'Adaptabilité & Résilience', score: 0 },
|
||||
{ name: 'Autonomie & Proactivité', score: 0 }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const scoreForm = useForm({
|
||||
@@ -114,11 +145,6 @@ const updateDocuments = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const saveNotes = () => {
|
||||
notesForm.patch(route('admin.candidates.update-notes', props.candidate.id), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
|
||||
const saveScores = () => {
|
||||
scoreForm.patch(route('admin.candidates.update-scores', props.candidate.id), {
|
||||
@@ -150,12 +176,21 @@ const bestTestScore = computed(() => {
|
||||
return Math.max(...finished.map(a => (a.score / a.max_score) * 20));
|
||||
});
|
||||
|
||||
// Calculated Soft Skills average
|
||||
const softSkillsScore = computed(() => {
|
||||
const skills = notesForm.interview_details.soft_skills || [];
|
||||
if (skills.length === 0) return 0;
|
||||
const total = skills.reduce((acc, s) => acc + (parseFloat(s.score) || 0), 0);
|
||||
return Number((total / skills.length).toFixed(1));
|
||||
});
|
||||
|
||||
// Données radar normalisées en % (chaque axe / son max)
|
||||
const radarData = computed(() => ([
|
||||
Math.round((parseFloat(scoreForm.cv_score) / 20) * 100),
|
||||
Math.round((parseFloat(scoreForm.motivation_score) / 10) * 100),
|
||||
Math.round((parseFloat(scoreForm.interview_score) / 30) * 100),
|
||||
Math.round((bestTestScore.value / 20) * 100),
|
||||
Math.round((softSkillsScore.value / 10) * 100), // Max is 10 for avg soft skills
|
||||
]));
|
||||
|
||||
const buildRadarChart = () => {
|
||||
@@ -172,7 +207,7 @@ const buildRadarChart = () => {
|
||||
radarChartInstance = new Chart(radarCanvasRef.value, {
|
||||
type: 'radar',
|
||||
data: {
|
||||
labels: ['Analyse CV', 'Lettre Motiv.', 'Entretien', 'Test Technique'],
|
||||
labels: ['Analyse CV', 'Lettre Motiv.', 'Entretien', 'Test Technique', 'Soft Skills'],
|
||||
datasets: [{
|
||||
label: 'Profil Candidat (%)',
|
||||
data: radarData.value,
|
||||
@@ -239,7 +274,7 @@ onUnmounted(() => {
|
||||
|
||||
// Mise à jour du radar quand les scores changent
|
||||
watch(
|
||||
() => [scoreForm.cv_score, scoreForm.motivation_score, scoreForm.interview_score, bestTestScore.value],
|
||||
() => [scoreForm.cv_score, scoreForm.motivation_score, scoreForm.interview_score, bestTestScore.value, softSkillsScore.value],
|
||||
() => {
|
||||
if (radarChartInstance) {
|
||||
radarChartInstance.data.datasets[0].data = radarData.value;
|
||||
@@ -247,6 +282,13 @@ watch(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Ré-initialisation du radar lors du switch d'onglet
|
||||
watch(activeTab, (newTab) => {
|
||||
if (newTab === 'overview') {
|
||||
nextTick(() => buildRadarChart());
|
||||
}
|
||||
});
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const aiAnalysis = ref(props.candidate.ai_analysis || null);
|
||||
@@ -254,6 +296,43 @@ const isAnalyzing = ref(false);
|
||||
const selectedProvider = ref(props.ai_config?.default || 'ollama');
|
||||
const forceAnalysis = ref(false);
|
||||
|
||||
// ─── Interview Scoring Logic ───────────────────────────────────────────────────
|
||||
const calculatedInterviewScore = computed(() => {
|
||||
const qScore = (notesForm.interview_details.questions || []).reduce((acc, q) => acc + (parseFloat(q.score) || 0), 0);
|
||||
const appScore = parseFloat(notesForm.interview_details.appreciation) || 0;
|
||||
return Math.min(30, qScore + appScore);
|
||||
});
|
||||
|
||||
// Auto-populate questions from AI analysis if empty
|
||||
watch(aiAnalysis, (newVal) => {
|
||||
if (newVal && newVal.questions_entretien_suggerees && (!notesForm.interview_details.questions || notesForm.interview_details.questions.length === 0)) {
|
||||
notesForm.interview_details.questions = newVal.questions_entretien_suggerees.map(q => ({
|
||||
question: q,
|
||||
score: 0,
|
||||
comment: ''
|
||||
}));
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// Sync with global score form and auto-save logic
|
||||
watch(calculatedInterviewScore, (newVal) => {
|
||||
scoreForm.interview_score = newVal;
|
||||
});
|
||||
|
||||
const saveNotes = () => {
|
||||
notesForm.transform((data) => ({
|
||||
...data,
|
||||
interview_score: calculatedInterviewScore.value
|
||||
})).patch(route('admin.candidates.update-notes', props.candidate.id), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
// Update raw candidate data to reflect the new score in computed fields if necessary
|
||||
props.candidate.interview_score = calculatedInterviewScore.value;
|
||||
props.candidate.interview_details = notesForm.interview_details;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Error Modal state
|
||||
const showErrorModal = ref(false);
|
||||
const modalErrorMessage = ref("");
|
||||
@@ -310,156 +389,161 @@ const runAI = async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
||||
<!-- Sidebar: Profile & Docs -->
|
||||
<div class="space-y-8">
|
||||
<!-- Profile Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="h-24 bg-gradient-to-r from-indigo-500 to-purple-600"></div>
|
||||
<div class="px-6 pb-6 text-center -mt-12 relative">
|
||||
<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">
|
||||
<button
|
||||
@click="toggleSelection"
|
||||
class="flex flex-col items-center gap-1 group focus:outline-none"
|
||||
:title="candidate.is_selected ? 'Retirer des retenus' : 'Marquer pour entretien'"
|
||||
>
|
||||
<div
|
||||
class="p-2 rounded-full transition-all"
|
||||
: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'"
|
||||
<div class="space-y-8">
|
||||
<!-- Hero Header (En-tête de Profil) -->
|
||||
<div class="bg-white rounded-3xl shadow-sm border border-anthracite/5 overflow-visible z-10 sticky top-0 md:top-4">
|
||||
<div class="h-16 md:h-20 bg-primary rounded-t-3xl relative overflow-hidden flex items-center px-8 relative">
|
||||
<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>
|
||||
<!-- Actions globales alignées à droite et stylées Or du midi -->
|
||||
<div class="ml-auto relative z-10 flex flex-wrap items-center justify-end gap-3 pt-2">
|
||||
<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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 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>
|
||||
Rapport (PDF)
|
||||
</a>
|
||||
<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">
|
||||
<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="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" />
|
||||
</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" />
|
||||
</svg>
|
||||
</div>
|
||||
<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>
|
||||
</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">
|
||||
{{ candidate.user.name.charAt(0) }}
|
||||
</div>
|
||||
<h3 class="text-xl font-bold">{{ candidate.user.name }}</h3>
|
||||
<p class="text-slate-500 text-sm mb-4">{{ candidate.user.email }}</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2 block text-left">Poste Cible</label>
|
||||
<select
|
||||
v-model="positionForm.job_position_id"
|
||||
@change="updatePosition"
|
||||
class="w-full bg-slate-50 dark:bg-slate-900 border-none rounded-xl py-2 px-3 text-xs font-bold text-indigo-600 focus:ring-2 focus:ring-indigo-500/20 transition-all cursor-pointer"
|
||||
>
|
||||
<option value="">Non assigné</option>
|
||||
<option v-for="pos in jobPositions" :key="pos.id" :value="pos.id">
|
||||
{{ pos.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Structure de rattachement (Super Admin only) -->
|
||||
<div v-if="page.props.auth.user.role === 'super_admin'" class="mb-6">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2 block text-left">Structure de Rattachement</label>
|
||||
<select
|
||||
v-model="tenantForm.tenant_id"
|
||||
@change="updateTenant"
|
||||
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 value="">Aucune structure</option>
|
||||
<option v-for="tenant in tenants" :key="tenant.id" :value="tenant.id">
|
||||
{{ tenant.name }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-[9px] text-slate-400 mt-1 italic text-left">Note: modifie aussi le rattachement de l'utilisateur.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 text-left">
|
||||
<div class="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-900 rounded-xl">
|
||||
<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" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium">{{ candidate.phone || 'Non renseigné' }}</span>
|
||||
{{ candidate.is_selected ? 'Retenu' : 'Sélectionner' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4 text-xs font-medium text-anthracite/60 font-subtitle">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<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>
|
||||
{{ candidate.user.email }}
|
||||
</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">
|
||||
<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>
|
||||
{{ candidate.phone }}
|
||||
</span>
|
||||
<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">
|
||||
<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>
|
||||
LinkedIn
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sélecteurs (Poste & Structure) -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 lg:min-w-[400px]">
|
||||
<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>
|
||||
<select
|
||||
v-model="positionForm.job_position_id"
|
||||
@change="updatePosition"
|
||||
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"
|
||||
>
|
||||
<option value="">Non assigné (Candidature spontanée)</option>
|
||||
<option v-for="pos in jobPositions" :key="pos.id" :value="pos.id">
|
||||
{{ pos.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="page.props.auth.user.role === 'super_admin'" class="flex-1 space-y-1.5">
|
||||
<label class="text-[9px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40">Structure (Tenant)</label>
|
||||
<select
|
||||
v-model="tenantForm.tenant_id"
|
||||
@change="updateTenant"
|
||||
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"
|
||||
>
|
||||
<option value="">Aucune structure</option>
|
||||
<option v-for="tenant in tenants" :key="tenant.id" :value="tenant.id">
|
||||
{{ tenant.name }}
|
||||
</option>
|
||||
</select>
|
||||
</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 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> <!-- Fin Hero Header (387) -->
|
||||
|
||||
<!-- Documents Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-6">
|
||||
<h4 class="font-bold mb-4 flex items-center gap-2">
|
||||
<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">
|
||||
<!-- Tabs Navigation -->
|
||||
<div class="border-t border-anthracite/5 px-6 md:px-8 bg-neutral/30 rounded-b-3xl">
|
||||
<div class="flex items-center gap-8 overflow-x-auto no-scrollbar">
|
||||
<button
|
||||
v-for="doc in candidate.documents"
|
||||
:key="doc.id"
|
||||
@click="openPreview(doc)"
|
||||
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"
|
||||
@click="activeTab = 'overview'"
|
||||
class="px-4 py-4 text-xs font-subtitle uppercase tracking-widest transition-all relative whitespace-nowrap"
|
||||
:class="activeTab === 'overview' ? 'text-primary font-black' : 'text-anthracite/40 hover:text-primary/70 font-bold'"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-white dark:bg-slate-800 rounded-lg group-hover:bg-indigo-500 group-hover:text-white transition-colors">
|
||||
<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">
|
||||
<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-5 w-5" 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 class="text-left">
|
||||
<div class="text-sm font-bold uppercase tracking-tight">{{ doc.type }}</div>
|
||||
<div class="text-[10px] text-slate-500 truncate max-w-[150px]">{{ doc.original_name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
Vue d'ensemble
|
||||
<div v-if="activeTab === 'overview'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full"></div>
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'ai_analysis'"
|
||||
class="px-4 py-4 text-xs font-subtitle uppercase tracking-widest transition-all relative whitespace-nowrap"
|
||||
:class="activeTab === 'ai_analysis' ? 'text-primary font-black' : 'text-anthracite/40 hover:text-primary/70 font-bold'"
|
||||
>
|
||||
Analyse IA
|
||||
<div v-if="activeTab === 'ai_analysis'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full"></div>
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'interview'"
|
||||
class="px-4 py-4 text-xs font-subtitle uppercase tracking-widest transition-all relative whitespace-nowrap"
|
||||
:class="activeTab === 'interview' ? 'text-primary font-black' : 'text-anthracite/40 hover:text-primary/70 font-bold'"
|
||||
>
|
||||
É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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Main: Content -->
|
||||
<div class="xl:col-span-2 space-y-8">
|
||||
<!-- Tab Content: Overview -->
|
||||
<div v-if="activeTab === 'overview'" class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<!-- 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="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) + '%' }"
|
||||
></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>
|
||||
|
||||
<!-- 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> <!-- Fin Overview Tab Content -->
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- AI Analysis Section (Full Width) -->
|
||||
<div class="xl:col-span-3 space-y-8">
|
||||
<!-- Tab Content: AI Analysis -->
|
||||
<div v-if="activeTab === 'ai_analysis'" class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<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>
|
||||
@@ -798,18 +881,6 @@ const runAI = async () => {
|
||||
</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 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">
|
||||
@@ -834,8 +905,157 @@ const runAI = async () => {
|
||||
</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="flex items-center justify-between mb-6">
|
||||
<h4 class="text-xl font-bold flex items-center gap-2">
|
||||
@@ -913,7 +1133,10 @@ const runAI = async () => {
|
||||
</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) -->
|
||||
<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">
|
||||
@@ -1027,8 +1250,84 @@ const runAI = async () => {
|
||||
Ce candidat n'a pas encore terminé de test.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- Fin Tests Tab -->
|
||||
|
||||
<!-- 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 -->
|
||||
<Modal :show="!!selectedDocument" @close="selectedDocument = null" max-width="4xl">
|
||||
@@ -1048,6 +1347,68 @@ const runAI = async () => {
|
||||
</Modal>
|
||||
</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 -->
|
||||
<Modal :show="showErrorModal" @close="showErrorModal = false" maxWidth="md">
|
||||
<div class="p-6">
|
||||
|
||||
@@ -31,69 +31,77 @@ const submit = () => {
|
||||
|
||||
<template>
|
||||
<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 }}
|
||||
</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>
|
||||
<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
|
||||
id="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"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="username"
|
||||
placeholder="prenom.nom@exemple.com"
|
||||
/>
|
||||
|
||||
<InputError class="mt-2" :message="form.errors.email" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<InputLabel for="password" value="Password" />
|
||||
<div>
|
||||
<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
|
||||
id="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"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
|
||||
<InputError class="mt-2" :message="form.errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 block">
|
||||
<label class="flex items-center">
|
||||
<Checkbox name="remember" v-model:checked="form.remember" />
|
||||
<span class="ms-2 text-sm text-gray-600"
|
||||
>Remember me</span
|
||||
>
|
||||
<div class="block pt-2">
|
||||
<label class="flex items-center group cursor-pointer w-max">
|
||||
<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-anthracite/60 group-hover:text-anthracite transition-colors">Rester connecté</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-end">
|
||||
<Link
|
||||
v-if="canResetPassword"
|
||||
:href="route('password.request')"
|
||||
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 }"
|
||||
<div class="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': 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
|
||||
</PrimaryButton>
|
||||
Se connecter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</GuestLayout>
|
||||
|
||||
@@ -44,88 +44,109 @@ const getStatusColor = (status) => {
|
||||
</div>
|
||||
</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 -->
|
||||
<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">
|
||||
<div class="text-slate-500 dark:text-slate-400 text-[10px] font-black uppercase tracking-widest">Total Candidats</div>
|
||||
<div class="text-4xl font-black mt-2 text-indigo-600 dark:text-indigo-400">{{ stats.total_candidates }}</div>
|
||||
<!-- Total Candidats -->
|
||||
<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-[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 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="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">
|
||||
<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>
|
||||
Retenus
|
||||
</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 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>
|
||||
<div class="text-4xl font-black mt-2 text-emerald-600 dark:text-emerald-400">{{ stats.finished_tests }}</div>
|
||||
|
||||
<!-- Tests terminés -->
|
||||
<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 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>
|
||||
<div class="text-4xl font-black mt-2 text-blue-600 dark:text-blue-400">{{ stats.average_score }} / 20</div>
|
||||
|
||||
<!-- Moyenne Générale -->
|
||||
<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 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>
|
||||
<div class="text-4xl font-black mt-2 text-purple-600 dark:text-purple-400">{{ stats.best_score }} / 20</div>
|
||||
|
||||
<!-- Meilleur Score -->
|
||||
<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>
|
||||
|
||||
<!-- 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="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">
|
||||
<h3 class="text-xl font-black uppercase tracking-tight">Top 10 Candidats</h3>
|
||||
<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">
|
||||
Voir tous les candidats →
|
||||
<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-anthracite/5 flex justify-between items-center bg-sand/30">
|
||||
<h3 class="text-xl font-serif font-black 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>
|
||||
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> →
|
||||
</Link>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-slate-50/50 dark:bg-slate-900/30">
|
||||
<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-black uppercase tracking-[0.2em] text-slate-400">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-black uppercase tracking-[0.2em] text-slate-400">Statut</th>
|
||||
<th class="px-8 py-4 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 text-right">Actions</th>
|
||||
<tr class="bg-neutral/50">
|
||||
<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-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40">Score Pondéré</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-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40">Statut</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>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
<tr v-for="candidate in top_candidates" :key="candidate.id" class="hover:bg-slate-50 dark:hover:bg-slate-900/50 transition-colors group">
|
||||
<tbody class="divide-y divide-anthracite/5">
|
||||
<tr v-for="candidate in top_candidates" :key="candidate.id" class="hover:bg-sand/30 transition-colors group">
|
||||
<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="text-xs text-slate-500 dark:text-slate-400">{{ candidate.email }}</div>
|
||||
<div class="font-bold text-primary group-hover:text-highlight transition-colors block">{{ candidate.name }}</div>
|
||||
<div class="text-xs text-anthracite/50 font-subtitle tracking-wide mt-0.5">{{ candidate.email }}</div>
|
||||
</td>
|
||||
<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">
|
||||
{{ candidate.weighted_score }} / 20
|
||||
<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 }} <span class="opacity-50 text-xs">/ 20</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-8 py-5">
|
||||
<div v-if="candidate.ai_analysis" class="flex items-center gap-2">
|
||||
<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="[
|
||||
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 >= 80 ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' :
|
||||
candidate.ai_analysis.match_score >= 60 ? 'bg-highlight/10 text-[#3a2800] border border-highlight/30' :
|
||||
'bg-accent/10 text-accent border border-accent/20'
|
||||
]"
|
||||
>
|
||||
{{ candidate.ai_analysis.match_score }}%
|
||||
</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 class="px-8 py-5">
|
||||
<span
|
||||
class="px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-full"
|
||||
:class="getStatusColor(candidate.status)"
|
||||
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>
|
||||
@@ -133,19 +154,20 @@ const getStatusColor = (status) => {
|
||||
<td class="px-8 py-5 text-right">
|
||||
<Link
|
||||
: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"
|
||||
>
|
||||
<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" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="top_candidates.length === 0">
|
||||
<td colspan="4" class="px-8 py-12 text-center text-slate-400 italic font-medium">
|
||||
Aucun candidat pour le moment.
|
||||
<td colspan="5" class="px-8 py-16 text-center">
|
||||
<div class="text-anthracite/40 italic font-medium font-subtitle">
|
||||
Aucun candidat pour le moment.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -154,19 +176,19 @@ const getStatusColor = (status) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Candidate Dashboard: LIGHT ONLY, high contrast, no dark: classes -->
|
||||
<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">
|
||||
<!-- Candidate Dashboard: LIGHT ONLY, matched with new graphic charter -->
|
||||
<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">
|
||||
|
||||
<!-- Welcome Section -->
|
||||
<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
|
||||
</div>
|
||||
<h3 class="font-black mb-5 tracking-tight" style="font-size: clamp(2rem, 5vw, 3.5rem); color: #1e1b4b; line-height: 1.1;">
|
||||
Bienvenue, <span style="color:#4f46e5;">{{ user.name }}</span> !
|
||||
<h3 class="text-4xl md:text-5xl font-serif font-black mb-5 tracking-tight text-primary leading-tight">
|
||||
Bienvenue, <span class="text-accent">{{ user.name }}</span> !
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
@@ -176,34 +198,31 @@ const getStatusColor = (status) => {
|
||||
<div
|
||||
v-for="quiz in quizzes"
|
||||
:key="quiz.id"
|
||||
class="group"
|
||||
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)'"
|
||||
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"
|
||||
>
|
||||
<!-- 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 -->
|
||||
<div style="display:inline-flex; padding:0.75rem; background:#eef2ff; border-radius:1rem; margin-bottom:1.5rem;">
|
||||
<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">
|
||||
<div class="inline-flex p-3 bg-sky/15 rounded-xl mb-6">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h4 style="font-size:1.25rem; font-weight:800; color:#1e1b4b; margin-bottom:0.75rem; line-height:1.3;">{{ 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;">
|
||||
<h4 class="text-xl font-subtitle font-bold text-primary mb-3 leading-tight">{{ quiz.title }}</h4>
|
||||
<p class="text-anthracite/70 text-sm leading-relaxed mb-8 line-clamp-2">
|
||||
{{ quiz.description }}
|
||||
</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 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 style="font-size:0.95rem; font-weight:800; color:#374151;">{{ quiz.duration_minutes }} min</div>
|
||||
<div class="text-[10px] font-black uppercase tracking-[0.1em] text-anthracite/40 mb-1">Durée</div>
|
||||
<div class="text-base font-bold text-anthracite">{{ quiz.duration_minutes }} min</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;">
|
||||
<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">
|
||||
<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" 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" />
|
||||
</svg>
|
||||
Terminé
|
||||
@@ -211,9 +230,7 @@ const getStatusColor = (status) => {
|
||||
<Link
|
||||
v-else
|
||||
: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;"
|
||||
@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)'"
|
||||
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"
|
||||
>
|
||||
Démarrer →
|
||||
</Link>
|
||||
@@ -222,21 +239,21 @@ const getStatusColor = (status) => {
|
||||
</div>
|
||||
|
||||
<!-- 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 style="display:inline-flex; padding:1.5rem; background:#fff7ed; border-radius:9999px; margin-bottom:1.5rem;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="width:3rem;height:3rem;stroke:#f97316;" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<div v-else class="text-center p-20 bg-white rounded-3xl shadow-sm border border-anthracite/5">
|
||||
<div class="inline-flex p-6 bg-accent/10 rounded-full mb-6">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<h4 style="font-size:1.5rem; font-weight:900; color:#1e1b4b; margin-bottom:0.75rem;">Aucun test assigné</h4>
|
||||
<p style="color:#6b7280; max-width:28rem; margin:0 auto; line-height:1.7; font-size:0.95rem;">
|
||||
<h4 class="text-2xl font-serif font-black text-primary mb-3">Aucun test assigné</h4>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="margin-top:3rem; text-align:center;">
|
||||
<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>
|
||||
<div class="mt-12 text-center text-primary/50 text-[10px] font-subtitle font-bold uppercase tracking-widest">
|
||||
© {{ new Date().getFullYear() }} — Communauté d'Agglomération Béziers Méditerranée
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -2,184 +2,185 @@
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
|
||||
defineProps({
|
||||
canLogin: Boolean,
|
||||
canRegister: Boolean,
|
||||
canLogin: {
|
||||
type: Boolean,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<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 -->
|
||||
<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="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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<div class="flex items-center gap-3 group cursor-default">
|
||||
<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-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" />
|
||||
</svg>
|
||||
</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 class="flex items-center gap-4">
|
||||
<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">
|
||||
Aller au Dashboard
|
||||
<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">
|
||||
Accéder au Tableau de bord
|
||||
</Link>
|
||||
</template>
|
||||
<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
|
||||
</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>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<main class="relative z-10 max-w-7xl mx-auto px-6 pt-20 pb-32 md:px-12 md:pt-32">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-16 items-center">
|
||||
<!-- Fond Institutionnel Hero Section -->
|
||||
<main class="relative z-10 w-full mt-4">
|
||||
<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 -->
|
||||
<div class="lg:col-span-7 space-y-10">
|
||||
<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">
|
||||
<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
|
||||
<!-- Graphic Elements -->
|
||||
<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="absolute -bottom-24 -left-24 w-96 h-96 bg-accent/20 rounded-full blur-[100px] pointer-events-none"></div>
|
||||
|
||||
<div class="relative z-10 grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
|
||||
|
||||
<!-- Hero Content -->
|
||||
<div class="space-y-10 text-white">
|
||||
<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">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-highlight opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-3 w-3 bg-highlight"></span>
|
||||
</span>
|
||||
Évaluation des candidats
|
||||
</div>
|
||||
|
||||
<h1 class="text-5xl md:text-7xl font-serif leading-[1.1] text-white">
|
||||
Découvrez le potentiel de vos <span class="text-highlight">futures équipes</span>.
|
||||
</h1>
|
||||
|
||||
<p class="text-lg md:text-xl text-sand font-sans font-light max-w-xl leading-relaxed">
|
||||
Recru.IT simplifie le processus d'évaluation.
|
||||
Générez des tests sur-mesure pour chaque poste et accédez à une analyse de compétences claire, précise et équitable.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 pt-4">
|
||||
<Link
|
||||
v-if="!$page.props.auth.user"
|
||||
:href="route('login')"
|
||||
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"
|
||||
>
|
||||
S'identifier
|
||||
</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 class="grid grid-cols-2 gap-4">
|
||||
<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-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>
|
||||
|
||||
<h1 class="text-6xl md:text-8xl font-black tracking-tight leading-[0.9] text-slate-900 dark:text-white">
|
||||
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">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row items-center gap-6">
|
||||
<Link
|
||||
:href="route('login')"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<p class="text-xs font-bold text-slate-400"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero Illustration / Mockup -->
|
||||
<div class="lg:col-span-5 relative hidden lg:block">
|
||||
<div class="absolute -inset-4 bg-gradient-to-tr from-indigo-600 to-purple-600 rounded-[4rem] blur-3xl opacity-20 animate-pulse"></div>
|
||||
<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">
|
||||
<div class="w-24 h-24 bg-indigo-600/10 rounded-3xl mx-auto flex items-center justify-center">
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div class="text-3xl font-black uppercase tracking-tighter">Félicitations !</div>
|
||||
<p class="text-slate-500 text-sm">Votre score est de 95%. <br> Vous êtes prêt pour la suite.</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="h-4 w-full bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden">
|
||||
<div class="h-full w-[95%] bg-indigo-600 rounded-full shadow-lg shadow-indigo-600/30"></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-[10px] font-black uppercase text-slate-400">
|
||||
<span>Rang S+</span>
|
||||
<span>Recruté</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Features Grid -->
|
||||
<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">
|
||||
<!-- Features -->
|
||||
<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="text-center mb-20 space-y-4">
|
||||
<h2 class="text-4xl font-black uppercase tracking-tight text-slate-900 dark:text-white">Conçu pour les recruteurs</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>
|
||||
<div class="text-center mb-16 space-y-4">
|
||||
<h2 class="text-3xl md:text-5xl font-serif text-primary">Un processus optimisé</h2>
|
||||
<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 class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<!-- 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="w-14 h-14 bg-indigo-600/10 rounded-2xl flex items-center justify-center mb-8 group-hover:scale-110 transition-transform">
|
||||
<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">
|
||||
<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-sky/10 rounded-xl flex items-center justify-center mb-6">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-4">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>
|
||||
<h3 class="text-xl font-subtitle font-bold text-primary mb-3">Quiz Dynamiques</h3>
|
||||
<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>
|
||||
|
||||
<!-- 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="w-14 h-14 bg-emerald-600/10 rounded-2xl flex items-center justify-center mb-8 group-hover:scale-110 transition-transform">
|
||||
<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">
|
||||
<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-highlight/10 rounded-xl flex items-center justify-center mb-6">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-4">Audit & Sécurité</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>
|
||||
<h3 class="text-xl font-subtitle font-bold text-anthracite mb-3">Sécurisé & Traçable</h3>
|
||||
<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>
|
||||
|
||||
<!-- 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="w-14 h-14 bg-purple-600/10 rounded-2xl flex items-center justify-center mb-8 group-hover:scale-110 transition-transform">
|
||||
<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">
|
||||
<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-accent/10 rounded-xl flex items-center justify-center mb-6">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-4">Mobile First</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>
|
||||
<h3 class="text-xl font-subtitle font-bold text-accent mb-3">Expérience fluide</h3>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="relative z-10 px-6 py-20 text-center text-slate-400 text-xs font-black uppercase tracking-[0.2em]">
|
||||
© 2026 RecruitQuizz — Advanced Recruitment Intelligence
|
||||
<footer class="relative bg-sand px-6 py-12 text-center border-t border-anthracite/10">
|
||||
<p class="text-primary font-subtitle font-bold text-xs uppercase tracking-[0.1em]">
|
||||
© {{ new Date().getFullYear() }} — Communauté d'Agglomération Béziers Méditerranée — Tous droits réservés
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;700;900&display=swap');
|
||||
|
||||
.font-sans {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
}
|
||||
/* 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');
|
||||
</style>
|
||||
|
||||
366
resources/views/pdfs/candidate-dossier.blade.php
Normal file
366
resources/views/pdfs/candidate-dossier.blade.php
Normal 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>
|
||||
@@ -12,10 +12,6 @@ use App\Http\Controllers\Auth\VerifyEmailController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware('guest')->group(function () {
|
||||
Route::get('register', [RegisteredUserController::class, 'create'])
|
||||
->name('register');
|
||||
|
||||
Route::post('register', [RegisteredUserController::class, 'store']);
|
||||
|
||||
Route::get('login', [AuthenticatedSessionController::class, 'create'])
|
||||
->name('login');
|
||||
|
||||
@@ -8,9 +8,6 @@ use Inertia\Inertia;
|
||||
Route::get('/', function () {
|
||||
return Inertia::render('Welcome', [
|
||||
'canLogin' => Route::has('login'),
|
||||
'canRegister' => Route::has('register'),
|
||||
'laravelVersion' => Application::VERSION,
|
||||
'phpVersion' => PHP_VERSION,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -91,6 +88,8 @@ Route::middleware('auth')->group(function () {
|
||||
Route::post('/candidates/{candidate}/analyze', [\App\Http\Controllers\AIAnalysisController::class, 'analyze'])->name('candidates.analyze');
|
||||
Route::post('/candidates/{candidate}/reset-password', [\App\Http\Controllers\CandidateController::class, 'resetPassword'])->name('candidates.reset-password');
|
||||
Route::get('/documents/{document}', [\App\Http\Controllers\DocumentController::class, 'show'])->name('documents.show');
|
||||
Route::get('/candidates/{candidate}/export-dossier', [\App\Http\Controllers\Admin\CandidateExportController::class, 'exportDossier'])->name('candidates.export-dossier');
|
||||
Route::get('/candidates/{candidate}/export-zip', [\App\Http\Controllers\Admin\CandidateExportController::class, 'exportZip'])->name('candidates.export-zip');
|
||||
|
||||
Route::resource('quizzes', \App\Http\Controllers\QuizController::class)->only(['index', 'store', 'show', 'update', 'destroy']);
|
||||
Route::resource('job-positions', \App\Http\Controllers\JobPositionController::class)->only(['index', 'store', 'update', 'destroy']);
|
||||
|
||||
@@ -14,8 +14,19 @@ export default {
|
||||
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#1a4b8c', // Bleu Méditerranée
|
||||
accent: '#c8102e', // Rouge Occitan
|
||||
highlight: '#f5a800', // Or du Midi
|
||||
sky: '#3a7abf', // Bleu ciel
|
||||
sand: '#e8e0d0', // Sable garrigue
|
||||
anthracite: '#2d2d2d', // Anthracite
|
||||
neutral: '#f0ece4', // Fond neutre
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Figtree', ...defaultTheme.fontFamily.sans],
|
||||
sans: ['Helvetica Neue', 'Arial', 'sans-serif'],
|
||||
serif: ['Merriweather', 'Georgia', 'serif'],
|
||||
subtitle: ['Nunito', 'Gill Sans', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user