Compare commits
50 Commits
6c1f6af523
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
589e9956f9 | ||
|
|
d6e5b44e47 | ||
|
|
c4ab5c97b2 | ||
|
|
84a9c6bb14 | ||
|
|
957947cc0b | ||
|
|
6e4eb62553 | ||
|
|
c74d8e14ec | ||
|
|
e93a17f324 | ||
|
|
49ee91c601 | ||
|
|
479a7e35d1 | ||
|
|
de6938d2e6 | ||
|
|
91213cc371 | ||
|
|
af4502859b | ||
|
|
107e2d0a1d | ||
|
|
71672509b6 | ||
|
|
351bdda2a1 | ||
|
|
21d4aaee59 | ||
|
|
ec1fe91b35 | ||
|
|
8c577cfaa7 | ||
|
|
4a137fc511 | ||
|
|
e68108a2b1 | ||
|
|
4810ca9d9c | ||
|
|
7ece2036c1 | ||
|
|
d0ecfa3e96 | ||
|
|
38fe4a22d6 | ||
|
|
a3057aae6f | ||
|
|
2e423445f5 | ||
|
|
f53d5770df | ||
|
|
7d94be7a8c | ||
|
|
03006051a9 | ||
|
|
837bf367e9 | ||
|
|
7a05b7e6b3 | ||
|
|
40c8aa2e5a | ||
|
|
30918870a2 | ||
|
|
5a8d9b494b | ||
|
|
0a82bf5017 | ||
|
|
bee5215a5b | ||
|
|
78245f2bee | ||
|
|
cd70edb483 | ||
|
|
4660c94869 | ||
|
|
e3b1a2583f | ||
|
|
e02c6849fe | ||
|
|
10b866fc47 | ||
|
|
937857a842 | ||
|
|
949423b1ae | ||
|
|
e6df75c1ff | ||
|
|
de0392bbe7 | ||
|
|
4459cbde69 | ||
|
|
26d723f239 | ||
|
|
33fcdcac3d |
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||
use App\Models\Candidate;
|
||||
use App\Services\AIAnalysisService;
|
||||
use Illuminate\Http\Request;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class AIAnalysisController extends Controller
|
||||
{
|
||||
@@ -15,14 +16,33 @@ class AIAnalysisController extends Controller
|
||||
$this->aiService = $aiService;
|
||||
}
|
||||
|
||||
public function analyze(Candidate $candidate)
|
||||
public function analyze(Request $request, Candidate $candidate)
|
||||
{
|
||||
if (!auth()->user()->isAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Restriction: Une analyse tous les 7 jours maximum par candidat
|
||||
// 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([
|
||||
'error' => "Une analyse a déjà été effectuée il y a moins de 7 jours. Merci de patienter avant de relancer l'IA."
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$analysis = $this->aiService->analyze($candidate);
|
||||
$analysis = $this->aiService->analyze($candidate, $request->provider);
|
||||
|
||||
// Persist the analysis on the candidate profile
|
||||
$candidate->update([
|
||||
'ai_analysis' => $analysis
|
||||
]);
|
||||
|
||||
return response()->json($analysis);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => $e->getMessage()], 500);
|
||||
|
||||
@@ -19,7 +19,9 @@ class AttemptController extends Controller
|
||||
$this->authorizeAdmin();
|
||||
|
||||
$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) {
|
||||
// Log the action
|
||||
@@ -74,11 +76,15 @@ class AttemptController extends Controller
|
||||
$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', [
|
||||
'quiz' => $quiz,
|
||||
'attempt' => $attempt->load('answers')
|
||||
'quiz' => $quizData,
|
||||
'attempt' => $attempt->load('answers'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
60
app/Http/Controllers/BackupController.php
Normal file
60
app/Http/Controllers/BackupController.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use ZipArchive;
|
||||
|
||||
class BackupController extends Controller
|
||||
{
|
||||
public function download()
|
||||
{
|
||||
$databaseName = env('DB_DATABASE');
|
||||
$userName = env('DB_USERNAME');
|
||||
$password = env('DB_PASSWORD');
|
||||
$host = env('DB_HOST', '127.0.0.1');
|
||||
|
||||
$backupFileName = 'backup_quizzcabm_' . date('Y-m-d_H-i-s') . '.zip';
|
||||
$backupFilePath = storage_path('app/' . $backupFileName);
|
||||
|
||||
$sqlDumpFilePath = storage_path('app/dump.sql');
|
||||
|
||||
// Execute mysqldump
|
||||
$command = "mysqldump --user={$userName} --password={$password} --host={$host} {$databaseName} > " . escapeshellarg($sqlDumpFilePath);
|
||||
if (empty($password)) {
|
||||
$command = "mysqldump --user={$userName} --host={$host} {$databaseName} > " . escapeshellarg($sqlDumpFilePath);
|
||||
}
|
||||
|
||||
exec($command);
|
||||
|
||||
$zip = new ZipArchive;
|
||||
if ($zip->open($backupFilePath, ZipArchive::CREATE) === TRUE) {
|
||||
// Add DB dump
|
||||
if (file_exists($sqlDumpFilePath)) {
|
||||
$zip->addFile($sqlDumpFilePath, 'database.sql');
|
||||
}
|
||||
|
||||
// Add documents specifically searching the local disk
|
||||
$allFiles = Storage::disk('local')->allFiles();
|
||||
foreach ($allFiles as $filePath) {
|
||||
$fullPath = Storage::disk('local')->path($filePath);
|
||||
// The zip structure will have a folder 'storage_files' containing the relative path
|
||||
$zip->addFile($fullPath, 'storage_files/' . $filePath);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
if (file_exists($sqlDumpFilePath)) {
|
||||
unlink($sqlDumpFilePath);
|
||||
}
|
||||
|
||||
if (file_exists($backupFilePath)) {
|
||||
return response()->download($backupFilePath)->deleteFileAfterSend(true);
|
||||
}
|
||||
|
||||
return back()->with('error', 'Erreur lors de la création de la sauvegarde.');
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,14 @@ class CandidateController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$candidates = Candidate::with(['user', 'documents', 'attempts'])->latest()->get();
|
||||
$candidates = Candidate::with(['user', 'documents', 'attempts', 'tenant', 'jobPosition'])->latest()->get();
|
||||
$jobPositions = \App\Models\JobPosition::orderBy('title')->get();
|
||||
$tenants = \App\Models\Tenant::orderBy('name')->get();
|
||||
|
||||
return \Inertia\Inertia::render('Admin/Candidates/Index', [
|
||||
'candidates' => $candidates
|
||||
'candidates' => $candidates,
|
||||
'jobPositions' => $jobPositions,
|
||||
'tenants' => $tenants
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -42,8 +46,10 @@ class CandidateController extends Controller
|
||||
'email' => 'required|string|email|max:255|unique:users',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'linkedin_url' => 'nullable|url|max:255',
|
||||
'cv' => 'nullable|file|mimes:pdf|max:5120',
|
||||
'cover_letter' => 'nullable|file|mimes:pdf|max:5120',
|
||||
'cv' => 'nullable|mimes:pdf|max:5120',
|
||||
'cover_letter' => 'nullable|mimes:pdf|max:5120',
|
||||
'tenant_id' => 'nullable|exists:tenants,id',
|
||||
'job_position_id' => 'nullable|exists:job_positions,id',
|
||||
]);
|
||||
|
||||
$password = Str::random(10);
|
||||
@@ -51,15 +57,17 @@ class CandidateController extends Controller
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($password),
|
||||
'password' => Hash::make(Str::random(12)),
|
||||
'role' => 'candidate',
|
||||
'tenant_id' => auth()->user()->isSuperAdmin() ? $request->tenant_id : auth()->user()->tenant_id,
|
||||
]);
|
||||
|
||||
$candidate = Candidate::create([
|
||||
'user_id' => $user->id,
|
||||
$candidate = $user->candidate()->create([
|
||||
'phone' => $request->phone,
|
||||
'linkedin_url' => $request->linkedin_url,
|
||||
'status' => 'en_attente',
|
||||
'tenant_id' => auth()->user()->isSuperAdmin() ? $request->tenant_id : auth()->user()->tenant_id,
|
||||
'job_position_id' => $request->job_position_id,
|
||||
]);
|
||||
|
||||
$this->storeDocument($candidate, $request->file('cv'), 'cv');
|
||||
@@ -73,16 +81,42 @@ class CandidateController extends Controller
|
||||
$candidate->load([
|
||||
'user',
|
||||
'documents',
|
||||
'attempts.quiz',
|
||||
'attempts.answers.question',
|
||||
'attempts.answers.option',
|
||||
'jobPosition'
|
||||
'jobPosition',
|
||||
'tenant'
|
||||
]);
|
||||
|
||||
return \Inertia\Inertia::render('Admin/Candidates/Show', [
|
||||
// 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 = [
|
||||
'candidate' => $candidate,
|
||||
'jobPositions' => \App\Models\JobPosition::all()
|
||||
]);
|
||||
'jobPositions' => \App\Models\JobPosition::all(),
|
||||
'ai_config' => [
|
||||
'default' => env('AI_DEFAULT_PROVIDER', 'ollama'),
|
||||
'enabled_providers' => array_filter([
|
||||
'ollama' => true, // Toujours dispo car local ou simulé
|
||||
'openai' => !empty(env('OPENAI_API_KEY')),
|
||||
'anthropic' => !empty(env('ANTHROPIC_API_KEY')),
|
||||
'gemini' => !empty(env('GEMINI_API_KEY')),
|
||||
], function($v) { return $v; })
|
||||
]
|
||||
];
|
||||
|
||||
if (auth()->user()->isSuperAdmin()) {
|
||||
$data['tenants'] = \App\Models\Tenant::orderBy('name')->get();
|
||||
}
|
||||
|
||||
return \Inertia\Inertia::render('Admin/Candidates/Show', $data);
|
||||
}
|
||||
|
||||
public function destroy(Candidate $candidate)
|
||||
@@ -157,6 +191,30 @@ class CandidateController extends Controller
|
||||
return back()->with('success', 'Fiche de poste associée au candidat.');
|
||||
}
|
||||
|
||||
public function updateTenant(Request $request, Candidate $candidate)
|
||||
{
|
||||
if (!auth()->user()->isSuperAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'tenant_id' => 'nullable|exists:tenants,id',
|
||||
]);
|
||||
|
||||
$candidate->update([
|
||||
'tenant_id' => $request->tenant_id,
|
||||
]);
|
||||
|
||||
// Also update the associated user's tenant_id if it exists
|
||||
if ($candidate->user) {
|
||||
$candidate->user->update([
|
||||
'tenant_id' => $request->tenant_id,
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Structure de rattachement mise à jour avec succès.');
|
||||
}
|
||||
|
||||
public function resetPassword(Candidate $candidate)
|
||||
{
|
||||
$password = Str::random(10);
|
||||
@@ -179,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) {
|
||||
|
||||
@@ -13,7 +13,9 @@ class JobPositionController extends Controller
|
||||
$this->authorizeAdmin();
|
||||
|
||||
return Inertia::render('Admin/JobPositions/Index', [
|
||||
'jobPositions' => JobPosition::all()
|
||||
'jobPositions' => JobPosition::with(['tenant', 'quizzes'])->get(),
|
||||
'tenants' => \App\Models\Tenant::orderBy('name')->get(),
|
||||
'quizzes' => \App\Models\Quiz::all()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -25,14 +27,22 @@ class JobPositionController extends Controller
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'required|string',
|
||||
'requirements' => 'nullable|array',
|
||||
'ai_prompt' => 'nullable|string',
|
||||
'tenant_id' => 'nullable|exists:tenants,id',
|
||||
'quiz_ids' => 'nullable|array',
|
||||
'quiz_ids.*' => 'exists:quizzes,id',
|
||||
]);
|
||||
|
||||
JobPosition::create([
|
||||
$jobPosition = JobPosition::create([
|
||||
'title' => $request->title,
|
||||
'description' => $request->description,
|
||||
'requirements' => $request->requirements,
|
||||
'ai_prompt' => $request->ai_prompt,
|
||||
'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.');
|
||||
}
|
||||
|
||||
@@ -44,14 +54,22 @@ class JobPositionController extends Controller
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'required|string',
|
||||
'requirements' => 'nullable|array',
|
||||
'ai_prompt' => 'nullable|string',
|
||||
'tenant_id' => 'nullable|exists:tenants,id',
|
||||
'quiz_ids' => 'nullable|array',
|
||||
'quiz_ids.*' => 'exists:quizzes,id',
|
||||
]);
|
||||
|
||||
$jobPosition->update([
|
||||
'title' => $request->title,
|
||||
'description' => $request->description,
|
||||
'requirements' => $request->requirements,
|
||||
'ai_prompt' => $request->ai_prompt,
|
||||
'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.');
|
||||
}
|
||||
|
||||
|
||||
64
app/Http/Controllers/TenantController.php
Normal file
64
app/Http/Controllers/TenantController.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class TenantController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
if (!auth()->user()->isSuperAdmin()) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
$tenants = Tenant::orderBy('name')->get();
|
||||
|
||||
return Inertia::render('Admin/Tenants/Index', [
|
||||
'tenants' => $tenants
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
if (!auth()->user()->isSuperAdmin()) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255|unique:tenants,name',
|
||||
]);
|
||||
|
||||
Tenant::create($request->only('name'));
|
||||
|
||||
return back()->with('success', 'Structure créée avec succès.');
|
||||
}
|
||||
|
||||
public function update(Request $request, Tenant $tenant)
|
||||
{
|
||||
if (!auth()->user()->isSuperAdmin()) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255|unique:tenants,name,' . $tenant->id,
|
||||
]);
|
||||
|
||||
$tenant->update($request->only('name'));
|
||||
|
||||
return back()->with('success', 'Structure mise à jour.');
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant)
|
||||
{
|
||||
if (!auth()->user()->isSuperAdmin()) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
$tenant->delete();
|
||||
|
||||
return back()->with('success', 'Structure supprimée.');
|
||||
}
|
||||
}
|
||||
111
app/Http/Controllers/UserController.php
Normal file
111
app/Http/Controllers/UserController.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
if (!auth()->user()->isSuperAdmin()) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
$users = User::whereIn('role', ['admin', 'super_admin'])
|
||||
->with('tenant')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$tenants = Tenant::orderBy('name')->get();
|
||||
|
||||
return Inertia::render('Admin/Users/Index', [
|
||||
'users' => $users,
|
||||
'tenants' => $tenants
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
if (!auth()->user()->isSuperAdmin()) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|string|email|max:255|unique:users',
|
||||
'role' => ['required', Rule::in(['admin', 'super_admin'])],
|
||||
'tenant_id' => 'nullable|exists:tenants,id',
|
||||
]);
|
||||
|
||||
$password = Str::random(10);
|
||||
|
||||
User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($password),
|
||||
'role' => $request->role,
|
||||
'tenant_id' => $request->role === 'super_admin' ? null : $request->tenant_id,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Administrateur créé avec succès. Mot de passe généré : ' . $password);
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user)
|
||||
{
|
||||
if (!auth()->user()->isSuperAdmin()) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|string|email|max:255|unique:users,email,' . $user->id,
|
||||
'role' => ['required', Rule::in(['admin', 'super_admin'])],
|
||||
'tenant_id' => 'nullable|exists:tenants,id',
|
||||
]);
|
||||
|
||||
$user->update([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'role' => $request->role,
|
||||
'tenant_id' => $request->role === 'super_admin' ? null : $request->tenant_id,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Administrateur mis à jour.');
|
||||
}
|
||||
|
||||
public function destroy(User $user)
|
||||
{
|
||||
if (!auth()->user()->isSuperAdmin()) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
if ($user->id === auth()->id()) {
|
||||
return back()->with('error', 'Vous ne pouvez pas supprimer votre propre compte.');
|
||||
}
|
||||
|
||||
$user->delete();
|
||||
|
||||
return back()->with('success', 'Administrateur supprimé.');
|
||||
}
|
||||
|
||||
public function resetPassword(User $user)
|
||||
{
|
||||
if (!auth()->user()->isSuperAdmin()) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
$password = Str::random(10);
|
||||
$user->update([
|
||||
'password' => Hash::make($password)
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Nouveau mot de passe généré pour ' . $user->name . ' : ' . $password);
|
||||
}
|
||||
}
|
||||
@@ -32,12 +32,14 @@ class HandleInertiaRequests extends Middleware
|
||||
return [
|
||||
...parent::share($request),
|
||||
'auth' => [
|
||||
'user' => $request->user(),
|
||||
'user' => $request->user() ? clone $request->user()->load('tenant') : null,
|
||||
],
|
||||
'flash' => [
|
||||
'success' => $request->session()->get('success'),
|
||||
'error' => $request->session()->get('error'),
|
||||
],
|
||||
'app_env' => config('app.env'),
|
||||
'app_version' => config('app.version'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,29 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
#[Fillable(['user_id', 'job_position_id', 'phone', 'linkedin_url', 'status', 'notes', 'cv_score', 'motivation_score', 'interview_score'])]
|
||||
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'])]
|
||||
class Candidate extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasFactory, BelongsToTenant;
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($candidate) {
|
||||
if ($candidate->job_position_id && !$candidate->tenant_id) {
|
||||
$jobPosition = JobPosition::find($candidate->job_position_id);
|
||||
if ($jobPosition) {
|
||||
$candidate->tenant_id = $jobPosition->tenant_id;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected $casts = [
|
||||
'ai_analysis' => 'array',
|
||||
'is_selected' => 'boolean',
|
||||
];
|
||||
|
||||
public function jobPosition(): BelongsTo
|
||||
{
|
||||
|
||||
@@ -7,10 +7,12 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
#[Fillable(['title', 'description', 'requirements'])]
|
||||
use App\Traits\BelongsToTenant;
|
||||
|
||||
#[Fillable(['title', 'description', 'requirements', 'ai_prompt', 'tenant_id'])]
|
||||
class JobPosition extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasFactory, BelongsToTenant;
|
||||
|
||||
protected $casts = [
|
||||
'requirements' => 'array',
|
||||
@@ -20,4 +22,9 @@ class JobPosition extends Model
|
||||
{
|
||||
return $this->hasMany(Candidate::class);
|
||||
}
|
||||
|
||||
public function quizzes()
|
||||
{
|
||||
return $this->belongsToMany(Quiz::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,20 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
#[Fillable(['title', 'description', 'duration_minutes'])]
|
||||
use App\Traits\BelongsToTenant;
|
||||
|
||||
#[Fillable(['title', 'description', 'duration_minutes', 'tenant_id'])]
|
||||
class Quiz extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasFactory, BelongsToTenant;
|
||||
|
||||
public function questions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Question::class);
|
||||
}
|
||||
|
||||
public function jobPositions()
|
||||
{
|
||||
return $this->belongsToMany(JobPosition::class);
|
||||
}
|
||||
}
|
||||
|
||||
31
app/Models/Tenant.php
Normal file
31
app/Models/Tenant.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
|
||||
#[Fillable(['name'])]
|
||||
class Tenant extends Model
|
||||
{
|
||||
public function users()
|
||||
{
|
||||
return $this->hasMany(User::class);
|
||||
}
|
||||
|
||||
public function candidates()
|
||||
{
|
||||
return $this->hasMany(Candidate::class);
|
||||
}
|
||||
|
||||
public function quizzes()
|
||||
{
|
||||
return $this->hasMany(Quiz::class);
|
||||
}
|
||||
|
||||
public function jobPositions()
|
||||
{
|
||||
return $this->hasMany(JobPosition::class);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password', 'role'])]
|
||||
#[Fillable(['name', 'email', 'password', 'role', 'tenant_id'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
@@ -19,7 +19,12 @@ class User extends Authenticatable
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->role === 'admin';
|
||||
return in_array($this->role, ['admin', 'super_admin']);
|
||||
}
|
||||
|
||||
public function isSuperAdmin(): bool
|
||||
{
|
||||
return $this->role === 'super_admin';
|
||||
}
|
||||
|
||||
public function isCandidate(): bool
|
||||
@@ -32,6 +37,11 @@ class User extends Authenticatable
|
||||
return $this->hasOne(Candidate::class);
|
||||
}
|
||||
|
||||
public function tenant()
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
|
||||
@@ -21,7 +21,7 @@ class AIAnalysisService
|
||||
/**
|
||||
* Analyze a candidate against their assigned Job Position.
|
||||
*/
|
||||
public function analyze(Candidate $candidate)
|
||||
public function analyze(Candidate $candidate, ?string $provider = null)
|
||||
{
|
||||
if (!$candidate->job_position_id) {
|
||||
throw new \Exception("Le candidat n'est associé à aucune fiche de poste.");
|
||||
@@ -36,7 +36,7 @@ class AIAnalysisService
|
||||
throw new \Exception("Impossible d'extraire le texte du CV.");
|
||||
}
|
||||
|
||||
return $this->callAI($candidate, $cvText, $letterText);
|
||||
return $this->callAI($candidate, $cvText, $letterText, $provider);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,23 +50,40 @@ class AIAnalysisService
|
||||
|
||||
try {
|
||||
$pdf = $this->parser->parseFile(Storage::disk('local')->path($document->file_path));
|
||||
return $pdf->getText();
|
||||
$text = $pdf->getText();
|
||||
return $this->cleanText($text);
|
||||
} catch (\Exception $e) {
|
||||
Log::error("PDF Extraction Error: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean text to ensure it's valid UTF-8 and fits well in JSON.
|
||||
*/
|
||||
protected function cleanText(string $text): string
|
||||
{
|
||||
// Remove non-UTF8 characters
|
||||
$text = mb_convert_encoding($text, 'UTF-8', 'UTF-8');
|
||||
|
||||
// Remove control characters (except newlines and tabs)
|
||||
$text = preg_replace('/[^\x20-\x7E\xA0-\xFF\x0A\x0D\x09]/u', '', $text);
|
||||
|
||||
return trim($text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the AI API (using a placeholder for now, or direct Http call).
|
||||
*/
|
||||
protected function callAI(Candidate $candidate, string $cvText, ?string $letterText)
|
||||
protected function callAI(Candidate $candidate, string $cvText, ?string $letterText, ?string $provider = null)
|
||||
{
|
||||
$provider = $provider ?: env('AI_DEFAULT_PROVIDER', 'ollama');
|
||||
|
||||
$jobTitle = $candidate->jobPosition->title;
|
||||
$jobDesc = $candidate->jobPosition->description;
|
||||
$requirements = implode(", ", $candidate->jobPosition->requirements ?? []);
|
||||
|
||||
$prompt = "Tu es un expert en recrutement technique. Analyse le CV (et la lettre de motivation si présente) d'un candidat pour le poste de '{$jobTitle}'.
|
||||
$prompt = "Tu es un expert en recrutement technique. Analyse le CV (et la lettre de motivation si présente) d'un candidat pour le poste de '{$jobTitle}' attache une grande importance aux compétences techniques et à l'expérience du candidat, mais aussi à sa capacité à s'intégrer dans une équipe et à sa motivation.
|
||||
|
||||
DESCRIPTION DU POSTE:
|
||||
{$jobDesc}
|
||||
@@ -75,44 +92,144 @@ class AIAnalysisService
|
||||
{$requirements}
|
||||
|
||||
CONTENU DU CV:
|
||||
{$cvText}
|
||||
|
||||
{$cvText}
|
||||
CONTENU DE LA LETTRE DE MOTIVATION:
|
||||
" . ($letterText ?? "Non fournie") . "
|
||||
|
||||
CONTEXTE ADDITIONNEL & INSTRUCTIONS PARTICULIÈRES:
|
||||
" . ($candidate->jobPosition->ai_prompt ?? "Aucune instruction spécifique.") . "
|
||||
|
||||
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
|
||||
- summary: résumé de 3-4 phrases sur le profil et la ville d'origine du candidat
|
||||
- strengths: liste des points forts par rapport au poste
|
||||
- gaps: liste des compétences manquantes ou points de vigilance
|
||||
- verdict: une conclusion (Favorable, Très Favorable, Réservé, Défavorable)
|
||||
|
||||
Réponds UNIQUEMENT en JSON pur.";
|
||||
|
||||
// For now, I'll use a mocked response or try to use a generic endpoint if configured.
|
||||
// I'll check if the user has an Ollama endpoint.
|
||||
$analysis = match ($provider) {
|
||||
'openai' => $this->callOpenAI($prompt),
|
||||
'anthropic' => $this->callAnthropic($prompt),
|
||||
'gemini' => $this->callGemini($prompt),
|
||||
default => $this->callOllama($prompt),
|
||||
};
|
||||
|
||||
// Inject metadata for display and tracking
|
||||
$analysis['provider'] = $provider;
|
||||
$analysis['analyzed_at'] = now()->toIso8601String();
|
||||
|
||||
$ollamaUrl = config('services.ollama.url', 'http://localhost:11434/api/generate');
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
protected function callOllama(string $prompt)
|
||||
{
|
||||
$ollamaUrl = env('OLLAMA_URL', 'http://localhost:11434/api/generate');
|
||||
$ollamaModel = env('OLLAMA_MODEL', 'mistral');
|
||||
|
||||
try {
|
||||
$response = Http::timeout(60)->post($ollamaUrl, [
|
||||
'model' => 'mistral', // or llama3
|
||||
$response = Http::timeout(120)->post($ollamaUrl, [
|
||||
'model' => $ollamaModel,
|
||||
'prompt' => $prompt,
|
||||
'stream' => false,
|
||||
'format' => 'json'
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return json_decode($response->json('response'), true);
|
||||
return json_decode($response->json('response'), true);
|
||||
} else {
|
||||
Log::warning("AI Provider Error (Ollama): HTTP " . $response->status() . " - " . $response->body());
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("AI Analysis Call Failed: " . $e->getMessage());
|
||||
Log::error("AI Connection Failed (Ollama): " . $e->getMessage());
|
||||
}
|
||||
|
||||
// Fallback for demo if Ollama is not running
|
||||
return $this->getSimulatedAnalysis();
|
||||
}
|
||||
|
||||
protected function callOpenAI(string $prompt)
|
||||
{
|
||||
$apiKey = env('OPENAI_API_KEY');
|
||||
if (!$apiKey) return $this->getSimulatedAnalysis();
|
||||
|
||||
try {
|
||||
$response = Http::withToken($apiKey)->timeout(60)->post('https://api.openai.com/v1/chat/completions', [
|
||||
'model' => 'gpt-4o',
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
'response_format' => ['type' => 'json_object']
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return json_decode($response->json('choices.0.message.content'), true);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("OpenAI Analysis Failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->getSimulatedAnalysis();
|
||||
}
|
||||
|
||||
protected function callAnthropic(string $prompt)
|
||||
{
|
||||
$apiKey = env('ANTHROPIC_API_KEY');
|
||||
if (!$apiKey) return $this->getSimulatedAnalysis();
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'x-api-key' => $apiKey,
|
||||
'anthropic-version' => '2023-06-01',
|
||||
'content-type' => 'application/json'
|
||||
])->timeout(60)->post('https://api.anthropic.com/v1/messages', [
|
||||
'model' => 'claude-3-5-sonnet-20240620',
|
||||
'max_tokens' => 1024,
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]]
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$content = $response->json('content.0.text');
|
||||
return json_decode($this->extractJson($content), true);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Anthropic Analysis Failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->getSimulatedAnalysis();
|
||||
}
|
||||
|
||||
protected function callGemini(string $prompt)
|
||||
{
|
||||
$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]]]]
|
||||
]);
|
||||
|
||||
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());
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Gemini Connection Failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->getSimulatedAnalysis();
|
||||
}
|
||||
|
||||
private function extractJson($string)
|
||||
{
|
||||
preg_match('/\{.*\}/s', $string, $matches);
|
||||
return $matches[0] ?? '{}';
|
||||
}
|
||||
|
||||
private function getSimulatedAnalysis()
|
||||
{
|
||||
return [
|
||||
'match_score' => 75,
|
||||
'summary' => "Analyse simulée (IA non connectée). Le candidat semble avoir une solide expérience mais certains points techniques doivent être vérifiés.",
|
||||
'summary' => "Analyse simulée (IA non connectée ou erreur API). Le candidat semble avoir une solide expérience mais certains points techniques doivent être vérifiés.",
|
||||
'strengths' => ["Expérience pertinente", "Bonne présentation"],
|
||||
'gaps' => ["Compétences spécifiques à confirmer"],
|
||||
'verdict' => "Favorable"
|
||||
|
||||
47
app/Traits/BelongsToTenant.php
Normal file
47
app/Traits/BelongsToTenant.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
trait BelongsToTenant
|
||||
{
|
||||
protected static function bootBelongsToTenant()
|
||||
{
|
||||
static::addGlobalScope('tenant', function (Builder $builder) {
|
||||
if (Auth::check()) {
|
||||
$user = Auth::user();
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
if ($user->tenant_id) {
|
||||
$builder->where(function ($query) use ($user) {
|
||||
$query->where('tenant_id', $user->tenant_id)
|
||||
->orWhereNull('tenant_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (Auth::check() && Auth::user()->tenant_id && Auth::user()->role !== 'super_admin') {
|
||||
$model->tenant_id = Auth::user()->tenant_id;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function tenant()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Tenant::class);
|
||||
}
|
||||
}
|
||||
@@ -123,4 +123,6 @@ return [
|
||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||
],
|
||||
|
||||
'version' => trim(file_exists(base_path('VERSION')) ? file_get_contents(base_path('VERSION')) : '1.0.0'),
|
||||
|
||||
];
|
||||
|
||||
@@ -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('ai_analysis')->nullable()->after('interview_score');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('candidates', function (Blueprint $table) {
|
||||
$table->dropColumn('ai_analysis');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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('job_positions', function (Blueprint $table) {
|
||||
$table->text('ai_prompt')->nullable()->after('description');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('job_positions', function (Blueprint $table) {
|
||||
$table->dropColumn('ai_prompt');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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::create('tenants', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tenants');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
<?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('users', function (Blueprint $table) {
|
||||
$table->foreignId('tenant_id')->nullable()->constrained()->nullOnDelete();
|
||||
});
|
||||
|
||||
Schema::table('candidates', function (Blueprint $table) {
|
||||
$table->foreignId('tenant_id')->nullable()->constrained()->nullOnDelete();
|
||||
});
|
||||
|
||||
Schema::table('quizzes', function (Blueprint $table) {
|
||||
$table->foreignId('tenant_id')->nullable()->constrained()->nullOnDelete();
|
||||
});
|
||||
|
||||
Schema::table('job_positions', function (Blueprint $table) {
|
||||
$table->foreignId('tenant_id')->nullable()->constrained()->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropForeign(['tenant_id']);
|
||||
$table->dropColumn('tenant_id');
|
||||
});
|
||||
|
||||
Schema::table('candidates', function (Blueprint $table) {
|
||||
$table->dropForeign(['tenant_id']);
|
||||
$table->dropColumn('tenant_id');
|
||||
});
|
||||
|
||||
Schema::table('quizzes', function (Blueprint $table) {
|
||||
$table->dropForeign(['tenant_id']);
|
||||
$table->dropColumn('tenant_id');
|
||||
});
|
||||
|
||||
Schema::table('job_positions', function (Blueprint $table) {
|
||||
$table->dropForeign(['tenant_id']);
|
||||
$table->dropColumn('tenant_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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('users', function (Blueprint $table) {
|
||||
$table->string('role')->default('candidate')->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->enum('role', ['admin', 'candidate'])->default('candidate')->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
19
package-lock.json
generated
19
package-lock.json
generated
@@ -6,6 +6,7 @@
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"chart.js": "^4.5.1",
|
||||
"marked": "^17.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -195,6 +196,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
@@ -1266,6 +1273,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"chart.js": "^4.5.1",
|
||||
"marked": "^17.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 15 KiB |
@@ -12,7 +12,8 @@ defineProps({
|
||||
<template>
|
||||
<Link
|
||||
: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 />
|
||||
</Link>
|
||||
|
||||
49
resources/js/Components/EnvironmentBanner.vue
Normal file
49
resources/js/Components/EnvironmentBanner.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
|
||||
const page = usePage();
|
||||
const appEnv = computed(() => page.props.app_env);
|
||||
|
||||
const isDevelopment = computed(() => appEnv.value !== 'production');
|
||||
|
||||
const bannerStyles = computed(() => {
|
||||
if (appEnv.value === 'local') {
|
||||
return 'bg-amber-400 text-amber-950 border-amber-500/50';
|
||||
} else if (appEnv.value === 'staging') {
|
||||
return 'bg-blue-500 text-white border-blue-600/50';
|
||||
}
|
||||
return 'bg-rose-500 text-white border-rose-600/50';
|
||||
});
|
||||
|
||||
const label = computed(() => {
|
||||
if (appEnv.value === 'local') return 'DEV';
|
||||
if (appEnv.value === 'staging') return 'STG';
|
||||
return appEnv.value?.toUpperCase() || 'ENV';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isDevelopment"
|
||||
class="fixed top-0 left-0 right-0 z-[9999] h-1 pointer-events-none shadow-sm shadow-black/10"
|
||||
:class="bannerStyles"
|
||||
></div>
|
||||
|
||||
<div
|
||||
v-if="isDevelopment"
|
||||
class="fixed top-4 -right-12 z-[9999] pointer-events-none select-none"
|
||||
>
|
||||
<div
|
||||
class="transform rotate-45 py-1 px-14 text-[10px] font-black tracking-tighter shadow-lg border-y whitespace-nowrap text-center flex flex-col items-center justify-center leading-none"
|
||||
:class="bannerStyles"
|
||||
>
|
||||
<span>ENV {{ label }}</span>
|
||||
<span class="text-[7px] opacity-70 mt-0.5">{{ appEnv }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* No specific styles needed as Tailwind handles most of it, but ensure z-index is high */
|
||||
</style>
|
||||
@@ -14,8 +14,8 @@ const props = defineProps({
|
||||
|
||||
const classes = computed(() =>
|
||||
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-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-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-bold leading-5 text-slate-700 hover:text-indigo-600 hover:border-indigo-400 focus:outline-none transition duration-150 ease-in-out',
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ import { Link } from '@inertiajs/vue3';
|
||||
import ApplicationLogo from '@/Components/ApplicationLogo.vue';
|
||||
import Dropdown from '@/Components/Dropdown.vue';
|
||||
import DropdownLink from '@/Components/DropdownLink.vue';
|
||||
import EnvironmentBanner from '@/Components/EnvironmentBanner.vue';
|
||||
|
||||
const isSidebarOpen = ref(true);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EnvironmentBanner />
|
||||
<div class="min-h-screen bg-slate-50 dark:bg-slate-900 flex text-slate-900 dark:text-slate-100">
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
@@ -77,6 +79,28 @@ const isSidebarOpen = ref(true);
|
||||
</svg>
|
||||
<span v-if="isSidebarOpen">Comparateur</span>
|
||||
</Link>
|
||||
<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']"
|
||||
>
|
||||
<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 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>
|
||||
</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']"
|
||||
>
|
||||
<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="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>
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div class="p-4 border-t border-slate-200 dark:border-slate-700">
|
||||
@@ -91,6 +115,9 @@ const isSidebarOpen = ref(true);
|
||||
<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>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -115,6 +142,7 @@ const isSidebarOpen = ref(true);
|
||||
</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>
|
||||
</template>
|
||||
</Dropdown>
|
||||
|
||||
@@ -6,16 +6,16 @@ import DropdownLink from '@/Components/DropdownLink.vue';
|
||||
import NavLink from '@/Components/NavLink.vue';
|
||||
import ResponsiveNavLink from '@/Components/ResponsiveNavLink.vue';
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import EnvironmentBanner from '@/Components/EnvironmentBanner.vue';
|
||||
|
||||
const showingNavigationDropdown = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EnvironmentBanner />
|
||||
<div>
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<nav
|
||||
class="border-b border-gray-100 bg-white"
|
||||
>
|
||||
<div class="min-h-screen" style="background:#f8fafc;">
|
||||
<nav style="border-bottom:1px solid #e2e8f0; background:white; box-shadow:0 1px 3px rgba(0,0,0,0.04);">
|
||||
<!-- Primary Navigation Menu -->
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 justify-between">
|
||||
@@ -24,7 +24,7 @@ const showingNavigationDropdown = ref(false);
|
||||
<div class="flex shrink-0 items-center">
|
||||
<Link :href="route('dashboard')">
|
||||
<ApplicationLogo
|
||||
class="block h-9 w-auto fill-current text-gray-800"
|
||||
class="block h-9 w-auto fill-indigo-600"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -50,7 +50,7 @@ const showingNavigationDropdown = ref(false);
|
||||
<span class="inline-flex rounded-md">
|
||||
<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 }}
|
||||
|
||||
@@ -181,7 +181,7 @@ const showingNavigationDropdown = ref(false);
|
||||
|
||||
<!-- Page Heading -->
|
||||
<header
|
||||
class="bg-white shadow"
|
||||
style="background:white; border-bottom:1px solid #f1f5f9; box-shadow:none;"
|
||||
v-if="$slots.header"
|
||||
>
|
||||
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
@@ -193,6 +193,10 @@ const showingNavigationDropdown = ref(false);
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="pb-8 pt-4 text-center" style="background:#f8fafc;">
|
||||
<span class="text-[10px] font-mono" style="color:#9ca3af;">v{{ $page.props.app_version }}</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup>
|
||||
import ApplicationLogo from '@/Components/ApplicationLogo.vue';
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import EnvironmentBanner from '@/Components/EnvironmentBanner.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EnvironmentBanner />
|
||||
<div
|
||||
class="flex min-h-screen flex-col items-center bg-gray-100 pt-6 sm:justify-center sm:pt-0"
|
||||
>
|
||||
|
||||
@@ -13,7 +13,9 @@ import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
|
||||
const props = defineProps({
|
||||
candidates: Array
|
||||
candidates: Array,
|
||||
jobPositions: Array,
|
||||
tenants: Array
|
||||
});
|
||||
|
||||
const isModalOpen = ref(false);
|
||||
@@ -23,9 +25,11 @@ const form = useForm({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
linkedin_url: '',
|
||||
linkedin_url: '',
|
||||
cv: null,
|
||||
cover_letter: null,
|
||||
tenant_id: '',
|
||||
job_position_id: '',
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
@@ -39,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;
|
||||
};
|
||||
@@ -64,8 +72,29 @@ const getNestedValue = (obj, path) => {
|
||||
return path.split('.').reduce((o, i) => (o ? o[i] : null), obj);
|
||||
};
|
||||
|
||||
const selectedJobPosition = ref('');
|
||||
const showOnlySelected = ref(false);
|
||||
|
||||
const filteredCandidates = computed(() => {
|
||||
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(() => {
|
||||
return [...props.candidates].sort((a, b) => {
|
||||
return [...filteredCandidates.value].sort((a, b) => {
|
||||
let valA = getNestedValue(a, sortKey.value);
|
||||
let valB = getNestedValue(b, sortKey.value);
|
||||
|
||||
@@ -87,8 +116,31 @@ const sortedCandidates = computed(() => {
|
||||
Gestion des Candidats
|
||||
</template>
|
||||
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h3 class="text-2xl font-bold">Liste des Candidats</h3>
|
||||
<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
|
||||
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"
|
||||
>
|
||||
<option value="">Toutes les fiches de poste</option>
|
||||
<option value="none">➜ Non assigné (Candidature Spontanée)</option>
|
||||
<option v-for="jp in jobPositions" :key="jp.id" :value="jp.id">
|
||||
{{ jp.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PrimaryButton @click="isModalOpen = true">
|
||||
Ajouter un Candidat
|
||||
</PrimaryButton>
|
||||
@@ -112,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
|
||||
@@ -128,6 +181,22 @@ const sortedCandidates = computed(() => {
|
||||
</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
|
||||
@@ -144,12 +213,30 @@ const sortedCandidates = computed(() => {
|
||||
</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">
|
||||
<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>
|
||||
@@ -157,6 +244,12 @@ const sortedCandidates = computed(() => {
|
||||
<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"
|
||||
@@ -191,6 +284,22 @@ const sortedCandidates = computed(() => {
|
||||
</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 }}%
|
||||
</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
|
||||
@@ -221,7 +330,7 @@ const sortedCandidates = computed(() => {
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="candidates.length === 0">
|
||||
<td colspan="5" 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>
|
||||
@@ -261,6 +370,25 @@ const sortedCandidates = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div v-if="$page.props.auth.user.role === 'super_admin'">
|
||||
<InputLabel for="tenant_id" value="Structure de rattachement" />
|
||||
<select id="tenant_id" v-model="form.tenant_id" class="mt-1 block w-full rounded-md border-slate-300 dark:border-slate-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-slate-900">
|
||||
<option value="">Aucune</option>
|
||||
<option v-for="t in tenants" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||
</select>
|
||||
<InputError class="mt-2" :message="form.errors.tenant_id" />
|
||||
</div>
|
||||
<div :class="{ 'md:col-span-2': $page.props.auth.user.role !== 'super_admin' }">
|
||||
<InputLabel for="job_position_id" value="Rattacher à une Fiche de poste" />
|
||||
<select id="job_position_id" v-model="form.job_position_id" class="mt-1 block w-full rounded-md border-slate-300 dark:border-slate-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-slate-900">
|
||||
<option value="">Aucune (Candidature spontanée)</option>
|
||||
<option v-for="jp in jobPositions" :key="jp.id" :value="jp.id">{{ jp.title }}</option>
|
||||
</select>
|
||||
<InputError class="mt-2" :message="form.errors.job_position_id" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<InputLabel value="CV (PDF)" />
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
<script setup>
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue';
|
||||
import axios from 'axios';
|
||||
import { Head, Link, router, useForm, usePage } from '@inertiajs/vue3';
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import Modal from '@/Components/Modal.vue';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import DangerButton from '@/Components/DangerButton.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import Chart from 'chart.js/auto';
|
||||
|
||||
const props = defineProps({
|
||||
candidate: Object,
|
||||
jobPositions: Array
|
||||
jobPositions: Array,
|
||||
tenants: Array,
|
||||
ai_config: Object
|
||||
});
|
||||
|
||||
const page = usePage();
|
||||
@@ -27,6 +31,20 @@ const updatePosition = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const tenantForm = useForm({
|
||||
tenant_id: props.candidate.tenant_id || ''
|
||||
});
|
||||
|
||||
const updateTenant = () => {
|
||||
tenantForm.patch(route('admin.candidates.update-tenant', props.candidate.id), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelection = () => {
|
||||
router.patch(route('admin.candidates.toggle-selection', props.candidate.id), {}, { preserveScroll: true });
|
||||
};
|
||||
|
||||
const selectedDocument = ref(null);
|
||||
|
||||
const docForm = useForm({
|
||||
@@ -120,26 +138,144 @@ const updateAnswerScore = (answerId, score) => {
|
||||
});
|
||||
};
|
||||
|
||||
const aiAnalysis = ref(null);
|
||||
// ─── Radar Chart ──────────────────────────────────────────────────────────────
|
||||
const radarCanvasRef = ref(null);
|
||||
let radarChartInstance = null;
|
||||
|
||||
// Calcul du score test (meilleure tentative ramenée sur 20)
|
||||
const bestTestScore = computed(() => {
|
||||
if (!props.candidate.attempts || props.candidate.attempts.length === 0) return 0;
|
||||
const finished = props.candidate.attempts.filter(a => a.finished_at && a.max_score > 0);
|
||||
if (finished.length === 0) return 0;
|
||||
return Math.max(...finished.map(a => (a.score / a.max_score) * 20));
|
||||
});
|
||||
|
||||
// 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),
|
||||
]));
|
||||
|
||||
const buildRadarChart = () => {
|
||||
if (!radarCanvasRef.value) return;
|
||||
if (radarChartInstance) {
|
||||
radarChartInstance.destroy();
|
||||
radarChartInstance = null;
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
radarChartInstance = new Chart(radarCanvasRef.value, {
|
||||
type: 'radar',
|
||||
data: {
|
||||
labels: ['Analyse CV', 'Lettre Motiv.', 'Entretien', 'Test Technique'],
|
||||
datasets: [{
|
||||
label: 'Profil Candidat (%)',
|
||||
data: radarData.value,
|
||||
backgroundColor: 'rgba(99,102,241,0.15)',
|
||||
borderColor: 'rgba(99,102,241,0.9)',
|
||||
borderWidth: 2.5,
|
||||
pointBackgroundColor: 'rgba(99,102,241,1)',
|
||||
pointBorderColor: '#fff',
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
pointHoverBackgroundColor: 'rgba(139,92,246,1)',
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
animation: { duration: 700, easing: 'easeInOutQuart' },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => ` ${ctx.raw}%`,
|
||||
},
|
||||
backgroundColor: isDark ? '#1e293b' : '#fff',
|
||||
titleColor: isDark ? '#e2e8f0' : '#0f172a',
|
||||
bodyColor: isDark ? '#94a3b8' : '#475569',
|
||||
borderColor: isDark ? '#334155' : '#e2e8f0',
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
cornerRadius: 10,
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
r: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: {
|
||||
stepSize: 25,
|
||||
color: labelColor,
|
||||
backdropColor: 'transparent',
|
||||
font: { size: 9, weight: 'bold' },
|
||||
callback: (v) => v + '%',
|
||||
},
|
||||
grid: { color: gridColor },
|
||||
angleLines: { color: gridColor },
|
||||
pointLabels: {
|
||||
color: labelColor,
|
||||
font: { size: 11, weight: 'bold' },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => buildRadarChart());
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (radarChartInstance) radarChartInstance.destroy();
|
||||
});
|
||||
|
||||
// Mise à jour du radar quand les scores changent
|
||||
watch(
|
||||
() => [scoreForm.cv_score, scoreForm.motivation_score, scoreForm.interview_score, bestTestScore.value],
|
||||
() => {
|
||||
if (radarChartInstance) {
|
||||
radarChartInstance.data.datasets[0].data = radarData.value;
|
||||
radarChartInstance.update();
|
||||
}
|
||||
}
|
||||
);
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
const modalErrorMessage = ref("");
|
||||
|
||||
const runAI = async () => {
|
||||
if (!props.candidate.job_position_id) {
|
||||
alert("Veuillez d'abord associer une fiche de poste à ce candidat.");
|
||||
modalErrorMessage.value = "Veuillez d'abord associer une fiche de poste à ce candidat.";
|
||||
showErrorModal.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
isAnalyzing.value = true;
|
||||
try {
|
||||
const response = await fetch(route('admin.candidates.analyze', props.candidate.id));
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
} else {
|
||||
aiAnalysis.value = data;
|
||||
}
|
||||
const response = await axios.post(route('admin.candidates.analyze', props.candidate.id), {
|
||||
provider: selectedProvider.value,
|
||||
force: forceAnalysis.value
|
||||
});
|
||||
aiAnalysis.value = response.data;
|
||||
} catch (error) {
|
||||
alert("Une erreur est survenue lors de l'analyse.");
|
||||
console.error('AI Analysis Error:', error);
|
||||
modalErrorMessage.value = error.response?.data?.error || "Une erreur est survenue lors de l'analyse.";
|
||||
showErrorModal.value = true;
|
||||
} finally {
|
||||
isAnalyzing.value = false;
|
||||
}
|
||||
@@ -180,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>
|
||||
@@ -200,6 +356,22 @@ const runAI = async () => {
|
||||
</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">
|
||||
@@ -361,6 +533,230 @@ const runAI = async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Radar Chart Section -->
|
||||
<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-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z" />
|
||||
</svg>
|
||||
Profil de Compétences
|
||||
</h4>
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<span class="text-[10px] font-black uppercase tracking-widest text-slate-400">Score global</span>
|
||||
<span class="text-2xl font-black text-indigo-600">{{ candidate.weighted_score }}<span class="text-sm text-slate-300 font-normal ml-1">/20</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 items-center">
|
||||
<!-- Radar Canvas -->
|
||||
<div class="relative flex items-center justify-center">
|
||||
<canvas ref="radarCanvasRef" class="max-h-72"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Score Breakdown Legend -->
|
||||
<div class="space-y-4">
|
||||
<!-- CV -->
|
||||
<div class="group">
|
||||
<div class="flex justify-between items-center mb-1.5">
|
||||
<span class="text-xs font-black uppercase tracking-widest text-slate-500">Analyse CV</span>
|
||||
<span class="text-sm font-black text-indigo-600">{{ scoreForm.cv_score }} <span class="text-slate-300 font-normal">/ 20</span></span>
|
||||
</div>
|
||||
<div class="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-gradient-to-r from-indigo-400 to-indigo-600 rounded-full transition-all duration-700"
|
||||
:style="{ width: (scoreForm.cv_score / 20 * 100) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Motivation -->
|
||||
<div class="group">
|
||||
<div class="flex justify-between items-center mb-1.5">
|
||||
<span class="text-xs font-black uppercase tracking-widest text-slate-500">Lettre de Motivation</span>
|
||||
<span class="text-sm font-black text-emerald-600">{{ scoreForm.motivation_score }} <span class="text-slate-300 font-normal">/ 10</span></span>
|
||||
</div>
|
||||
<div class="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-gradient-to-r from-emerald-400 to-emerald-600 rounded-full transition-all duration-700"
|
||||
:style="{ width: (scoreForm.motivation_score / 10 * 100) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entretien -->
|
||||
<div class="group">
|
||||
<div class="flex justify-between items-center mb-1.5">
|
||||
<span class="text-xs font-black uppercase tracking-widest text-slate-500">Entretien</span>
|
||||
<span class="text-sm font-black text-purple-600">{{ scoreForm.interview_score }} <span class="text-slate-300 font-normal">/ 30</span></span>
|
||||
</div>
|
||||
<div class="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-gradient-to-r from-purple-400 to-purple-600 rounded-full transition-all duration-700"
|
||||
:style="{ width: (scoreForm.interview_score / 30 * 100) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Technique -->
|
||||
<div class="group">
|
||||
<div class="flex justify-between items-center mb-1.5">
|
||||
<span class="text-xs font-black uppercase tracking-widest text-slate-500">Test Technique</span>
|
||||
<span class="text-sm font-black text-amber-600">{{ bestTestScore.toFixed(2) }} <span class="text-slate-300 font-normal">/ 20</span></span>
|
||||
</div>
|
||||
<div class="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-gradient-to-r from-amber-400 to-amber-600 rounded-full transition-all duration-700"
|
||||
:style="{ width: (bestTestScore / 20 * 100) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score footer note -->
|
||||
<p class="text-[10px] text-slate-400 italic pt-2 border-t border-slate-100 dark:border-slate-700">
|
||||
Chaque axe est normalisé sur 100% par rapport à son barème maximum.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Analysis Section -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8 overflow-hidden relative">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
|
||||
<div>
|
||||
<h3 class="font-bold text-lg mb-4 flex items-center gap-3 flex-wrap">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9l-.707.707M12 21v-1m4.243-4.243l-.707-.707m2.828-9.9l-.707.707" />
|
||||
</svg>
|
||||
Analyse IA complète
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="aiAnalysis?.provider" class="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-500 uppercase font-bold border border-slate-200">
|
||||
{{ aiAnalysis.provider }}
|
||||
</span>
|
||||
<span v-if="aiAnalysis?.analyzed_at" class="text-[10px] text-slate-400 italic font-normal">
|
||||
Effectuée le {{ new Date(aiAnalysis.analyzed_at).toLocaleDateString('fr-FR') }}
|
||||
</span>
|
||||
</div>
|
||||
</h3>
|
||||
<p class="text-xs text-slate-400 mt-1 uppercase font-bold tracking-widest">Choisir l'IA pour l'analyse du matching</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<!-- Provider Selector -->
|
||||
<div v-if="props.ai_config?.enabled_providers" class="flex items-center bg-slate-100 dark:bg-slate-900/50 p-1.5 rounded-2xl border border-slate-200 dark:border-slate-800">
|
||||
<button
|
||||
v-for="provider in Object.keys(props.ai_config.enabled_providers)"
|
||||
:key="provider"
|
||||
@click="selectedProvider = provider"
|
||||
class="px-4 py-2 text-[10px] font-black uppercase tracking-widest rounded-xl transition-all"
|
||||
:class="selectedProvider === provider ? 'bg-white dark:bg-slate-800 shadow-sm text-indigo-600' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'"
|
||||
>
|
||||
{{ provider }}
|
||||
</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"
|
||||
class="!bg-indigo-600 hover:!bg-indigo-500 !border-none !rounded-xl group"
|
||||
>
|
||||
<span v-if="isAnalyzing" class="flex items-center gap-2">
|
||||
<svg class="animate-spin h-4 w-4 text-white" 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>
|
||||
Analyse en cours...
|
||||
</span>
|
||||
<span v-else class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 group-hover:scale-110 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Lancer l'analyse intelligente
|
||||
</span>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Results -->
|
||||
<div v-if="aiAnalysis" class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="bg-slate-50 dark:bg-slate-900/50 p-6 rounded-3xl border border-slate-100 dark:border-slate-800 text-center flex flex-col items-center justify-center">
|
||||
<div class="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-4">Score d'Adéquation</div>
|
||||
<div class="text-5xl font-black text-indigo-600 mb-2">{{ aiAnalysis.match_score }}%</div>
|
||||
<div
|
||||
class="px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest"
|
||||
:class="[
|
||||
aiAnalysis.match_score >= 80 ? 'bg-emerald-100 text-emerald-700' :
|
||||
aiAnalysis.match_score >= 60 ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
]"
|
||||
>
|
||||
{{ aiAnalysis.verdict }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:col-span-2 bg-slate-50 dark:bg-slate-900/50 p-6 rounded-3xl border border-slate-100 dark:border-slate-800">
|
||||
<div class="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-4">Synthèse du Profil</div>
|
||||
<p class="text-sm leading-relaxed text-slate-600 dark:text-slate-400 italic">
|
||||
" {{ aiAnalysis.summary }} "
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div class="space-y-4">
|
||||
<h5 class="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-emerald-500">
|
||||
<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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Points Forts Identifiés
|
||||
</h5>
|
||||
<ul class="space-y-3">
|
||||
<li v-for="(strength, idx) in aiAnalysis.strengths" :key="idx" class="flex items-start gap-3 p-3 bg-emerald-50/50 dark:bg-emerald-900/10 rounded-2xl border border-emerald-100 dark:border-emerald-800/50 text-[13px]">
|
||||
<span class="text-emerald-500 font-bold mt-0.5">•</span>
|
||||
<span class="text-emerald-800 dark:text-emerald-400">{{ strength }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<h5 class="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-amber-500">
|
||||
<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="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>
|
||||
Points de Vigilance / Gaps
|
||||
</h5>
|
||||
<ul class="space-y-3">
|
||||
<li v-for="(gap, idx) in aiAnalysis.gaps" :key="idx" class="flex items-start gap-3 p-3 bg-amber-50/50 dark:bg-amber-900/10 rounded-2xl border border-amber-100 dark:border-amber-800/50 text-[13px]">
|
||||
<span class="text-amber-500 font-bold mt-0.5">•</span>
|
||||
<span class="text-amber-800 dark:text-amber-400">{{ gap }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!isAnalyzing" class="py-12 border-2 border-dashed border-slate-100 dark:border-slate-800 rounded-3xl text-center">
|
||||
<p class="text-slate-400 text-sm font-medium">Aucune analyse effectuée pour le moment.</p>
|
||||
</div>
|
||||
<div v-if="isAnalyzing" class="absolute inset-0 bg-white/60 dark:bg-slate-800/60 backdrop-blur-[2px] z-10 flex flex-col items-center justify-center gap-4">
|
||||
<div class="flex gap-1 animate-pulse"><div class="w-2 h-2 bg-indigo-500 rounded-full"></div><div class="w-2 h-2 bg-indigo-500 rounded-full"></div><div class="w-2 h-2 bg-indigo-500 rounded-full"></div></div>
|
||||
<p class="text-sm font-black text-indigo-600 uppercase tracking-widest">Analyse en cours...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes Section -->
|
||||
<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">
|
||||
@@ -440,106 +836,6 @@ const runAI = async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Analysis Section -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8 overflow-hidden relative">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h4 class="text-xl font-bold flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Analyse d'Adéquation IA
|
||||
</h4>
|
||||
<p class="text-xs text-slate-400 mt-1 uppercase font-bold tracking-widest">Matching CV/Lettre vs Fiche de poste</p>
|
||||
</div>
|
||||
|
||||
<PrimaryButton
|
||||
@click="runAI"
|
||||
:disabled="isAnalyzing"
|
||||
class="!bg-indigo-600 hover:!bg-indigo-500 !border-none !rounded-xl group"
|
||||
>
|
||||
<span v-if="isAnalyzing" class="flex items-center gap-2">
|
||||
<svg class="animate-spin h-4 w-4 text-white" 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>
|
||||
Analyse en cours...
|
||||
</span>
|
||||
<span v-else class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 group-hover:scale-110 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Lancer l'analyse intelligente
|
||||
</span>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
<!-- AI Results -->
|
||||
<div v-if="aiAnalysis" class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="bg-slate-50 dark:bg-slate-900/50 p-6 rounded-3xl border border-slate-100 dark:border-slate-800 text-center flex flex-col items-center justify-center">
|
||||
<div class="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-4">Score d'Adéquation</div>
|
||||
<div class="text-5xl font-black text-indigo-600 mb-2">{{ aiAnalysis.match_score }}%</div>
|
||||
<div
|
||||
class="px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest"
|
||||
:class="[
|
||||
aiAnalysis.match_score >= 80 ? 'bg-emerald-100 text-emerald-700' :
|
||||
aiAnalysis.match_score >= 60 ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
]"
|
||||
>
|
||||
{{ aiAnalysis.verdict }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:col-span-2 bg-slate-50 dark:bg-slate-900/50 p-6 rounded-3xl border border-slate-100 dark:border-slate-800">
|
||||
<div class="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-4">Synthèse du Profil</div>
|
||||
<p class="text-sm leading-relaxed text-slate-600 dark:text-slate-400 italic">
|
||||
" {{ aiAnalysis.summary }} "
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div class="space-y-4">
|
||||
<h5 class="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-emerald-500">
|
||||
<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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Points Forts Identifiés
|
||||
</h5>
|
||||
<ul class="space-y-3">
|
||||
<li v-for="(strength, idx) in aiAnalysis.strengths" :key="idx" class="flex items-start gap-3 p-3 bg-emerald-50/50 dark:bg-emerald-900/10 rounded-2xl border border-emerald-100 dark:border-emerald-800/50 text-[13px]">
|
||||
<span class="text-emerald-500 font-bold mt-0.5">•</span>
|
||||
<span class="text-emerald-800 dark:text-emerald-400">{{ strength }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<h5 class="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-amber-500">
|
||||
<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="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>
|
||||
Points de Vigilance / Gaps
|
||||
</h5>
|
||||
<ul class="space-y-3">
|
||||
<li v-for="(gap, idx) in aiAnalysis.gaps" :key="idx" class="flex items-start gap-3 p-3 bg-amber-50/50 dark:bg-amber-900/10 rounded-2xl border border-amber-100 dark:border-amber-800/50 text-[13px]">
|
||||
<span class="text-amber-500 font-bold mt-0.5">•</span>
|
||||
<span class="text-amber-800 dark:text-amber-400">{{ gap }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!isAnalyzing" class="py-12 border-2 border-dashed border-slate-100 dark:border-slate-800 rounded-3xl text-center">
|
||||
<p class="text-slate-400 text-sm font-medium">Aucune analyse effectuée pour le moment.</p>
|
||||
</div>
|
||||
<div v-if="isAnalyzing" class="absolute inset-0 bg-white/60 dark:bg-slate-800/60 backdrop-blur-[2px] z-10 flex flex-col items-center justify-center gap-4">
|
||||
<div class="flex gap-1 animate-pulse"><div class="w-2 h-2 bg-indigo-500 rounded-full"></div><div class="w-2 h-2 bg-indigo-500 rounded-full"></div><div class="w-2 h-2 bg-indigo-500 rounded-full"></div></div>
|
||||
<p class="text-sm font-black text-indigo-600 uppercase tracking-widest">Analyse en cours...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
Historique des Tests
|
||||
@@ -561,7 +857,7 @@ const runAI = async () => {
|
||||
</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">
|
||||
Fini le {{ formatDateTime(attempt.finished_at) }}
|
||||
</div>
|
||||
@@ -672,4 +968,28 @@ const runAI = async () => {
|
||||
</div>
|
||||
</Modal>
|
||||
</AdminLayout>
|
||||
|
||||
<!-- Error Modal -->
|
||||
<Modal :show="showErrorModal" @close="showErrorModal = false" maxWidth="md">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-4 mb-4 text-red-600">
|
||||
<div class="flex-shrink-0 w-12 h-12 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center">
|
||||
<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="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>
|
||||
<h2 class="text-xl font-bold">Attention</h2>
|
||||
</div>
|
||||
|
||||
<p class="text-slate-600 dark:text-slate-400 mb-6">
|
||||
{{ modalErrorMessage }}
|
||||
</p>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<PrimaryButton @click="showErrorModal = false" class="!bg-red-600 hover:!bg-red-500 !border-none">
|
||||
Fermer
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -9,7 +9,9 @@ import DangerButton from '@/Components/DangerButton.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
|
||||
const props = defineProps({
|
||||
jobPositions: Array
|
||||
jobPositions: Array,
|
||||
tenants: Array,
|
||||
quizzes: Array
|
||||
});
|
||||
|
||||
const showingModal = ref(false);
|
||||
@@ -18,7 +20,10 @@ const editingPosition = ref(null);
|
||||
const form = useForm({
|
||||
title: '',
|
||||
description: '',
|
||||
requirements: []
|
||||
requirements: [],
|
||||
ai_prompt: '',
|
||||
tenant_id: '',
|
||||
quiz_ids: [],
|
||||
});
|
||||
|
||||
const openModal = (position = null) => {
|
||||
@@ -27,6 +32,9 @@ const openModal = (position = null) => {
|
||||
form.title = position.title;
|
||||
form.description = position.description;
|
||||
form.requirements = position.requirements || [];
|
||||
form.ai_prompt = position.ai_prompt || '';
|
||||
form.tenant_id = position.tenant_id || '';
|
||||
form.quiz_ids = position.quizzes ? position.quizzes.map(q => q.id) : [];
|
||||
} else {
|
||||
form.reset();
|
||||
}
|
||||
@@ -70,11 +78,14 @@ const removeRequirement = (index) => {
|
||||
|
||||
<AdminLayout>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex justify-between items-center gap-8">
|
||||
<h2 class="text-xl font-semibold leading-tight capitalize">
|
||||
Fiches de Poste (Analyse IA)
|
||||
</h2>
|
||||
Fiches de Poste
|
||||
</h2>
|
||||
<PrimaryButton @click="openModal()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Nouvelle Fiche
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
@@ -88,7 +99,12 @@ const removeRequirement = (index) => {
|
||||
class="bg-white dark:bg-slate-800 rounded-3xl p-8 shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-2xl transition-all duration-300 group flex flex-col h-full"
|
||||
>
|
||||
<div class="mb-6 flex-1">
|
||||
<div class="text-[10px] font-black uppercase tracking-widest text-indigo-500 mb-2">Poste / Compétences</div>
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div class="text-[10px] font-black uppercase tracking-widest text-indigo-500">Poste / Compétences</div>
|
||||
<div v-if="$page.props.auth.user.role === 'super_admin'" class="text-[9px] font-black uppercase tracking-widest px-2 py-0.5 rounded bg-indigo-50 text-indigo-600 dark:bg-indigo-900/30">
|
||||
{{ position.tenant ? position.tenant.name : 'Global' }}
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-2xl font-black mb-3 group-hover:text-indigo-600 transition-colors">{{ position.title }}</h3>
|
||||
<p class="text-slate-500 dark:text-slate-400 text-sm line-clamp-3 leading-relaxed">
|
||||
{{ position.description }}
|
||||
@@ -141,6 +157,19 @@ const removeRequirement = (index) => {
|
||||
<h3 class="text-2xl font-black mb-8">{{ editingPosition ? 'Modifier' : 'Créer' }} la Fiche de Poste</h3>
|
||||
|
||||
<form @submit.prevent="submit" class="space-y-6">
|
||||
<div v-if="$page.props.auth.user.role === 'super_admin'" class="mb-4">
|
||||
<label class="block text-xs font-black uppercase tracking-widest text-slate-400 mb-2">Structure de rattachement</label>
|
||||
<select
|
||||
v-model="form.tenant_id"
|
||||
class="w-full bg-slate-50 dark:bg-slate-900 border-none rounded-2xl p-4 focus:ring-2 focus:ring-indigo-500/20 transition-all font-bold"
|
||||
required
|
||||
>
|
||||
<option value="">Sélectionnez une structure</option>
|
||||
<option v-for="t in tenants" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.tenant_id" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-black uppercase tracking-widest text-slate-400 mb-2">Titre du Poste</label>
|
||||
<input
|
||||
@@ -165,6 +194,44 @@ const removeRequirement = (index) => {
|
||||
<InputError :message="form.errors.description" />
|
||||
</div>
|
||||
|
||||
<div class="bg-indigo-50/50 dark:bg-indigo-900/10 p-6 rounded-3xl border border-indigo-100 dark:border-indigo-800/50">
|
||||
<label class="block text-xs font-black uppercase tracking-widest text-indigo-600 dark:text-indigo-400 mb-2">IA Context & Prompt Personnalisé</label>
|
||||
<p class="text-[10px] text-indigo-400 mb-4 font-bold uppercase tracking-tight">Utilisez cette zone pour donner des instructions spécifiques à l'IA (priorités, contexte entreprise, ton de l'analyse...)</p>
|
||||
<textarea
|
||||
v-model="form.ai_prompt"
|
||||
rows="5"
|
||||
class="w-full bg-white dark:bg-slate-900 border-none rounded-2xl p-4 focus:ring-2 focus:ring-indigo-500/20 transition-all text-sm leading-relaxed"
|
||||
placeholder="Ex: Sois particulièrement attentif à l'expérience sur des projets SaaS à forte charge. Favorise les candidats ayant travaillé en environnement Agile."
|
||||
></textarea>
|
||||
<InputError :message="form.errors.ai_prompt" />
|
||||
</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 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>
|
||||
|
||||
105
resources/js/Pages/Admin/Tenants/Index.vue
Normal file
105
resources/js/Pages/Admin/Tenants/Index.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script setup>
|
||||
import { Head, useForm, Link } from '@inertiajs/vue3';
|
||||
import { ref } from 'vue';
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue';
|
||||
|
||||
const props = defineProps({
|
||||
tenants: Array
|
||||
});
|
||||
|
||||
const isCreating = ref(false);
|
||||
const editingTenant = ref(null);
|
||||
|
||||
const form = useForm({
|
||||
name: ''
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
if (editingTenant.value) {
|
||||
form.put(route('admin.tenants.update', editingTenant.value.id), {
|
||||
onSuccess: () => {
|
||||
editingTenant.value = null;
|
||||
form.reset();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
form.post(route('admin.tenants.store'), {
|
||||
onSuccess: () => {
|
||||
isCreating.value = false;
|
||||
form.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const editTenant = (tenant) => {
|
||||
editingTenant.value = tenant;
|
||||
form.name = tenant.name;
|
||||
isCreating.value = true;
|
||||
};
|
||||
|
||||
const deleteTenant = (tenant) => {
|
||||
if (confirm('Êtes-vous sûr de vouloir supprimer cette structure ?')) {
|
||||
form.delete(route('admin.tenants.destroy', tenant.id));
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
isCreating.value = false;
|
||||
editingTenant.value = null;
|
||||
form.reset();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Gestion des Structures" />
|
||||
<AdminLayout>
|
||||
<template #header>Gestion des Structures (SaaS)</template>
|
||||
|
||||
<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-indigo-500 to-purple-500">
|
||||
Structures / Tenants
|
||||
</h1>
|
||||
<button v-if="!isCreating" @click="isCreating = true" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700">
|
||||
Ajouter une Structure
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isCreating" class="mb-8 p-6 bg-white dark:bg-slate-800 rounded-xl shadow border border-slate-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-bold mb-4">{{ editingTenant ? 'Modifier la structure' : 'Nouvelle structure' }}</h2>
|
||||
<form @submit.prevent="submit" class="flex items-center gap-4">
|
||||
<input v-model="form.name" type="text" placeholder="Nom du service ou client" class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" required />
|
||||
<button type="submit" class="px-4 py-2 bg-indigo-600 focus:bg-indigo-700 text-white rounded-lg whitespace-nowrap" :disabled="form.processing">
|
||||
{{ editingTenant ? 'Mettre à jour' : 'Créer' }}
|
||||
</button>
|
||||
<button type="button" @click="cancel" class="px-4 py-2 bg-slate-200 text-slate-800 rounded-lg whitespace-nowrap">Annuler</button>
|
||||
</form>
|
||||
<div v-if="form.errors.name" class="mt-2 text-sm text-red-600">{{ form.errors.name }}</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow overflow-hidden border border-slate-200 dark:border-slate-700">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
|
||||
<th class="py-4 px-6 font-semibold text-slate-600 dark:text-slate-300">ID</th>
|
||||
<th class="py-4 px-6 font-semibold text-slate-600 dark:text-slate-300">Nom de la structure</th>
|
||||
<th class="py-4 px-6 font-semibold text-slate-600 dark:text-slate-300 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="tenant in tenants" :key="tenant.id" class="border-b border-slate-100 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||
<td class="py-3 px-6 text-slate-500">{{ tenant.id }}</td>
|
||||
<td class="py-3 px-6 font-medium">{{ tenant.name }}</td>
|
||||
<td class="py-3 px-6 text-right space-x-2">
|
||||
<button @click="editTenant(tenant)" class="text-indigo-600 hover:text-indigo-900 px-3 py-1 rounded bg-indigo-50 hover:bg-indigo-100 transition-colors">Modifier</button>
|
||||
<button @click="deleteTenant(tenant)" class="text-red-600 hover:text-red-900 px-3 py-1 rounded bg-red-50 hover:bg-red-100 transition-colors">Supprimer</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="tenants.length === 0">
|
||||
<td colspan="3" class="py-8 text-center text-slate-500">Aucune structure. Ajoutez-en une pour commencer.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
175
resources/js/Pages/Admin/Users/Index.vue
Normal file
175
resources/js/Pages/Admin/Users/Index.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<script setup>
|
||||
import { Head, useForm, Link, usePage } from '@inertiajs/vue3';
|
||||
import { ref, computed } from 'vue';
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue';
|
||||
|
||||
const page = usePage();
|
||||
const flashSuccess = computed(() => page.props.flash?.success);
|
||||
|
||||
const props = defineProps({
|
||||
users: Array,
|
||||
tenants: Array
|
||||
});
|
||||
|
||||
const isCreating = ref(false);
|
||||
const editingUser = ref(null);
|
||||
|
||||
const form = useForm({
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'admin',
|
||||
tenant_id: ''
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
if (editingUser.value) {
|
||||
form.put(route('admin.users.update', editingUser.value.id), {
|
||||
onSuccess: () => {
|
||||
editingUser.value = null;
|
||||
form.reset();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
form.post(route('admin.users.store'), {
|
||||
onSuccess: () => {
|
||||
isCreating.value = false;
|
||||
form.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const editUser = (user) => {
|
||||
editingUser.value = user;
|
||||
form.name = user.name;
|
||||
form.email = user.email;
|
||||
form.role = user.role;
|
||||
form.tenant_id = user.tenant_id || '';
|
||||
isCreating.value = true;
|
||||
};
|
||||
|
||||
const deleteUser = (user) => {
|
||||
if (confirm('Êtes-vous sûr de vouloir supprimer cet administrateur ?')) {
|
||||
form.delete(route('admin.users.destroy', user.id));
|
||||
}
|
||||
};
|
||||
|
||||
const resetPassword = (user) => {
|
||||
if (confirm(`Êtes-vous sûr de vouloir réinitialiser le mot de passe de ${user.name} ? Un nouveau mot de passe sera généré aléatoirement.`)) {
|
||||
form.post(route('admin.users.reset-password', user.id));
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
isCreating.value = false;
|
||||
editingUser.value = null;
|
||||
form.reset();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Équipe / Utilisateurs Admin" />
|
||||
<AdminLayout>
|
||||
<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">
|
||||
<h1 class="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-500 to-indigo-500">
|
||||
Administrateurs Plateforme
|
||||
</h1>
|
||||
<button v-if="!isCreating" @click="isCreating = true" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
|
||||
Ajouter un Utilisateur
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isCreating" class="mb-8 p-6 bg-white dark:bg-slate-800 rounded-xl shadow border border-slate-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-bold mb-4">{{ editingUser ? 'Modifier l\'utilisateur' : 'Nouvel utilisateur admin' }}</h2>
|
||||
<form @submit.prevent="submit" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">Nom Complet</label>
|
||||
<input v-model="form.name" type="text" class="mt-1 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" required />
|
||||
<div v-if="form.errors.name" class="mt-1 text-sm text-red-600">{{ form.errors.name }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">Adresse Email</label>
|
||||
<input v-model="form.email" type="email" class="mt-1 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" required />
|
||||
<div v-if="form.errors.email" class="mt-1 text-sm text-red-600">{{ form.errors.email }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">Rôle</label>
|
||||
<select v-model="form.role" class="mt-1 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6">
|
||||
<option value="admin">Administrateur Standard (SaaS)</option>
|
||||
<option value="super_admin">Super Administrateur (Global)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="form.role === 'admin'">
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">Structure / Service</label>
|
||||
<select v-model="form.tenant_id" class="mt-1 block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" required>
|
||||
<option disabled value="">Sélectionnez une structure</option>
|
||||
<option v-for="tenant in tenants" :key="tenant.id" :value="tenant.id">{{ tenant.name }}</option>
|
||||
</select>
|
||||
<div v-if="form.errors.tenant_id" class="mt-1 text-sm text-red-600">{{ form.errors.tenant_id }}</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 flex justify-end gap-3 mt-4">
|
||||
<button type="button" @click="cancel" class="px-4 py-2 border border-slate-300 text-slate-700 dark:text-slate-300 rounded-lg whitespace-nowrap">Annuler</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 focus:bg-blue-700 text-white rounded-lg whitespace-nowrap" :disabled="form.processing">
|
||||
{{ editingUser ? 'Mettre à jour compte' : 'Créer l\'accès' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow overflow-hidden border border-slate-200 dark:border-slate-700">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
|
||||
<th class="py-4 px-6 font-semibold text-slate-600 dark:text-slate-300">Nom</th>
|
||||
<th class="py-4 px-6 font-semibold text-slate-600 dark:text-slate-300">Email</th>
|
||||
<th class="py-4 px-6 font-semibold text-slate-600 dark:text-slate-300">Rôle</th>
|
||||
<th class="py-4 px-6 font-semibold text-slate-600 dark:text-slate-300">Rattachement</th>
|
||||
<th class="py-4 px-6 font-semibold text-slate-600 dark:text-slate-300 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id" class="border-b border-slate-100 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||
<td class="py-3 px-6 font-medium">{{ user.name }}</td>
|
||||
<td class="py-3 px-6 text-slate-500">{{ user.email }}</td>
|
||||
<td class="py-3 px-6">
|
||||
<span v-if="user.role === 'super_admin'" class="inline-flex items-center rounded-md bg-purple-50 px-2 py-1 text-xs font-medium text-purple-700 ring-1 ring-inset ring-purple-700/10">Super Admin</span>
|
||||
<span v-else class="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">Admin Site</span>
|
||||
</td>
|
||||
<td class="py-3 px-6 text-slate-500">
|
||||
{{ user.tenant ? user.tenant.name : (user.role === 'super_admin' ? 'Toutes les structures' : 'Aucun rattachement') }}
|
||||
</td>
|
||||
<td class="py-3 px-6 text-right space-x-2">
|
||||
<button v-if="page.props.auth.user.role === 'super_admin'" @click="resetPassword(user)" class="text-orange-600 hover:text-orange-900 px-3 py-1 rounded bg-orange-50 hover:bg-orange-100 transition-colors" title="Réinitialiser le mot de passe">
|
||||
MDP
|
||||
</button>
|
||||
<button @click="editUser(user)" class="text-indigo-600 hover:text-indigo-900 px-3 py-1 rounded bg-indigo-50 hover:bg-indigo-100 transition-colors">Modifier</button>
|
||||
<button @click="deleteUser(user)" class="text-red-600 hover:text-red-900 px-3 py-1 rounded bg-red-50 hover:bg-red-100 transition-colors">Supprimer</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="users.length === 0">
|
||||
<td colspan="5" class="py-8 text-center text-slate-500">Aucun accès administrateur trouvé.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
@@ -10,7 +10,7 @@ const props = defineProps({
|
||||
|
||||
const currentQuestionIndex = ref(0);
|
||||
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;
|
||||
|
||||
// Initialize answers from existing attempt answers if any
|
||||
@@ -58,8 +58,8 @@ const formatTime = (seconds) => {
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const currentQuestion = computed(() => props.quiz.questions[currentQuestionIndex.value]);
|
||||
const progress = computed(() => ((currentQuestionIndex.value + 1) / props.quiz.questions.length) * 100);
|
||||
const currentQuestion = computed(() => props.quiz.questions?.[currentQuestionIndex.value] ?? null);
|
||||
const progress = computed(() => props.quiz.questions?.length ? ((currentQuestionIndex.value + 1) / props.quiz.questions.length) * 100 : 0);
|
||||
|
||||
const saveAnswer = async () => {
|
||||
const qid = currentQuestion.value.id;
|
||||
@@ -150,8 +150,13 @@ const finishQuiz = () => {
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 flex flex-col items-center justify-center p-8">
|
||||
<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 -->
|
||||
<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 -->
|
||||
<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
|
||||
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..."
|
||||
v-model="answers[currentQuestion.id].text_content"
|
||||
:value="answers[currentQuestion.id]?.text_content ?? ''"
|
||||
@input="updateOpenAnswer($event.target.value)"
|
||||
@blur="saveAnswer"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,8 @@ const props = defineProps({
|
||||
|
||||
const page = usePage();
|
||||
const user = computed(() => page.props.auth.user);
|
||||
const layout = computed(() => user.value?.role === 'admin' ? AdminLayout : AuthenticatedLayout);
|
||||
const isAdmin = computed(() => ['admin', 'super_admin'].includes(user.value?.role));
|
||||
const layout = computed(() => isAdmin.value ? AdminLayout : AuthenticatedLayout);
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
@@ -30,18 +31,38 @@ const getStatusColor = (status) => {
|
||||
|
||||
<component :is="layout">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold leading-tight capitalize">
|
||||
Tableau de bord {{ user.role }}
|
||||
</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-xl font-semibold leading-tight capitalize">
|
||||
Tableau de bord
|
||||
</h2>
|
||||
<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
|
||||
</span>
|
||||
<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 }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="user.role === 'admin'" class="p-8 space-y-8">
|
||||
<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>
|
||||
@@ -52,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>
|
||||
|
||||
@@ -70,6 +91,7 @@ const getStatusColor = (status) => {
|
||||
<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>
|
||||
@@ -85,6 +107,21 @@ const getStatusColor = (status) => {
|
||||
{{ candidate.weighted_score }} / 20
|
||||
</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="[
|
||||
candidate.ai_analysis.match_score >= 80 ? 'bg-emerald-100 text-emerald-700' :
|
||||
candidate.ai_analysis.match_score >= 60 ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
]"
|
||||
>
|
||||
{{ candidate.ai_analysis.match_score }}%
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-[10px] text-slate-300 italic font-medium">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"
|
||||
@@ -117,41 +154,91 @@ const getStatusColor = (status) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="py-12">
|
||||
<div class="mx-auto max-w-3xl sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-slate-800 p-12 rounded-3xl shadow-xl border border-slate-200 dark:border-slate-700 text-center">
|
||||
<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>
|
||||
<!-- 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">
|
||||
<div class="w-full max-w-4xl">
|
||||
|
||||
<div v-if="quizzes.length > 0" class="space-y-4">
|
||||
<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="text-left flex-1">
|
||||
<h4 class="text-xl font-bold group-hover:text-indigo-600 transition-colors">{{ quiz.title }}</h4>
|
||||
<p class="text-sm text-slate-500 mt-1">{{ quiz.duration_minutes }} minutes • {{ quiz.description }}</p>
|
||||
<!-- 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;">
|
||||
✦ 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>
|
||||
<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>
|
||||
|
||||
<!-- Quizzes Grid -->
|
||||
<div v-if="quizzes && quizzes.length > 0" class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<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>
|
||||
</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" 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">
|
||||
<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="M5 13l4 4L19 7" />
|
||||
|
||||
<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>
|
||||
Test effectué
|
||||
Terminé
|
||||
</div>
|
||||
<Link
|
||||
<Link
|
||||
v-else
|
||||
: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>
|
||||
</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>
|
||||
<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>
|
||||
</component>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<title inertia>{{ config('app.name', 'Laravel') }}</title>
|
||||
|
||||
|
||||
@@ -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),
|
||||
@@ -41,14 +42,24 @@ Route::get('/dashboard', function () {
|
||||
'name' => $candidate->user->name,
|
||||
'email' => $candidate->user->email,
|
||||
'status' => $candidate->status,
|
||||
'weighted_score' => $candidate->weighted_score
|
||||
'weighted_score' => $candidate->weighted_score,
|
||||
'ai_analysis' => $candidate->ai_analysis
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
} else {
|
||||
$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
|
||||
? $candidate->attempts()->where('quiz_id', $quiz->id)->whereNotNull('finished_at')->exists()
|
||||
: false;
|
||||
@@ -75,13 +86,19 @@ Route::middleware('auth')->group(function () {
|
||||
Route::patch('/candidates/{candidate}/notes', [\App\Http\Controllers\CandidateController::class, 'updateNotes'])->name('candidates.update-notes');
|
||||
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::get('/candidates/{candidate}/analyze', [\App\Http\Controllers\AIAnalysisController::class, 'analyze'])->name('candidates.analyze');
|
||||
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');
|
||||
|
||||
Route::resource('quizzes', \App\Http\Controllers\QuizController::class)->only(['index', 'store', 'show', 'update', 'destroy']);
|
||||
Route::resource('job-positions', \App\Http\Controllers\JobPositionController::class)->only(['index', 'store', 'update', 'destroy']);
|
||||
Route::resource('quizzes.questions', \App\Http\Controllers\QuestionController::class)->only(['store', 'update', 'destroy']);
|
||||
Route::resource('tenants', \App\Http\Controllers\TenantController::class)->only(['index', 'store', 'update', 'destroy']);
|
||||
Route::resource('users', \App\Http\Controllers\UserController::class)->except(['show', 'create', 'edit']);
|
||||
Route::post('/users/{user}/reset-password', [\App\Http\Controllers\UserController::class, 'resetPassword'])->name('users.reset-password');
|
||||
Route::get('/backup', [\App\Http\Controllers\BackupController::class, 'download'])->name('backup');
|
||||
Route::delete('/attempts/{attempt}', [\App\Http\Controllers\AttemptController::class, 'destroy'])->name('attempts.destroy');
|
||||
Route::patch('/answers/{answer}/score', [\App\Http\Controllers\AttemptController::class, 'updateAnswerScore'])->name('answers.update-score');
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ export default {
|
||||
'./resources/views/**/*.blade.php',
|
||||
'./resources/js/**/*.vue',
|
||||
],
|
||||
darkMode: 'class',
|
||||
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||
Reference in New Issue
Block a user