Compare commits

19 Commits

Author SHA1 Message Date
jeremy bayse
589e9956f9 UI: lock application to light mode globally (disabled dark mode detection) 2026-04-16 19:16:48 +02:00
jeremy bayse
d6e5b44e47 Admin: implementation of the 'is_selected' feature for candidates for interviews 2026-04-16 19:08:52 +02:00
jeremy bayse
c4ab5c97b2 Admin: Super Admin can bypass the 7-day AI analysis restriction 2026-04-16 18:55:53 +02:00
jeremy bayse
84a9c6bb14 Fix: New registered candidates visibility (profile creation and tenant scope adjustment) 2026-04-16 18:31:23 +02:00
jeremy bayse
957947cc0b fix: quiz questions not showing - reload quiz freshly in show(), fix v-if guard in QuizInterface 2026-04-14 19:55:05 +02:00
jeremy bayse
6e4eb62553 fix: bypass tenant scope in AttemptController::destroy - null quiz title caused 500 when admin deletes attempt 2026-04-14 19:51:06 +02:00
jeremy bayse
c74d8e14ec fix: null quiz crash in admin candidate show - load attempts.quiz withoutGlobalScopes + null guard in Vue template 2026-04-14 19:49:12 +02:00
jeremy bayse
e93a17f324 refactor: fix BelongsToTenant trait to exempt candidates globally - removes all withoutGlobalScopes() workarounds 2026-04-14 19:38:42 +02:00
jeremy bayse
49ee91c601 fix: bypass tenant scope in recalculateScore - null quiz when candidate finishes quiz 2026-04-14 19:35:26 +02:00
jeremy bayse
479a7e35d1 fix: bypass tenant scope in AttemptController::show - candidates (no tenant_id) were getting 404 on quiz start 2026-04-14 19:24:29 +02:00
jeremy bayse
de6938d2e6 fix: bypass BelongsToTenant global scope when loading candidate quizzes on dashboard - candidates have no tenant_id 2026-04-14 19:20:20 +02:00
jeremy bayse
91213cc371 fix: sync quizzes unconditionally - was not removing quizzes when quiz_ids was empty 2026-04-14 19:15:15 +02:00
jeremy bayse
af4502859b fix: QuizInterface crash on undefined text_content - add null guards and safe optional chaining 2026-04-14 19:09:58 +02:00
jeremy bayse
107e2d0a1d design: darken nav bar elements for high contrast - Dashboard and NavLink text fix 2026-04-14 19:08:06 +02:00
jeremy bayse
71672509b6 design: fix candidate layout with full light theme, no dark mode dependencies, high contrast throughout 2026-04-14 19:06:17 +02:00
jeremy bayse
351bdda2a1 design: complete UI harmonization with full dark mode support and high contrast for candidate dashboard 2026-04-14 19:02:38 +02:00
jeremy bayse
21d4aaee59 design: premium candidate dashboard with high contrast and better layout 2026-04-14 18:59:09 +02:00
jeremy bayse
ec1fe91b35 feat: link quizzes to job positions and filter candidate dashboard accordingly 2026-04-14 18:30:13 +02:00
jeremy bayse
8c577cfaa7 fix: display flash messages on admin users index to show generated passwords 2026-04-14 18:24:44 +02:00
22 changed files with 399 additions and 82 deletions

View File

