Compare commits
2 Commits
7d94be7a8c
...
2e423445f5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e423445f5 | ||
|
|
f53d5770df |
@@ -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');
|
||||
|
||||
@@ -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.');
|
||||
|
||||
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.');
|
||||
}
|
||||
}
|
||||
97
app/Http/Controllers/UserController.php
Normal file
97
app/Http/Controllers/UserController.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?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é.');
|
||||
}
|
||||
}
|
||||
@@ -32,12 +32,13 @@ 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
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.
|
||||
*
|
||||
|
||||
38
app/Traits/BelongsToTenant.php
Normal file
38
app/Traits/BelongsToTenant.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?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();
|
||||
|
||||
if ($user->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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 15 KiB |
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>
|
||||
@@ -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">
|
||||
|
||||
@@ -6,11 +6,13 @@ 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
|
||||
|
||||
@@ -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 = () => {
|
||||
@@ -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
|
||||
</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-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>
|
||||
<PrimaryButton @click="isModalOpen = true">
|
||||
Ajouter un Candidat
|
||||
</PrimaryButton>
|
||||
@@ -128,6 +155,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
|
||||
@@ -165,6 +208,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"
|
||||
@@ -245,7 +294,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="7" class="px-6 py-12 text-center text-slate-500 italic">
|
||||
Aucun candidat trouvé.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -285,6 +334,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)" />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
<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)
|
||||
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>
|
||||
@@ -90,7 +96,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 }}
|
||||
@@ -143,6 +154,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
|
||||
|
||||
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>
|
||||
150
resources/js/Pages/Admin/Users/Index.vue
Normal file
150
resources/js/Pages/Admin/Users/Index.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<script setup>
|
||||
import { Head, useForm, Link } from '@inertiajs/vue3';
|
||||
import { ref } from 'vue';
|
||||
import AdminLayout from '@/Layouts/AdminLayout.vue';
|
||||
|
||||
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 cancel = () => {
|
||||
isCreating.value = false;
|
||||
editingUser.value = null;
|
||||
form.reset();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Équipe / Utilisateurs Admin" />
|
||||
<AdminLayout>
|
||||
<template #header>Équipe / Utilisateurs Admin</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-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 @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>
|
||||
@@ -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,12 +31,20 @@ 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-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-400 border border-indigo-200 dark:border-indigo-800 px-3 py-1 rounded-full text-[10px] font-black tracking-widest uppercase">
|
||||
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="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">
|
||||
|
||||
@@ -83,6 +83,8 @@ Route::middleware('auth')->group(function () {
|
||||
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::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');
|
||||
|
||||
Reference in New Issue
Block a user