diff --git a/app/Http/Controllers/CandidateController.php b/app/Http/Controllers/CandidateController.php index e8a0117..54de8b8 100644 --- a/app/Http/Controllers/CandidateController.php +++ b/app/Http/Controllers/CandidateController.php @@ -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'); diff --git a/app/Http/Controllers/JobPositionController.php b/app/Http/Controllers/JobPositionController.php index 706c8ba..263b7d0 100644 --- a/app/Http/Controllers/JobPositionController.php +++ b/app/Http/Controllers/JobPositionController.php @@ -13,7 +13,8 @@ class JobPositionController extends Controller $this->authorizeAdmin(); return Inertia::render('Admin/JobPositions/Index', [ - 'jobPositions' => JobPosition::all() + 'jobPositions' => JobPosition::with('tenant')->get(), + 'tenants' => \App\Models\Tenant::orderBy('name')->get() ]); } @@ -26,6 +27,7 @@ class JobPositionController extends Controller 'description' => 'required|string', 'requirements' => 'nullable|array', 'ai_prompt' => 'nullable|string', + 'tenant_id' => 'nullable|exists:tenants,id', ]); JobPosition::create([ @@ -33,6 +35,7 @@ class JobPositionController extends Controller 'description' => $request->description, 'requirements' => $request->requirements, 'ai_prompt' => $request->ai_prompt, + 'tenant_id' => auth()->user()->isSuperAdmin() ? $request->tenant_id : auth()->user()->tenant_id, ]); return back()->with('success', 'Fiche de poste créée avec succès.'); @@ -47,6 +50,7 @@ class JobPositionController extends Controller 'description' => 'required|string', 'requirements' => 'nullable|array', 'ai_prompt' => 'nullable|string', + 'tenant_id' => 'nullable|exists:tenants,id', ]); $jobPosition->update([ @@ -54,6 +58,7 @@ class JobPositionController extends Controller 'description' => $request->description, 'requirements' => $request->requirements, 'ai_prompt' => $request->ai_prompt, + 'tenant_id' => auth()->user()->isSuperAdmin() ? $request->tenant_id : auth()->user()->tenant_id, ]); return back()->with('success', 'Fiche de poste mise à jour.'); diff --git a/app/Http/Controllers/TenantController.php b/app/Http/Controllers/TenantController.php new file mode 100644 index 0000000..d2a8ef3 --- /dev/null +++ b/app/Http/Controllers/TenantController.php @@ -0,0 +1,64 @@ +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.'); + } +} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php new file mode 100644 index 0000000..19ada21 --- /dev/null +++ b/app/Http/Controllers/UserController.php @@ -0,0 +1,97 @@ +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é.'); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 7a582a7..867ecf6 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -32,7 +32,7 @@ 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'), diff --git a/app/Models/Candidate.php b/app/Models/Candidate.php index 2feae11..8189614 100644 --- a/app/Models/Candidate.php +++ b/app/Models/Candidate.php @@ -9,10 +9,12 @@ 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', 'ai_analysis'])] +use App\Traits\BelongsToTenant; + +#[Fillable(['user_id', 'job_position_id', 'phone', 'linkedin_url', 'status', 'notes', 'cv_score', 'motivation_score', 'interview_score', 'ai_analysis', 'tenant_id'])] class Candidate extends Model { - use HasFactory; + use HasFactory, BelongsToTenant; protected $casts = [ 'ai_analysis' => 'array', diff --git a/app/Models/JobPosition.php b/app/Models/JobPosition.php index 73e86c1..3374c71 100644 --- a/app/Models/JobPosition.php +++ b/app/Models/JobPosition.php @@ -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', 'ai_prompt'])] +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', diff --git a/app/Models/Quiz.php b/app/Models/Quiz.php index 653af25..32de4d7 100644 --- a/app/Models/Quiz.php +++ b/app/Models/Quiz.php @@ -8,10 +8,12 @@ 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 { diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php new file mode 100644 index 0000000..6c1056c --- /dev/null +++ b/app/Models/Tenant.php @@ -0,0 +1,31 @@ +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); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index d894df0..d07a2d9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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. * diff --git a/app/Traits/BelongsToTenant.php b/app/Traits/BelongsToTenant.php new file mode 100644 index 0000000..2b14472 --- /dev/null +++ b/app/Traits/BelongsToTenant.php @@ -0,0 +1,38 @@ +role === 'super_admin') { + // Super admins see everything + return; + } + + if ($user->tenant_id) { + $builder->where('tenant_id', $user->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); + } +} diff --git a/database/migrations/2026_03_28_091033_create_tenants_table.php b/database/migrations/2026_03_28_091033_create_tenants_table.php new file mode 100644 index 0000000..d56e012 --- /dev/null +++ b/database/migrations/2026_03_28_091033_create_tenants_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('name'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tenants'); + } +}; diff --git a/database/migrations/2026_03_28_091035_add_tenant_id_to_saas_tables.php b/database/migrations/2026_03_28_091035_add_tenant_id_to_saas_tables.php new file mode 100644 index 0000000..5c3a664 --- /dev/null +++ b/database/migrations/2026_03_28_091035_add_tenant_id_to_saas_tables.php @@ -0,0 +1,56 @@ +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'); + }); + } +}; diff --git a/resources/js/Layouts/AdminLayout.vue b/resources/js/Layouts/AdminLayout.vue index 9c36d5b..bca3454 100644 --- a/resources/js/Layouts/AdminLayout.vue +++ b/resources/js/Layouts/AdminLayout.vue @@ -77,6 +77,28 @@ const isSidebarOpen = ref(true); Comparateur + + + + + Structures + + + + + + Équipe SaaS +
diff --git a/resources/js/Pages/Admin/Candidates/Index.vue b/resources/js/Pages/Admin/Candidates/Index.vue index afa1935..73fdf79 100644 --- a/resources/js/Pages/Admin/Candidates/Index.vue +++ b/resources/js/Pages/Admin/Candidates/Index.vue @@ -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 = () => { @@ -64,8 +68,16 @@ const getNestedValue = (obj, path) => { return path.split('.').reduce((o, i) => (o ? o[i] : null), obj); }; +const selectedJobPosition = ref(''); + +const filteredCandidates = computed(() => { + if (selectedJobPosition.value === '') return props.candidates; + if (selectedJobPosition.value === 'none') return props.candidates.filter(c => !c.job_position_id); + return props.candidates.filter(c => c.job_position_id === selectedJobPosition.value); +}); + 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 +99,23 @@ const sortedCandidates = computed(() => { Gestion des Candidats -
-

Liste des Candidats

+
+
+

Liste des Candidats

+
+ + +
+
Ajouter un Candidat @@ -128,6 +155,22 @@ const sortedCandidates = computed(() => {
+ +
+ Structure + + + +
+ + +
+ Fiche de Poste + + + +
+
Statut @@ -165,6 +208,12 @@ const sortedCandidates = computed(() => { {{ candidate.user.email }} + + {{ candidate.tenant ? candidate.tenant.name : 'Aucun' }} + + + {{ candidate.job_position ? candidate.job_position.title : 'Non assigné' }} + { - + Aucun candidat trouvé. @@ -285,6 +334,25 @@ const sortedCandidates = computed(() => {
+
+
+ + + +
+
+ + + +
+
+
diff --git a/resources/js/Pages/Admin/JobPositions/Index.vue b/resources/js/Pages/Admin/JobPositions/Index.vue index 6217fd1..0ba49cc 100644 --- a/resources/js/Pages/Admin/JobPositions/Index.vue +++ b/resources/js/Pages/Admin/JobPositions/Index.vue @@ -9,7 +9,8 @@ import DangerButton from '@/Components/DangerButton.vue'; import InputError from '@/Components/InputError.vue'; const props = defineProps({ - jobPositions: Array + jobPositions: Array, + tenants: Array }); const showingModal = ref(false); @@ -19,7 +20,8 @@ const form = useForm({ title: '', description: '', requirements: [], - ai_prompt: '' + ai_prompt: '', + tenant_id: '', }); const openModal = (position = null) => { @@ -29,6 +31,7 @@ const openModal = (position = null) => { form.description = position.description; form.requirements = position.requirements || []; form.ai_prompt = position.ai_prompt || ''; + form.tenant_id = position.tenant_id || ''; } else { form.reset(); } @@ -72,11 +75,14 @@ const removeRequirement = (index) => {