@@ -23,7 +23,10 @@ class AIAnalysisController extends Controller
} }
// Restriction: Une analyse tous les 7 jours maximum par candidat // Restriction: Une analyse tous les 7 jours maximum par candidat
if ($candidate->ai_analysis && isset($candidate->ai_analysis['analyzed_at'])) { // Le super_admin peut outrepasser cette restriction via le paramètre 'force'
$shouldCheckRestriction = !($request->force && auth()->user()->isSuperAdmin());
if ($shouldCheckRestriction && $candidate->ai_analysis && isset($candidate->ai_analysis['analyzed_at'])) {
$lastAnalysis = Carbon::parse($candidate->ai_analysis['analyzed_at']); $lastAnalysis = Carbon::parse($candidate->ai_analysis['analyzed_at']);
if ($lastAnalysis->diffInDays(now()) < 7) { if ($lastAnalysis->diffInDays(now()) < 7) {
return response()->json([ return response()->json([

View File

@@ -19,7 +19,9 @@ class AttemptController extends Controller
$this->authorizeAdmin(); $this->authorizeAdmin();
$candidateName = $attempt->candidate->user->name; $candidateName = $attempt->candidate->user->name;
$quizTitle = $attempt->quiz->title; // Bypass tenant scope: admin may delete attempts for cross-tenant quizzes
$quiz = Quiz::withoutGlobalScopes()->find($attempt->quiz_id);
$quizTitle = $quiz?->title ?? "Quiz #{$attempt->quiz_id}";
DB::transaction(function () use ($attempt, $candidateName, $quizTitle) { DB::transaction(function () use ($attempt, $candidateName, $quizTitle) {
// Log the action // Log the action
@@ -74,11 +76,15 @@ class AttemptController extends Controller
$candidate->update(['status' => 'en_cours']); $candidate->update(['status' => 'en_cours']);
} }
$quiz->load(['questions.options']); // Reload quiz with questions FRESHLY (avoid any cached state from model binding)
$quizData = Quiz::with(['questions' => function($q) {
$q->orderBy('id')->with('options');
}])
->find($quiz->id);
return Inertia::render('Candidate/QuizInterface', [ return Inertia::render('Candidate/QuizInterface', [
'quiz' => $quiz, 'quiz' => $quizData,
'attempt' => $attempt->load('answers') 'attempt' => $attempt->load('answers'),
]); ]);
} }

View File

@@ -41,6 +41,12 @@ class RegisteredUserController extends Controller
'name' => $request->name, 'name' => $request->name,
'email' => $request->email, 'email' => $request->email,
'password' => Hash::make($request->password), 'password' => Hash::make($request->password),
'role' => 'candidate',
]);
// Create the associated candidate record so they appear in the lists
$user->candidate()->create([
'status' => 'en_attente',
]); ]);
event(new Registered($user)); event(new Registered($user));

View File

@@ -81,13 +81,23 @@ class CandidateController extends Controller
$candidate->load([ $candidate->load([
'user', 'user',
'documents', 'documents',
'attempts.quiz',
'attempts.answers.question',
'attempts.answers.option',
'jobPosition', 'jobPosition',
'tenant' 'tenant'
]); ]);
// Load attempts with quiz bypassing tenant scope
// (admin may view candidates whose quizzes belong to other tenants)
$candidate->setRelation(
'attempts',
$candidate->attempts()
->with([
'quiz' => fn($q) => $q->withoutGlobalScopes(),
'answers.question',
'answers.option',
])
->get()
);
$data = [ $data = [
'candidate' => $candidate, 'candidate' => $candidate,
'jobPositions' => \App\Models\JobPosition::all(), 'jobPositions' => \App\Models\JobPosition::all(),
@@ -227,6 +237,15 @@ class CandidateController extends Controller
$this->storeDocument($candidate, $file, $type); $this->storeDocument($candidate, $file, $type);
} }
public function toggleSelection(Candidate $candidate)
{
$candidate->update([
'is_selected' => !$candidate->is_selected
]);
return back()->with('success', 'Statut de sélection mis à jour.');
}
private function storeDocument(Candidate $candidate, $file, string $type) private function storeDocument(Candidate $candidate, $file, string $type)
{ {
if (!$file) { if (!$file) {

View File

@@ -13,8 +13,9 @@ class JobPositionController extends Controller
$this->authorizeAdmin(); $this->authorizeAdmin();
return Inertia::render('Admin/JobPositions/Index', [ return Inertia::render('Admin/JobPositions/Index', [
'jobPositions' => JobPosition::with('tenant')->get(), 'jobPositions' => JobPosition::with(['tenant', 'quizzes'])->get(),
'tenants' => \App\Models\Tenant::orderBy('name')->get() 'tenants' => \App\Models\Tenant::orderBy('name')->get(),
'quizzes' => \App\Models\Quiz::all()
]); ]);
} }
@@ -28,9 +29,11 @@ class JobPositionController extends Controller
'requirements' => 'nullable|array', 'requirements' => 'nullable|array',
'ai_prompt' => 'nullable|string', 'ai_prompt' => 'nullable|string',
'tenant_id' => 'nullable|exists:tenants,id', 'tenant_id' => 'nullable|exists:tenants,id',
'quiz_ids' => 'nullable|array',
'quiz_ids.*' => 'exists:quizzes,id',
]); ]);
JobPosition::create([ $jobPosition = JobPosition::create([
'title' => $request->title, 'title' => $request->title,
'description' => $request->description, 'description' => $request->description,
'requirements' => $request->requirements, 'requirements' => $request->requirements,
@@ -38,6 +41,8 @@ class JobPositionController extends Controller
'tenant_id' => auth()->user()->isSuperAdmin() ? $request->tenant_id : auth()->user()->tenant_id, 'tenant_id' => auth()->user()->isSuperAdmin() ? $request->tenant_id : auth()->user()->tenant_id,
]); ]);
$jobPosition->quizzes()->sync($request->input('quiz_ids', []));
return back()->with('success', 'Fiche de poste créée avec succès.'); return back()->with('success', 'Fiche de poste créée avec succès.');
} }
@@ -51,6 +56,8 @@ class JobPositionController extends Controller
'requirements' => 'nullable|array', 'requirements' => 'nullable|array',
'ai_prompt' => 'nullable|string', 'ai_prompt' => 'nullable|string',
'tenant_id' => 'nullable|exists:tenants,id', 'tenant_id' => 'nullable|exists:tenants,id',
'quiz_ids' => 'nullable|array',
'quiz_ids.*' => 'exists:quizzes,id',
]); ]);
$jobPosition->update([ $jobPosition->update([
@@ -61,6 +68,8 @@ class JobPositionController extends Controller
'tenant_id' => auth()->user()->isSuperAdmin() ? $request->tenant_id : auth()->user()->tenant_id, 'tenant_id' => auth()->user()->isSuperAdmin() ? $request->tenant_id : auth()->user()->tenant_id,
]); ]);
$jobPosition->quizzes()->sync($request->input('quiz_ids', []));
return back()->with('success', 'Fiche de poste mise à jour.'); return back()->with('success', 'Fiche de poste mise à jour.');
} }

