feat: implementation du role Gestionnaire RH et refonte de la gestion des offres

This commit is contained in:
jeremy bayse
2026-05-09 11:21:40 +02:00
parent 97a8b9443d
commit 9edf79e8ba
23 changed files with 1223 additions and 232 deletions

View File

@@ -1,7 +1,7 @@
<script setup>
import AdminLayout from '@/Layouts/AdminLayout.vue';
import { Head, useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
import { Head, useForm, Link } from '@inertiajs/vue3';
import { ref, computed } from 'vue';
import Modal from '@/Components/Modal.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
@@ -14,6 +14,49 @@ const props = defineProps({
quizzes: Array
});
const viewMode = ref('grid');
const sortKey = ref('created_at');
const sortOrder = ref(-1); // -1 = desc, 1 = asc
const filterStatus = ref('active'); // active, expired, all
const filteredAndSortedPositions = computed(() => {
let result = [...props.jobPositions];
const now = new Date();
// Filtering
if (filterStatus.value === 'active') {
result = result.filter(p => !p.expires_at || new Date(p.expires_at) >= now);
} else if (filterStatus.value === 'expired') {
result = result.filter(p => p.expires_at && new Date(p.expires_at) < now);
}
// Sorting
result.sort((a, b) => {
let valA = a[sortKey.value] || '';
let valB = b[sortKey.value] || '';
if (sortKey.value.includes('at')) {
valA = valA ? new Date(valA).getTime() : 0;
valB = valB ? new Date(valB).getTime() : 0;
}
if (valA < valB) return -1 * sortOrder.value;
if (valA > valB) return 1 * sortOrder.value;
return 0;
});
return result;
});
const toggleSort = (key) => {
if (sortKey.value === key) {
sortOrder.value *= -1;
} else {
sortKey.value = key;
sortOrder.value = -1;
}
};
const showingModal = ref(false);
const editingPosition = ref(null);
@@ -26,6 +69,7 @@ const form = useForm({
tenant_id: '',
quiz_ids: [],
fpt_metadata: null,
expires_at: '',
});
const isGeneratingFpt = ref(false);
@@ -41,6 +85,7 @@ const openModal = (position = null) => {
form.tenant_id = position.tenant_id || '';
form.quiz_ids = position.quizzes ? position.quizzes.map(q => q.id) : [];
form.fpt_metadata = position.fpt_metadata || null;
form.expires_at = position.expires_at ? position.expires_at.split('T')[0] : '';
} else {
form.reset();
}
@@ -88,7 +133,7 @@ const submit = () => {
};
const deletePosition = (id) => {
if (confirm('Voulez-vous vraiment supprimer cette fiche de poste ?')) {
if (confirm('Voulez-vous vraiment supprimer cette offre d\'emploi ?')) {
form.delete(route('admin.job-positions.destroy', id));
}
};
@@ -110,41 +155,125 @@ const copyLink = (position) => {
</script>
<template>
<Head title="Fiches de Poste" />
<Head title="Offres d'emploi" />
<AdminLayout>
<template #header>
<div class="flex justify-between items-center gap-8">
<h2 class="text-xl font-semibold leading-tight capitalize">
Fiches de Poste
Offres d'emploi
</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
Nouvelle Offre
</PrimaryButton>
</div>
</template>
<div class="p-8">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<!-- Filter & Sort Bar -->
<div class="mb-8 flex flex-col md:flex-row gap-6 justify-between items-start md:items-center bg-white dark:bg-slate-800 p-6 rounded-[2rem] shadow-sm border border-slate-100 dark:border-slate-700">
<div class="flex flex-wrap items-center gap-4">
<div class="flex bg-slate-100 dark:bg-slate-900 p-1 rounded-xl">
<button
@click="filterStatus = 'active'"
:class="filterStatus === 'active' ? 'bg-white dark:bg-slate-800 shadow-sm text-indigo-600' : 'text-slate-500 hover:text-indigo-400'"
class="px-4 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all"
>
En cours
</button>
<button
@click="filterStatus = 'expired'"
:class="filterStatus === 'expired' ? 'bg-white dark:bg-slate-800 shadow-sm text-indigo-600' : 'text-slate-500 hover:text-indigo-400'"
class="px-4 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all"
>
Expirées
</button>
<button
@click="filterStatus = 'all'"
:class="filterStatus === 'all' ? 'bg-white dark:bg-slate-800 shadow-sm text-indigo-600' : 'text-slate-500 hover:text-indigo-400'"
class="px-4 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all"
>
Toutes
</button>
</div>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700 mx-2 hidden md:block"></div>
<div class="flex items-center gap-2">
<span class="text-[10px] font-black uppercase tracking-widest text-slate-400">Trier par :</span>
<select
v-model="sortKey"
class="bg-transparent border-none text-xs font-bold text-slate-700 dark:text-slate-300 focus:ring-0 cursor-pointer py-0 pl-0"
>
<option value="created_at">Date de création</option>
<option value="expires_at">Date d'expiration</option>
<option value="title">Titre</option>
</select>
<button @click="sortOrder *= -1" class="p-1 hover:bg-slate-100 dark:hover:bg-slate-900 rounded-lg transition-colors">
<svg v-if="sortOrder === 1" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" /></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M3 4h13M3 8h9m-9 4h6m4 0l4 4m0 0l4-4m-4 4v-12" /></svg>
</button>
</div>
</div>
<div class="flex items-center gap-2 bg-slate-100 dark:bg-slate-900 p-1 rounded-xl">
<button
@click="viewMode = 'grid'"
:class="viewMode === 'grid' ? 'bg-white dark:bg-slate-800 shadow-sm text-indigo-600' : 'text-slate-400 hover:text-indigo-400'"
class="p-2 rounded-lg transition-all"
>
<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.5" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" /></svg>
</button>
<button
@click="viewMode = 'list'"
:class="viewMode === 'list' ? 'bg-white dark:bg-slate-800 shadow-sm text-indigo-600' : 'text-slate-400 hover:text-indigo-400'"
class="p-2 rounded-lg transition-all"
>
<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.5" d="M4 6h16M4 10h16M4 14h16M4 18h16" /></svg>
</button>
</div>
</div>
<!-- Grid View -->
<div v-if="viewMode === 'grid'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div
v-for="position in jobPositions"
v-for="position in filteredAndSortedPositions"
:key="position.id"
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="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">
<div v-if="['super_admin', 'gestionnaire_rh'].includes($page.props.auth.user.role)" 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">
<h3 class="text-2xl font-black mb-1 group-hover:text-indigo-600 transition-colors">{{ position.title }}</h3>
<div class="flex items-center gap-2 mb-3">
<span class="text-[10px] font-black bg-primary/10 text-primary px-2 py-0.5 rounded-full uppercase tracking-tighter">
{{ position.candidates_count }} {{ position.candidates_count > 1 ? 'candidats' : 'candidat' }}
</span>
</div>
<p class="text-slate-500 dark:text-slate-400 text-sm line-clamp-3 leading-relaxed mb-4">
{{ position.description }}
</p>
<div class="flex flex-wrap gap-2 mb-4">
<div v-if="position.expires_at" class="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider px-2 py-1 rounded-lg w-fit" :class="new Date(position.expires_at) < new Date() ? 'text-red-500 bg-red-50 dark:bg-red-900/20' : 'text-emerald-500 bg-emerald-50 dark:bg-emerald-900/20'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ new Date(position.expires_at) < new Date() ? 'Expirée le' : 'Expire le' }} : {{ new Date(position.expires_at).toLocaleDateString() }}
</div>
<div class="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider px-2 py-1 rounded-lg w-fit text-slate-400 bg-slate-50 dark:bg-slate-900/40 border border-slate-100 dark:border-slate-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Créée le : {{ new Date(position.created_at).toLocaleDateString() }}
</div>
</div>
</div>
<div class="flex items-center gap-2 mb-6" v-if="position.requirements?.length">
@@ -160,8 +289,13 @@ const copyLink = (position) => {
</span>
</div>
<div class="pt-6 border-t border-slate-100 dark:border-slate-700 flex justify-between gap-3">
<SecondaryButton @click="openModal(position)" class="flex-1 !justify-center !py-2 text-xs">Modifier</SecondaryButton>
<div class="pt-6 border-t border-slate-100 dark:border-slate-700 flex flex-col gap-3">
<div class="flex gap-3">
<Link :href="route('admin.candidates.index', { job_position: position.id })" class="flex-1 inline-flex items-center justify-center py-2 rounded-xl bg-primary/5 text-primary text-xs font-extrabold uppercase tracking-widest hover:bg-primary/10 transition-all">
Voir Candidats
</Link>
<SecondaryButton @click="openModal(position)" class="flex-1 !justify-center !py-2 text-xs">Modifier</SecondaryButton>
</div>
<div class="flex gap-1">
<button
@click="copyLink(position)"
@@ -184,28 +318,74 @@ const copyLink = (position) => {
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="jobPositions.length === 0" class="col-span-full py-32 text-center">
<div class="inline-flex p-6 bg-slate-100 dark:bg-slate-800 rounded-full mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<!-- List View -->
<div v-else class="space-y-4">
<div
v-for="position in filteredAndSortedPositions"
:key="position.id"
class="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-md transition-all flex flex-col md:flex-row items-center gap-6"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-1">
<h3 class="text-lg font-black truncate">{{ position.title }}</h3>
<span v-if="['super_admin', 'gestionnaire_rh'].includes($page.props.auth.user.role)" 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 shrink-0">
{{ position.tenant ? position.tenant.name : 'Global' }}
</span>
</div>
<div class="flex flex-wrap items-center gap-4 text-[10px] font-bold uppercase tracking-widest text-slate-400">
<div class="flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-indigo-400"></span>
{{ position.candidates_count }} candidats
</div>
<div v-if="position.expires_at" class="flex items-center gap-1.5" :class="new Date(position.expires_at) < new Date() ? 'text-red-500' : 'text-emerald-500'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{{ new Date(position.expires_at) < new Date() ? 'Expirée' : 'Expire' }} : {{ new Date(position.expires_at).toLocaleDateString() }}
</div>
<div class="flex items-center gap-1.5">
Créée le : {{ new Date(position.created_at).toLocaleDateString() }}
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Link :href="route('admin.candidates.index', { job_position: position.id })" title="Voir candidats" class="p-3 bg-primary/5 text-primary rounded-xl hover:bg-primary/10 transition-all">
<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>
</Link>
<button @click="openModal(position)" title="Modifier" class="p-3 bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 rounded-xl hover:bg-slate-200 dark:hover:bg-slate-600 transition-all">
<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="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
</button>
<button @click="copyLink(position)" title="Lien de candidature" class="p-3 text-slate-400 hover:text-indigo-500 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 rounded-xl transition-all">
<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="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
</button>
<button @click="deletePosition(position.id)" title="Supprimer" class="p-3 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-xl transition-all">
<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 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
<h3 class="text-2xl font-black mb-2">Aucune fiche de poste</h3>
<p class="text-slate-500 mb-8">Créez votre première fiche de poste pour permettre l'analyse IA.</p>
<PrimaryButton @click="openModal()">Créer une fiche</PrimaryButton>
</div>
</div>
<!-- Empty State -->
<div v-if="filteredAndSortedPositions.length === 0" class="col-span-full py-32 text-center">
<div class="inline-flex p-6 bg-slate-100 dark:bg-slate-800 rounded-full mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 class="text-2xl font-black mb-2">Aucune offre d'emploi</h3>
<p class="text-slate-500 mb-8">Créez votre première offre d'emploi pour permettre l'analyse IA.</p>
<PrimaryButton @click="openModal()">Créer une offre</PrimaryButton>
</div>
</div>
<!-- Modal Create/Edit -->
<Modal :show="showingModal" @close="closeModal">
<div class="p-8">
<h3 class="text-2xl font-black mb-8">{{ editingPosition ? 'Modifier' : 'Créer' }} la Fiche de Poste</h3>
<h3 class="text-2xl font-black mb-8">{{ editingPosition ? 'Modifier' : 'Créer' }} l'Offre d'emploi</h3>
<form @submit.prevent="submit" class="space-y-6">
<div v-if="$page.props.auth.user.role === 'super_admin'" class="mb-4">
<div v-if="['super_admin', 'gestionnaire_rh'].includes($page.props.auth.user.role)" 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"
@@ -231,7 +411,18 @@ const copyLink = (position) => {
</div>
<div>
<label class="block text-xs font-black uppercase tracking-widest text-slate-400 mb-2">Description / Fiche de Poste</label>
<label class="block text-xs font-black uppercase tracking-widest text-slate-400 mb-2">Date limite de candidature (Expiration)</label>
<input
v-model="form.expires_at"
type="date"
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"
>
<p class="mt-1 text-[10px] text-slate-400 font-bold uppercase tracking-tight">L'offre ne sera plus visible sur le site après cette date.</p>
<InputError :message="form.errors.expires_at" />
</div>
<div>
<label class="block text-xs font-black uppercase tracking-widest text-slate-400 mb-2">Description / Détail de l'offre</label>
<textarea
v-model="form.description"
rows="8"