feat: multi-tenant SaaS implementation - admin interface, tenant isolation, and UI updates

This commit is contained in:
jeremy bayse
2026-03-28 18:38:22 +01:00
parent 7d94be7a8c
commit f53d5770df
20 changed files with 757 additions and 34 deletions

View File

@@ -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)" />

View File

@@ -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)
</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>
@@ -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

View 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>

View 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>