View File

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

View File

@@ -22,4 +22,9 @@ class JobPosition extends Model
{ {
return $this->hasMany(Candidate::class); return $this->hasMany(Candidate::class);
} }
public function quizzes()
{
return $this->belongsToMany(Quiz::class);
}
} }

View File

@@ -19,4 +19,9 @@ class Quiz extends Model
{ {
return $this->hasMany(Question::class); return $this->hasMany(Question::class);
} }
public function jobPositions()
{
return $this->belongsToMany(JobPosition::class);
}
} }

View File

@@ -13,13 +13,22 @@ trait BelongsToTenant
if (Auth::check()) { if (Auth::check()) {
$user = Auth::user(); $user = Auth::user();
if ($user->role === 'super_admin') {
// Super admins see everything // Super admins see everything
if ($user->role === 'super_admin') {
return;
}
// Candidates don't have a tenant_id but must access
// quizzes/job positions linked to their position
if ($user->role === 'candidate') {
return; return;
} }
if ($user->tenant_id) { if ($user->tenant_id) {
$builder->where('tenant_id', $user->tenant_id); $builder->where(function ($query) use ($user) {
$query->where('tenant_id', $user->tenant_id)
->orWhereNull('tenant_id');
});
} }
} }
}); });

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('job_position_quiz', function (Blueprint $table) {
$table->id();
$table->foreignId('job_position_id')->constrained()->cascadeOnDelete();
$table->foreignId('quiz_id')->constrained()->cascadeOnDelete();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('job_position_quiz');
}
};

View File

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

View File

