Compare commits

4 Commits

11 changed files with 165 additions and 25 deletions

View File

@@ -23,7 +23,10 @@ class AIAnalysisController extends Controller
}
// 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']);
if ($lastAnalysis->diffInDays(now()) < 7) {
return response()->json([

View File

@@ -41,6 +41,12 @@ class RegisteredUserController extends Controller
'name' => $request->name,
'email' => $request->email,
'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));

View File

@@ -237,6 +237,15 @@ class CandidateController extends Controller
$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)
{
if (!$file) {

View File

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

View File

@@ -25,7 +25,10 @@ trait BelongsToTenant
}
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,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

@@ -43,10 +43,14 @@ const submit = () => {
const deleteCandidate = (id) => {
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) => {
selectedDocument.value = doc;
};
@@ -69,11 +73,24 @@ const getNestedValue = (obj, path) => {
};
const selectedJobPosition = ref('');
const showOnlySelected = ref(false);
const filteredCandidates = computed(() => {
if (selectedJobPosition.value === '') return 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);
let result = props.candidates;
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(() => {
@@ -102,6 +119,13 @@ const sortedCandidates = computed(() => {
<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">
<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">
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">Filtrer par fiche de poste :</label>
<select
@@ -116,6 +140,7 @@ const sortedCandidates = computed(() => {
</select>
</div>
</div>
</div>
<PrimaryButton @click="isModalOpen = true">
Ajouter un Candidat
</PrimaryButton>
@@ -139,6 +164,7 @@ const sortedCandidates = computed(() => {
<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"></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
@@ -201,6 +227,16 @@ const sortedCandidates = computed(() => {
</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">
<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>
@@ -294,7 +330,7 @@ const sortedCandidates = computed(() => {
</td>
</tr>
<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é.
</td>
</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 docForm = useForm({
@@ -161,7 +165,7 @@ const buildRadarChart = () => {
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 labelColor = isDark ? '#94a3b8' : '#64748b';
@@ -248,6 +252,7 @@ watch(
const aiAnalysis = ref(props.candidate.ai_analysis || null);
const isAnalyzing = ref(false);
const selectedProvider = ref(props.ai_config?.default || 'ollama');
const forceAnalysis = ref(false);
// Error Modal state
const showErrorModal = ref(false);
@@ -263,7 +268,8 @@ const runAI = async () => {
isAnalyzing.value = true;
try {
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;
} catch (error) {
@@ -310,7 +316,27 @@ const runAI = async () => {
<!-- 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">
<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">
{{ candidate.user.name.charAt(0) }}
</div>
@@ -630,6 +656,19 @@ const runAI = async () => {
</button>
</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
@click="runAI"
:disabled="isAnalyzing"

View File

@@ -46,11 +46,23 @@ const getStatusColor = (status) => {
<div v-if="isAdmin" class="p-8 space-y-8">
<!-- 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="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>
<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="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>
@@ -61,7 +73,7 @@ const getStatusColor = (status) => {
</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-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>

View File

@@ -26,6 +26,7 @@ Route::get('/dashboard', function () {
$allCandidates = Candidate::with(['attempts'])->get();
$stats = [
'total_candidates' => Candidate::count(),
'selected_candidates' => Candidate::where('is_selected', true)->count(),
'finished_tests' => Attempt::whereNotNull('finished_at')->count(),
'average_score' => round($allCandidates->avg('weighted_score') ?? 0, 1),
'best_score' => round($allCandidates->max('weighted_score') ?? 0, 1),
@@ -86,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}/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}/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}/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');

View File

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