@@ -12,7 +12,8 @@ defineProps({
<template> <template>
<Link <Link
:href="href" :href="href"
class="block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 transition duration-150 ease-in-out hover:bg-gray-100 focus:bg-gray-100 focus:outline-none" class="block w-full px-4 py-2 text-start text-sm font-semibold leading-5 transition duration-150 ease-in-out focus:outline-none"
style="color:#1e293b;"
> >
<slot /> <slot />
</Link> </Link>

View File

@@ -14,8 +14,8 @@ const props = defineProps({
const classes = computed(() => const classes = computed(() =>
props.active props.active
? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-400 text-sm font-medium leading-5 text-gray-900 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out' ? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-600 text-sm font-bold leading-5 text-indigo-700 focus:outline-none transition duration-150 ease-in-out'
: 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none focus:text-gray-700 focus:border-gray-300 transition duration-150 ease-in-out', : 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-bold leading-5 text-slate-700 hover:text-indigo-600 hover:border-indigo-400 focus:outline-none transition duration-150 ease-in-out',
); );
</script> </script>

View File

@@ -14,10 +14,8 @@ const showingNavigationDropdown = ref(false);
<template> <template>
<EnvironmentBanner /> <EnvironmentBanner />
<div> <div>
<div class="min-h-screen bg-gray-100"> <div class="min-h-screen" style="background:#f8fafc;">
<nav <nav style="border-bottom:1px solid #e2e8f0; background:white; box-shadow:0 1px 3px rgba(0,0,0,0.04);">
class="border-b border-gray-100 bg-white"
>
<!-- Primary Navigation Menu --> <!-- Primary Navigation Menu -->
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between"> <div class="flex h-16 justify-between">
@@ -26,7 +24,7 @@ const showingNavigationDropdown = ref(false);
<div class="flex shrink-0 items-center"> <div class="flex shrink-0 items-center">
<Link :href="route('dashboard')"> <Link :href="route('dashboard')">
<ApplicationLogo <ApplicationLogo
class="block h-9 w-auto fill-current text-gray-800" class="block h-9 w-auto fill-indigo-600"
/> />
</Link> </Link>
</div> </div>
@@ -52,7 +50,7 @@ const showingNavigationDropdown = ref(false);
<span class="inline-flex rounded-md"> <span class="inline-flex rounded-md">
<button <button
type="button" type="button"
class="inline-flex items-center rounded-md border border-transparent bg-white px-3 py-2 text-sm font-medium leading-4 text-gray-500 transition duration-150 ease-in-out hover:text-gray-700 focus:outline-none" style="display:inline-flex; align-items:center; border-radius:0.75rem; border:1.5px solid #e2e8f0; background:#f1f5f9; padding:0.5rem 1rem; font-size:0.875rem; font-weight:700; color:#0f172a; transition:all 0.15s ease;"
> >
{{ $page.props.auth.user.name }} {{ $page.props.auth.user.name }}
@@ -183,7 +181,7 @@ const showingNavigationDropdown = ref(false);
<!-- Page Heading --> <!-- Page Heading -->
<header <header
class="bg-white shadow" style="background:white; border-bottom:1px solid #f1f5f9; box-shadow:none;"
v-if="$slots.header" v-if="$slots.header"
> >
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
@@ -196,8 +194,8 @@ const showingNavigationDropdown = ref(false);
<slot /> <slot />
</main> </main>
<footer class="pb-8 pt-4 text-center"> <footer class="pb-8 pt-4 text-center" style="background:#f8fafc;">
<span class="text-[10px] text-gray-400 font-mono">v{{ $page.props.app_version }}</span> <span class="text-[10px] font-mono" style="color:#9ca3af;">v{{ $page.props.app_version }}</span>
</footer> </footer>
</div> </div>
</div> </div>

View File

@@ -43,10 +43,14 @@ const submit = () => {
const deleteCandidate = (id) => { const deleteCandidate = (id) => {
if (confirm('Voulez-vous vraiment supprimer ce candidat ?')) { if (confirm('Voulez-vous vraiment supprimer ce candidat ?')) {
router.delete(route('admin.candidates.destroy', id)); router.delete(route('admin.candidates.destroy', id), { preserveScroll: true });
} }
}; };
const toggleSelection = (id) => {
router.patch(route('admin.candidates.toggle-selection', id), {}, { preserveScroll: true });
};
const openPreview = (doc) => { const openPreview = (doc) => {
selectedDocument.value = doc; selectedDocument.value = doc;
}; };
@@ -69,11 +73,24 @@ const getNestedValue = (obj, path) => {
}; };
const selectedJobPosition = ref(''); const selectedJobPosition = ref('');
const showOnlySelected = ref(false);
const filteredCandidates = computed(() => { const filteredCandidates = computed(() => {
if (selectedJobPosition.value === '') return props.candidates; let result = props.candidates;
if (selectedJobPosition.value === 'none') return props.candidates.filter(c => !c.job_position_id);
return props.candidates.filter(c => c.job_position_id === selectedJobPosition.value); if (showOnlySelected.value) {
result = result.filter(c => c.is_selected);
}
if (selectedJobPosition.value !== '') {
if (selectedJobPosition.value === 'none') {
result = result.filter(c => !c.job_position_id);
} else {
result = result.filter(c => c.job_position_id === selectedJobPosition.value);
}
}
return result;
}); });
const sortedCandidates = computed(() => { const sortedCandidates = computed(() => {
@@ -102,6 +119,13 @@ const sortedCandidates = computed(() => {
<div class="flex justify-between items-end mb-8"> <div class="flex justify-between items-end mb-8">
<div class="space-y-4"> <div class="space-y-4">
<h3 class="text-2xl font-bold">Liste des Candidats</h3> <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">
<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>
</label>
</div>
<div class="flex items-center gap-3"> <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> <label class="text-sm font-medium text-slate-700 dark:text-slate-300">Filtrer par fiche de poste :</label>
<select <select
@@ -116,6 +140,7 @@ const sortedCandidates = computed(() => {
</select> </select>
</div> </div>
</div> </div>
</div>
<PrimaryButton @click="isModalOpen = true"> <PrimaryButton @click="isModalOpen = true">
Ajouter un Candidat Ajouter un Candidat
</PrimaryButton> </PrimaryButton>
@@ -139,6 +164,7 @@ const sortedCandidates = computed(() => {
<table class="w-full text-left"> <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"> <thead class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
<tr> <tr>
<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"> <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"> <div class="flex items-center gap-2">
Nom Nom
@@ -201,6 +227,16 @@ const sortedCandidates = computed(() => {
</thead> </thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700"> <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"> <tr v-for="candidate in sortedCandidates" :key="candidate.id" class="hover:bg-slate-50/50 dark:hover:bg-slate-700/30 transition-colors">
<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"> <td class="px-6 py-4">
<div class="font-bold text-slate-900 dark:text-white">{{ candidate.user.name }}</div> <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> <div class="text-[10px] text-slate-500 font-bold uppercase tracking-tight">{{ candidate.phone }}</div>
@@ -294,7 +330,7 @@ const sortedCandidates = computed(() => {
</td> </td>
</tr> </tr>
<tr v-if="candidates.length === 0"> <tr v-if="candidates.length === 0">
<td colspan="7" class="px-6 py-12 text-center text-slate-500 italic"> <td colspan="8" class="px-6 py-12 text-center text-slate-500 italic">
Aucun candidat trouvé. Aucun candidat trouvé.
</td> </td>
</tr> </tr>

View File

@@ -41,6 +41,10 @@ const updateTenant = () => {
}); });
}; };
const toggleSelection = () => {
router.patch(route('admin.candidates.toggle-selection', props.candidate.id), {}, { preserveScroll: true });
};
const selectedDocument = ref(null); const selectedDocument = ref(null);
const docForm = useForm({ const docForm = useForm({
@@ -161,7 +165,7 @@ const buildRadarChart = () => {
radarChartInstance = null; radarChartInstance = null;
} }
const isDark = document.documentElement.classList.contains('dark'); const isDark = false; // Désactivation forcée du mode sombre
const gridColor = isDark ? 'rgba(148,163,184,0.15)' : 'rgba(100,116,139,0.15)'; const gridColor = isDark ? 'rgba(148,163,184,0.15)' : 'rgba(100,116,139,0.15)';
const labelColor = isDark ? '#94a3b8' : '#64748b'; const labelColor = isDark ? '#94a3b8' : '#64748b';
@@ -248,6 +252,7 @@ watch(
const aiAnalysis = ref(props.candidate.ai_analysis || null); const aiAnalysis = ref(props.candidate.ai_analysis || null);
const isAnalyzing = ref(false); const isAnalyzing = ref(false);
const selectedProvider = ref(props.ai_config?.default || 'ollama'); const selectedProvider = ref(props.ai_config?.default || 'ollama');
const forceAnalysis = ref(false);
// Error Modal state // Error Modal state
const showErrorModal = ref(false); const showErrorModal = ref(false);
@@ -263,7 +268,8 @@ const runAI = async () => {
isAnalyzing.value = true; isAnalyzing.value = true;
try { try {
const response = await axios.post(route('admin.candidates.analyze', props.candidate.id), { const response = await axios.post(route('admin.candidates.analyze', props.candidate.id), {
provider: selectedProvider.value provider: selectedProvider.value,
force: forceAnalysis.value
}); });
aiAnalysis.value = response.data; aiAnalysis.value = response.data;
} catch (error) { } catch (error) {
@@ -310,7 +316,27 @@ const runAI = async () => {
<!-- Profile Card --> <!-- 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="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="h-24 bg-gradient-to-r from-indigo-500 to-purple-600"></div>
<div class="px-6 pb-6 text-center -mt-12"> <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'"
>
<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" 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"> <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) }} {{ candidate.user.name.charAt(0) }}
</div> </div>
@@ -630,6 +656,19 @@ const runAI = async () => {
</button> </button>
</div> </div>
<!-- Force option for Super Admin -->
<div v-if="$page.props.auth.user.role === 'super_admin'" class="flex items-center gap-2 px-4 py-2 bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-900/50 rounded-xl">
<input
type="checkbox"
id="forceAnalysis"
v-model="forceAnalysis"
class="rounded border-red-300 text-red-600 focus:ring-red-500/20 w-4 h-4 cursor-pointer"
/>
<label for="forceAnalysis" class="text-[10px] font-black uppercase tracking-widest text-red-600 cursor-pointer select-none">
Forcer (Bypass 7 jours)
</label>
</div>
<PrimaryButton <PrimaryButton
@click="runAI" @click="runAI"
:disabled="isAnalyzing" :disabled="isAnalyzing"
@@ -818,7 +857,7 @@ const runAI = async () => {
</div> </div>
</div> </div>
<div> <div>
<h4 class="text-xl font-black uppercase tracking-tight">{{ attempt.quiz.title }}</h4> <h4 class="text-xl font-black uppercase tracking-tight">{{ attempt.quiz?.title ?? 'Quiz supprimé' }}</h4>
<div class="text-xs text-slate-400 mt-1 uppercase font-bold tracking-widest"> <div class="text-xs text-slate-400 mt-1 uppercase font-bold tracking-widest">
Fini le {{ formatDateTime(attempt.finished_at) }} Fini le {{ formatDateTime(attempt.finished_at) }}
</div> </div>

View File

@@ -10,7 +10,8 @@ import InputError from '@/Components/InputError.vue';
const props = defineProps({ const props = defineProps({
jobPositions: Array, jobPositions: Array,
tenants: Array tenants: Array,
quizzes: Array
}); });
const showingModal = ref(false); const showingModal = ref(false);
@@ -22,6 +23,7 @@ const form = useForm({
requirements: [], requirements: [],
ai_prompt: '', ai_prompt: '',
tenant_id: '', tenant_id: '',
quiz_ids: [],
}); });
const openModal = (position = null) => { const openModal = (position = null) => {
@@ -32,6 +34,7 @@ const openModal = (position = null) => {
form.requirements = position.requirements || []; form.requirements = position.requirements || [];
form.ai_prompt = position.ai_prompt || ''; form.ai_prompt = position.ai_prompt || '';
form.tenant_id = position.tenant_id || ''; form.tenant_id = position.tenant_id || '';
form.quiz_ids = position.quizzes ? position.quizzes.map(q => q.id) : [];
} else { } else {
form.reset(); form.reset();
} }
@@ -203,6 +206,32 @@ const removeRequirement = (index) => {
<InputError :message="form.errors.ai_prompt" /> <InputError :message="form.errors.ai_prompt" />
</div> </div>
<div v-if="quizzes && quizzes.length > 0">
<label class="block text-xs font-black uppercase tracking-widest text-slate-400 mb-4">Tests techniques associés</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div
v-for="quiz in quizzes"
:key="quiz.id"
class="flex items-center p-3 bg-slate-50 dark:bg-slate-900 rounded-2xl cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
@click="form.quiz_ids.includes(quiz.id) ? form.quiz_ids = form.quiz_ids.filter(id => id !== quiz.id) : form.quiz_ids.push(quiz.id)"
>
<div
class="w-5 h-5 rounded-md border-2 mr-3 flex items-center justify-center transition-all"
:class="form.quiz_ids.includes(quiz.id) ? 'bg-indigo-600 border-indigo-600' : 'border-slate-300 dark:border-slate-600'"
>
<svg v-if="form.quiz_ids.includes(quiz.id)" xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<div class="text-xs font-bold leading-tight">{{ quiz.title }}</div>
<div class="text-[9px] text-slate-400 uppercase tracking-tighter">{{ quiz.duration_minutes }} min</div>
</div>
</div>
</div>
<InputError :message="form.errors.quiz_ids" />
</div>
<div> <div>
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<label class="text-xs font-black uppercase tracking-widest text-slate-400">Compétences clés / Pré-requis</label> <label class="text-xs font-black uppercase tracking-widest text-slate-400">Compétences clés / Pré-requis</label>

View File

@@ -1,9 +1,10 @@
<script setup> <script setup>
import { Head, useForm, Link, usePage } from '@inertiajs/vue3'; import { Head, useForm, Link, usePage } from '@inertiajs/vue3';
import { ref } from 'vue'; import { ref, computed } from 'vue';
import AdminLayout from '@/Layouts/AdminLayout.vue'; import AdminLayout from '@/Layouts/AdminLayout.vue';
const page = usePage(); const page = usePage();
const flashSuccess = computed(() => page.props.flash?.success);
const props = defineProps({ const props = defineProps({
users: Array, users: Array,
@@ -71,6 +72,19 @@ const cancel = () => {
<AdminLayout> <AdminLayout>
<template #header>Équipe / Utilisateurs Admin</template> <template #header>Équipe / Utilisateurs Admin</template>
<!-- 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-xl 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">
<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 class="flex-1">
<p class="font-bold text-emerald-800 dark:text-emerald-400">Action réussie !</p>
<p class="text-emerald-700 dark:text-emerald-500 text-sm">{{ flashSuccess }}</p>
</div>
</div>
<div class="mb-6 flex justify-between items-center"> <div class="mb-6 flex justify-between items-center">
<h1 class="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-500 to-indigo-500"> <h1 class="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-500 to-indigo-500">
Administrateurs Plateforme Administrateurs Plateforme

View File

@@ -10,7 +10,7 @@ const props = defineProps({
const currentQuestionIndex = ref(0); const currentQuestionIndex = ref(0);
const answers = ref({}); const answers = ref({});
const timeLeft = ref(props.quiz.duration_minutes * 60); const timeLeft = ref(props.quiz?.duration_minutes ? props.quiz.duration_minutes * 60 : 0);
let timer = null; let timer = null;
// Initialize answers from existing attempt answers if any // Initialize answers from existing attempt answers if any
@@ -58,8 +58,8 @@ const formatTime = (seconds) => {
return `${mins}:${secs.toString().padStart(2, '0')}`; return `${mins}:${secs.toString().padStart(2, '0')}`;
}; };
const currentQuestion = computed(() => props.quiz.questions[currentQuestionIndex.value]); const currentQuestion = computed(() => props.quiz.questions?.[currentQuestionIndex.value] ?? null);
const progress = computed(() => ((currentQuestionIndex.value + 1) / props.quiz.questions.length) * 100); const progress = computed(() => props.quiz.questions?.length ? ((currentQuestionIndex.value + 1) / props.quiz.questions.length) * 100 : 0);
const saveAnswer = async () => { const saveAnswer = async () => {
const qid = currentQuestion.value.id; const qid = currentQuestion.value.id;
@@ -150,8 +150,13 @@ const finishQuiz = () => {
<!-- Main Content --> <!-- Main Content -->
<main class="flex-1 flex flex-col items-center justify-center p-8"> <main class="flex-1 flex flex-col items-center justify-center p-8">
<div class="w-full max-w-3xl"> <div class="w-full max-w-3xl">
<!-- Guard: only render if quiz has questions -->
<div v-if="!quiz.questions || !quiz.questions.length" class="text-center py-12">
<p class="text-slate-400 italic">Aucune question disponible pour ce test.</p>
</div>
<!-- Question Card --> <!-- Question Card -->
<div class="bg-slate-800/50 backdrop-blur-xl rounded-3xl p-8 sm:p-12 border border-slate-700 shadow-2xl relative overflow-hidden group"> <div v-else class="bg-slate-800/50 backdrop-blur-xl rounded-3xl p-8 sm:p-12 border border-slate-700 shadow-2xl relative overflow-hidden group">
<!-- Subtle background glow --> <!-- Subtle background glow -->
<div class="absolute -top-24 -right-24 w-64 h-64 bg-indigo-600/10 blur-[100px] rounded-full group-hover:bg-indigo-600/20 transition-all duration-700"></div> <div class="absolute -top-24 -right-24 w-64 h-64 bg-indigo-600/10 blur-[100px] rounded-full group-hover:bg-indigo-600/20 transition-all duration-700"></div>
@@ -202,7 +207,8 @@ const finishQuiz = () => {
<textarea <textarea
class="w-full h-48 bg-slate-700/30 border border-slate-600 rounded-2xl p-6 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all outline-none resize-none text-lg" class="w-full h-48 bg-slate-700/30 border border-slate-600 rounded-2xl p-6 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all outline-none resize-none text-lg"
placeholder="Saisissez votre réponse ici..." placeholder="Saisissez votre réponse ici..."
v-model="answers[currentQuestion.id].text_content" :value="answers[currentQuestion.id]?.text_content ?? ''"
@input="updateOpenAnswer($event.target.value)"
@blur="saveAnswer" @blur="saveAnswer"
></textarea> ></textarea>
</div> </div>

View File

@@ -38,7 +38,7 @@ const getStatusColor = (status) => {
<span v-if="user.role === 'super_admin'" class="bg-gradient-to-r from-red-600 to-orange-500 text-white px-3 py-1 rounded-full text-[10px] font-black tracking-widest uppercase shadow-sm"> <span v-if="user.role === 'super_admin'" class="bg-gradient-to-r from-red-600 to-orange-500 text-white px-3 py-1 rounded-full text-[10px] font-black tracking-widest uppercase shadow-sm">
GOD MODE GOD MODE
</span> </span>
<span v-else-if="user.tenant" class="bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-400 border border-indigo-200 dark:border-indigo-800 px-3 py-1 rounded-full text-[10px] font-black tracking-widest uppercase"> <span v-else-if="user.tenant" class="bg-indigo-600 text-white dark:bg-indigo-500/20 dark:text-indigo-300 px-3 py-1 rounded-full text-[10px] font-black tracking-widest uppercase border border-indigo-700 dark:border-indigo-400/30">
Structure : {{ user.tenant.name }} Structure : {{ user.tenant.name }}
</span> </span>
</div> </div>
@@ -46,11 +46,23 @@ const getStatusColor = (status) => {
<div v-if="isAdmin" class="p-8 space-y-8"> <div v-if="isAdmin" class="p-8 space-y-8">
<!-- KPI Cards --> <!-- KPI Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
<div class="bg-white dark:bg-slate-800 p-6 rounded-3xl shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-xl transition-all duration-300"> <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-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> <div class="text-4xl font-black mt-2 text-indigo-600 dark:text-indigo-400">{{ stats.total_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 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>
<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">
<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>
</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="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-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> <div class="text-4xl font-black mt-2 text-emerald-600 dark:text-emerald-400">{{ stats.finished_tests }}</div>
@@ -61,7 +73,7 @@ const getStatusColor = (status) => {
</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="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-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-amber-600 dark:text-amber-400">{{ stats.best_score }} / 20</div> <div class="text-4xl font-black mt-2 text-purple-600 dark:text-purple-400">{{ stats.best_score }} / 20</div>
</div> </div>
</div> </div>
@@ -142,41 +154,91 @@ const getStatusColor = (status) => {
</div> </div>
</div> </div>
<div v-else class="py-12"> <!-- Candidate Dashboard: LIGHT ONLY, high contrast, no dark: classes -->
<div class="mx-auto max-w-3xl sm:px-6 lg:px-8"> <div v-else style="background: linear-gradient(135deg, #f8faff 0%, #eef2ff 100%); min-height: calc(100vh - 4rem);" class="flex flex-col items-center justify-center px-4 py-16">
<div class="bg-white dark:bg-slate-800 p-12 rounded-3xl shadow-xl border border-slate-200 dark:border-slate-700 text-center"> <div class="w-full max-w-4xl">
<h3 class="text-3xl font-black mb-4">Bienvenue, {{ user.name }} !</h3>
<p class="text-slate-500 dark:text-slate-400 mb-12">
Veuillez sélectionner le test technique auquel vous avez été invité.
Prenez le temps de vous installer confortablement avant de commencer.
</p>
<div v-if="quizzes.length > 0" class="space-y-4"> <!-- Welcome Section -->
<div v-for="quiz in quizzes" :key="quiz.id" class="p-6 bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-2xl flex flex-col sm:flex-row items-center justify-between gap-6 group hover:border-indigo-500 transition-all duration-300"> <div class="mb-12 text-center">
<div class="text-left flex-1"> <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;">
<h4 class="text-xl font-bold group-hover:text-indigo-600 transition-colors">{{ quiz.title }}</h4> Espace Candidat
<p class="text-sm text-slate-500 mt-1">{{ quiz.duration_minutes }} minutes {{ quiz.description }}</p> </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>
<p style="color:#6b7280; font-size:1.1rem; max-width:40rem; margin:0 auto; line-height:1.7;">
Voici les tests techniques préparés pour votre candidature. Installez-vous confortablement avant de commencer.
</p>
</div> </div>
<div v-if="quiz.has_finished_attempt" class="flex items-center gap-2 px-6 py-3 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-xl text-emerald-600 dark:text-emerald-400 font-bold whitespace-nowrap"> <!-- Quizzes Grid -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <div v-if="quizzes && quizzes.length > 0" class="grid grid-cols-1 md:grid-cols-2 gap-8">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> <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)'"
>
<!-- 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>
<!-- 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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg> </svg>
Test effectué </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;">
{{ 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>
<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>
<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">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
Terminé
</div> </div>
<Link <Link
v-else v-else
:href="route('quizzes.take', quiz.id)" :href="route('quizzes.take', quiz.id)"
class="bg-indigo-600 text-white px-8 py-3 rounded-xl font-bold hover:bg-slate-900 dark:hover:bg-white dark:hover:text-slate-900 transition-all duration-300 shadow-lg shadow-indigo-600/20 active:scale-95 whitespace-nowrap" 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)'"
> >
Démarrer le test Démarrer
</Link> </Link>
</div> </div>
</div> </div>
<div v-else class="py-12 text-slate-500 italic">
Aucun test ne vous est assigné pour le moment.
</div> </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">
<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> </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;">
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>
</div> </div>
</div> </div>
</component> </component>

View File

@@ -26,6 +26,7 @@ Route::get('/dashboard', function () {
$allCandidates = Candidate::with(['attempts'])->get(); $allCandidates = Candidate::with(['attempts'])->get();
$stats = [ $stats = [
'total_candidates' => Candidate::count(), 'total_candidates' => Candidate::count(),
'selected_candidates' => Candidate::where('is_selected', true)->count(),
'finished_tests' => Attempt::whereNotNull('finished_at')->count(), 'finished_tests' => Attempt::whereNotNull('finished_at')->count(),
'average_score' => round($allCandidates->avg('weighted_score') ?? 0, 1), 'average_score' => round($allCandidates->avg('weighted_score') ?? 0, 1),
'best_score' => round($allCandidates->max('weighted_score') ?? 0, 1), 'best_score' => round($allCandidates->max('weighted_score') ?? 0, 1),
@@ -49,7 +50,16 @@ Route::get('/dashboard', function () {
->all(); ->all();
} else { } else {
$candidate = auth()->user()->candidate; $candidate = auth()->user()->candidate;
$quizzes = \App\Models\Quiz::all()->map(function($quiz) use ($candidate) {
if ($candidate) {
$candidate->load('jobPosition.quizzes');
}
$quizzes = ($candidate && $candidate->jobPosition)
? $candidate->jobPosition->quizzes
: collect();
$quizzes = $quizzes->map(function($quiz) use ($candidate) {
$quiz->has_finished_attempt = $candidate $quiz->has_finished_attempt = $candidate
? $candidate->attempts()->where('quiz_id', $quiz->id)->whereNotNull('finished_at')->exists() ? $candidate->attempts()->where('quiz_id', $quiz->id)->whereNotNull('finished_at')->exists()
: false; : false;
@@ -77,6 +87,7 @@ Route::middleware('auth')->group(function () {
Route::patch('/candidates/{candidate}/scores', [\App\Http\Controllers\CandidateController::class, 'updateScores'])->name('candidates.update-scores'); Route::patch('/candidates/{candidate}/scores', [\App\Http\Controllers\CandidateController::class, 'updateScores'])->name('candidates.update-scores');
Route::patch('/candidates/{candidate}/position', [\App\Http\Controllers\CandidateController::class, 'updatePosition'])->name('candidates.update-position'); Route::patch('/candidates/{candidate}/position', [\App\Http\Controllers\CandidateController::class, 'updatePosition'])->name('candidates.update-position');
Route::patch('/candidates/{candidate}/tenant', [\App\Http\Controllers\CandidateController::class, 'updateTenant'])->name('candidates.update-tenant'); Route::patch('/candidates/{candidate}/tenant', [\App\Http\Controllers\CandidateController::class, 'updateTenant'])->name('candidates.update-tenant');
Route::patch('/candidates/{candidate}/toggle-selection', [\App\Http\Controllers\CandidateController::class, 'toggleSelection'])->name('candidates.toggle-selection');
Route::post('/candidates/{candidate}/analyze', [\App\Http\Controllers\AIAnalysisController::class, 'analyze'])->name('candidates.analyze'); Route::post('/candidates/{candidate}/analyze', [\App\Http\Controllers\AIAnalysisController::class, 'analyze'])->name('candidates.analyze');
Route::post('/candidates/{candidate}/reset-password', [\App\Http\Controllers\CandidateController::class, 'resetPassword'])->name('candidates.reset-password'); Route::post('/candidates/{candidate}/reset-password', [\App\Http\Controllers\CandidateController::class, 'resetPassword'])->name('candidates.reset-password');
Route::get('/documents/{document}', [\App\Http\Controllers\DocumentController::class, 'show'])->name('documents.show'); Route::get('/documents/{document}', [\App\Http\Controllers\DocumentController::class, 'show'])->name('documents.show');

View File

@@ -10,6 +10,7 @@ export default {
'./resources/views/**/*.blade.php', './resources/views/**/*.blade.php',
'./resources/js/**/*.vue', './resources/js/**/*.vue',
], ],
darkMode: 'class',
theme: { theme: {
extend: { extend: {