Initial commit

This commit is contained in:
jeremy bayse
2026-03-20 08:25:58 +01:00
commit a55a33ae2a
143 changed files with 19599 additions and 0 deletions

View File

@@ -0,0 +1,250 @@
<script setup>
import AdminLayout from '@/Layouts/AdminLayout.vue';
import { Head, useForm, Link, usePage, router } from '@inertiajs/vue3';
import { ref, computed } from 'vue';
const page = usePage();
const flashSuccess = computed(() => page.props.flash?.success);
import Modal from '@/Components/Modal.vue';
import InputLabel from '@/Components/InputLabel.vue';
import TextInput from '@/Components/TextInput.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import InputError from '@/Components/InputError.vue';
const props = defineProps({
candidates: Array
});
const isModalOpen = ref(false);
const selectedDocument = ref(null);
const form = useForm({
name: '',
email: '',
phone: '',
linkedin_url: '',
cv: null,
cover_letter: null,
});
const submit = () => {
form.post(route('admin.candidates.store'), {
onSuccess: () => {
isModalOpen.value = false;
form.reset();
},
});
};
const deleteCandidate = (id) => {
if (confirm('Voulez-vous vraiment supprimer ce candidat ?')) {
router.delete(route('admin.candidates.destroy', id));
}
};
const openPreview = (doc) => {
selectedDocument.value = doc;
};
</script>
<template>
<Head title="Gestion des Candidats" />
<AdminLayout>
<template #header>
Gestion des Candidats
</template>
<div class="flex justify-between items-center mb-8">
<h3 class="text-2xl font-bold">Liste des Candidats</h3>
<PrimaryButton @click="isModalOpen = true">
Ajouter un Candidat
</PrimaryButton>
</div>
<!-- Flash Messages -->
<div v-if="flashSuccess" class="mb-8 p-6 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-2xl flex items-center gap-4 animate-in fade-in slide-in-from-top-4 duration-500">
<div class="p-2 bg-emerald-500 rounded-lg text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<p class="font-bold text-emerald-800 dark:text-emerald-400">Succès !</p>
<p class="text-emerald-700 dark:text-emerald-500 text-sm">{{ flashSuccess }}</p>
</div>
</div>
<!-- Candidates Table -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
<table class="w-full text-left">
<thead class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
<tr>
<th class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300">Nom</th>
<th class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300">Email</th>
<th class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300">Statut</th>
<th class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300">Documents</th>
<th class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
<tr v-for="candidate in candidates" :key="candidate.id" class="hover:bg-slate-50/50 dark:hover:bg-slate-700/30 transition-colors">
<td class="px-6 py-4">
<div class="font-medium">{{ candidate.user.name }}</div>
<div class="text-xs text-slate-500">{{ candidate.phone }}</div>
</td>
<td class="px-6 py-4 text-slate-600 dark:text-slate-400">
{{ candidate.user.email }}
</td>
<td class="px-6 py-4">
<span
class="px-2 py-1 rounded-full text-xs font-semibold"
:class="{
'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400': candidate.status === 'en_attente',
'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400': candidate.status === 'en_cours',
'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400': candidate.status === 'termine'
}"
>
{{ candidate.status }}
</span>
</td>
<td class="px-6 py-4">
<div class="flex gap-2">
<button
v-for="doc in candidate.documents"
:key="doc.id"
@click="openPreview(doc)"
class="p-2 bg-slate-100 dark:bg-slate-700 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
:title="doc.type.toUpperCase()"
>
<svg v-if="doc.type === 'cv'" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-indigo-600 dark:text-indigo-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>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</button>
</div>
</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end gap-3">
<Link :href="route('admin.candidates.show', candidate.id)" class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium">Détails</Link>
<button @click="deleteCandidate(candidate.id)" class="p-2 text-slate-400 hover:text-red-600 transition-colors" title="Supprimer">
<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>
</td>
</tr>
<tr v-if="candidates.length === 0">
<td colspan="5" class="px-6 py-12 text-center text-slate-500 italic">
Aucun candidat trouvé.
</td>
</tr>
</tbody>
</table>
</div>
<!-- Add Candidate Modal -->
<Modal :show="isModalOpen" @close="isModalOpen = false">
<div class="p-6">
<h3 class="text-xl font-bold mb-6">Ajouter un nouveau candidat</h3>
<form @submit.prevent="submit" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<InputLabel for="name" value="Nom Complet" />
<TextInput id="name" type="text" class="mt-1 block w-full" v-model="form.name" required />
<InputError class="mt-2" :message="form.errors.name" />
</div>
<div>
<InputLabel for="email" value="Email" />
<TextInput id="email" type="email" class="mt-1 block w-full" v-model="form.email" required />
<InputError class="mt-2" :message="form.errors.email" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<InputLabel for="phone" value="Téléphone" />
<TextInput id="phone" type="text" class="mt-1 block w-full" v-model="form.phone" />
<InputError class="mt-2" :message="form.errors.phone" />
</div>
<div>
<InputLabel for="linkedin_url" value="LinkedIn URL" />
<TextInput id="linkedin_url" type="url" class="mt-1 block w-full" v-model="form.linkedin_url" />
<InputError class="mt-2" :message="form.errors.linkedin_url" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<InputLabel value="CV (PDF)" />
<div class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-slate-300 dark:border-slate-600 border-dashed rounded-lg">
<div class="space-y-1 text-center">
<svg class="mx-auto h-10 w-10 text-slate-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<div class="flex text-sm text-slate-600 dark:text-slate-400">
<label class="relative cursor-pointer bg-white dark:bg-slate-800 rounded-md font-medium text-indigo-600 hover:text-indigo-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500">
<span>Téléverser</span>
<input type="file" class="sr-only" @input="form.cv = $event.target.files[0]" accept="application/pdf" />
</label>
</div>
<p class="text-xs text-slate-500">{{ form.cv ? form.cv.name : 'PDF uniquement' }}</p>
</div>
</div>
<InputError class="mt-2" :message="form.errors.cv" />
</div>
<div>
<InputLabel value="Lettre de motivation (PDF)" />
<div class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-slate-300 dark:border-slate-600 border-dashed rounded-lg">
<div class="space-y-1 text-center">
<svg class="mx-auto h-10 w-10 text-slate-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<div class="flex text-sm text-slate-600 dark:text-slate-400">
<label class="relative cursor-pointer bg-white dark:bg-slate-800 rounded-md font-medium text-indigo-600 hover:text-indigo-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500">
<span>Téléverser</span>
<input type="file" class="sr-only" @input="form.cover_letter = $event.target.files[0]" accept="application/pdf" />
</label>
</div>
<p class="text-xs text-slate-500">{{ form.cover_letter ? form.cover_letter.name : 'PDF uniquement' }}</p>
</div>
</div>
<InputError class="mt-2" :message="form.errors.cover_letter" />
</div>
</div>
<div class="flex justify-end gap-3 mt-8">
<SecondaryButton @click="isModalOpen = false" :disabled="form.processing">
Annuler
</SecondaryButton>
<PrimaryButton :disabled="form.processing">
Enregistrer Candidat
</PrimaryButton>
</div>
</form>
</div>
</Modal>
<!-- Document Preview Modal -->
<Modal :show="!!selectedDocument" @close="selectedDocument = null" max-width="4xl">
<div class="p-6 h-[80vh] flex flex-col">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-bold">Aperçu : {{ selectedDocument.original_name }}</h3>
<SecondaryButton @click="selectedDocument = null">Fermer</SecondaryButton>
</div>
<div class="flex-1 bg-slate-100 dark:bg-slate-900 rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700">
<iframe
v-if="selectedDocument"
:src="route('admin.documents.show', selectedDocument.id)"
class="w-full h-full"
></iframe>
</div>
</div>
</Modal>
</AdminLayout>
</template>

View File

@@ -0,0 +1,321 @@
<script setup>
import AdminLayout from '@/Layouts/AdminLayout.vue';
import { Head, Link, router, useForm, usePage } from '@inertiajs/vue3';
import { ref, computed } from 'vue';
import Modal from '@/Components/Modal.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import DangerButton from '@/Components/DangerButton.vue';
import InputError from '@/Components/InputError.vue';
const props = defineProps({
candidate: Object
});
const page = usePage();
const flashSuccess = computed(() => page.props.flash?.success);
const selectedDocument = ref(null);
const docForm = useForm({
cv: null,
cover_letter: null,
_method: 'PUT' // For file upload via PUT in Laravel
});
const openAttempts = ref([]);
const toggleAttempt = (id) => {
if (openAttempts.value.includes(id)) {
openAttempts.value = openAttempts.value.filter(item => item !== id);
} else {
openAttempts.value.push(id);
}
};
const formatDateTime = (date) => {
return new Date(date).toLocaleString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const deleteCandidate = () => {
if (confirm('Voulez-vous vraiment supprimer ce candidat ? Toutes ses données seront DEFINITIVEMENT perdues.')) {
router.delete(route('admin.candidates.destroy', props.candidate.id));
}
};
const deleteAttempt = (id) => {
if (confirm('Voulez-vous vraiment supprimer cette tentative de test ? Cette action sera enregistrée dans les logs.')) {
router.delete(route('admin.attempts.destroy', id), {
preserveScroll: true
});
}
};
const resetPassword = () => {
if (confirm('Générer un nouveau mot de passe pour ce candidat ?')) {
router.post(route('admin.candidates.reset-password', props.candidate.id));
}
};
const updateDocuments = () => {
docForm.post(route('admin.candidates.update', props.candidate.id), {
onSuccess: () => {
docForm.reset();
},
});
};
const openPreview = (doc) => {
selectedDocument.value = doc;
};
</script>
<template>
<Head :title="'Candidat - ' + candidate.user.name" />
<AdminLayout>
<template #header>
<div class="flex items-center gap-4">
<Link :href="route('admin.candidates.index')" class="p-2 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors">
<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="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</Link>
<span>Détails Candidat</span>
</div>
</template>
<!-- Flash Messages -->
<div v-if="flashSuccess" class="mb-8 p-6 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-2xl flex items-center gap-4 animate-in fade-in slide-in-from-top-4 duration-500">
<div class="p-2 bg-emerald-500 rounded-lg text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="flex-1">
<p class="font-bold text-emerald-800 dark:text-emerald-400">Action réussie !</p>
<p class="text-emerald-700 dark:text-emerald-500 text-sm">{{ flashSuccess }}</p>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-8">
<!-- Sidebar: Profile & Docs -->
<div class="space-y-8">
<!-- Profile Card -->
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="h-24 bg-gradient-to-r from-indigo-500 to-purple-600"></div>
<div class="px-6 pb-6 text-center -mt-12">
<div class="w-24 h-24 bg-white dark:bg-slate-900 rounded-2xl shadow-xl border-4 border-white dark:border-slate-800 flex items-center justify-center text-4xl font-black text-indigo-600 mx-auto mb-4">
{{ candidate.user.name.charAt(0) }}
</div>
<h3 class="text-xl font-bold">{{ candidate.user.name }}</h3>
<p class="text-slate-500 text-sm mb-6">{{ candidate.user.email }}</p>
<div class="flex flex-col gap-3 text-left">
<div class="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-900 rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
<span class="text-sm font-medium">{{ candidate.phone || 'Non renseigné' }}</span>
</div>
<a v-if="candidate.linkedin_url" :href="candidate.linkedin_url" target="_blank" class="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-900 rounded-xl hover:text-indigo-600 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/>
</svg>
<span class="text-sm font-medium">LinkedIn Profile</span>
</a>
</div>
</div>
<div class="px-6 py-4 bg-slate-50 dark:bg-slate-900 border-t border-slate-200 dark:border-slate-700 flex justify-between items-center gap-2">
<SecondaryButton @click="resetPassword" class="!px-3 !py-1 text-[10px] uppercase font-bold tracking-widest">Réinitialiser MDP</SecondaryButton>
<DangerButton @click="deleteCandidate" class="!px-3 !py-1 text-[10px] uppercase font-bold tracking-widest">Supprimer Compte</DangerButton>
</div>
</div>
<!-- Documents Card -->
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-6">
<h4 class="font-bold mb-4 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
Documents joints
</h4>
<div class="space-y-3">
<button
v-for="doc in candidate.documents"
:key="doc.id"
@click="openPreview(doc)"
class="w-full flex items-center justify-between p-4 bg-slate-100 dark:bg-slate-900 rounded-xl hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors group"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-white dark:bg-slate-800 rounded-lg group-hover:bg-indigo-500 group-hover:text-white transition-colors">
<svg v-if="doc.type === 'cv'" 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="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>
<svg v-else 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="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div class="text-left">
<div class="text-sm font-bold uppercase tracking-tight">{{ doc.type }}</div>
<div class="text-[10px] text-slate-500 truncate max-w-[150px]">{{ doc.original_name }}</div>
</div>
</div>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-400 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<div class="pt-6 mt-6 border-t border-slate-100 dark:border-slate-700">
<h5 class="text-xs font-black uppercase text-slate-400 tracking-widest mb-4">Ajouter / Remplacer</h5>
<form @submit.prevent="updateDocuments" class="space-y-4">
<div class="grid grid-cols-2 gap-2">
<div class="relative group/file">
<label class="flex flex-col items-center justify-center p-3 bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl cursor-pointer hover:border-indigo-500 transition-colors">
<span class="text-[10px] font-bold uppercase tracking-tight text-slate-500">CV (PDF)</span>
<span class="text-[9px] text-slate-400 truncate w-full text-center mt-1">{{ docForm.cv ? docForm.cv.name : 'Choisir...' }}</span>
<input type="file" class="hidden" @input="docForm.cv = $event.target.files[0]" accept="application/pdf" />
</label>
</div>
<div class="relative group/file">
<label class="flex flex-col items-center justify-center p-3 bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl cursor-pointer hover:border-emerald-500 transition-colors">
<span class="text-[10px] font-bold uppercase tracking-tight text-slate-500">Lettre (PDF)</span>
<span class="text-[9px] text-slate-400 truncate w-full text-center mt-1">{{ docForm.cover_letter ? docForm.cover_letter.name : 'Choisir...' }}</span>
<input type="file" class="hidden" @input="docForm.cover_letter = $event.target.files[0]" accept="application/pdf" />
</label>
</div>
</div>
<InputError :message="docForm.errors.cv" />
<InputError :message="docForm.errors.cover_letter" />
<PrimaryButton class="w-full !justify-center !py-2 text-xs" :disabled="docForm.processing || (!docForm.cv && !docForm.cover_letter)">
Mettre à jour les fichiers
</PrimaryButton>
</form>
</div>
</div>
</div>
</div>
<!-- Main: Attempts -->
<div class="xl:col-span-2 space-y-8">
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8">
<h3 class="text-xl font-bold mb-8 flex items-center justify-between">
Historique des Tests
<span class="text-sm font-normal text-slate-400">{{ candidate.attempts.length }} tentative(s)</span>
</h3>
<div v-if="candidate.attempts.length > 0" class="space-y-6">
<div v-for="attempt in candidate.attempts" :key="attempt.id" class="bg-slate-50 dark:bg-slate-900 rounded-3xl border border-slate-200 dark:border-slate-800 overflow-hidden group">
<!-- Attempt Header -->
<div
@click="toggleAttempt(attempt.id)"
class="p-6 flex flex-col md:flex-row md:items-center justify-between gap-6 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800/50 transition-colors"
>
<div class="flex items-center gap-6">
<div class="px-5 py-3 bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-sm text-center min-w-[100px]">
<div class="text-[10px] uppercase font-black tracking-widest text-slate-400">Score</div>
<div class="text-xl font-black text-indigo-600 dark:text-indigo-400">
{{ attempt.score }}<span class="text-slate-300 dark:text-slate-600 mx-1">/</span>{{ attempt.max_score || '?' }}
</div>
</div>
<div>
<h4 class="text-xl font-black uppercase tracking-tight">{{ attempt.quiz.title }}</h4>
<div class="text-xs text-slate-400 mt-1 uppercase font-bold tracking-widest">
Fini le {{ formatDateTime(attempt.finished_at) }}
</div>
</div>
</div>
<div class="flex items-center gap-4 self-end md:self-auto">
<button
@click.stop="deleteAttempt(attempt.id)"
class="p-3 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-xl transition-all"
title="Supprimer ce test"
>
<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 class="w-10 h-10 rounded-full bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 flex items-center justify-center text-slate-400 group-hover:text-indigo-600 group-hover:border-indigo-500 transition-all shadow-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 transition-transform duration-300" :class="{ 'rotate-180': openAttempts.includes(attempt.id) }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</div>
<!-- Accordion Content -->
<div v-show="openAttempts.includes(attempt.id)" class="border-t border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-800/50 divide-y divide-slate-100 dark:divide-slate-800 animate-in slide-in-from-top-2 duration-300">
<div v-for="(answer, aIdx) in attempt.answers" :key="answer.id" class="p-8">
<div class="flex items-start gap-6">
<div class="w-10 h-10 rounded-2xl bg-slate-50 dark:bg-slate-900 border border-slate-100 dark:border-slate-800 flex items-center justify-center font-black text-xs shrink-0 text-slate-400">
{{ String(aIdx + 1).padStart(2, '0') }}
</div>
<div class="flex-1">
<h5 class="text-sm font-bold text-slate-700 dark:text-slate-300 mb-4 leading-relaxed">{{ answer.question.label }}</h5>
<!-- QCM Answer -->
<div v-if="answer.question.type === 'qcm'" class="flex items-center">
<div
class="px-5 py-3 rounded-2xl text-[13px] font-bold border flex items-center gap-3 shadow-sm"
:class="[
answer.option.is_correct
? 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-900/20 dark:border-emerald-800 dark:text-emerald-400'
: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-900/20 dark:border-red-800 dark:text-red-400'
]"
>
<div class="p-1 rounded-full bg-white/50 dark:bg-black/20">
<svg v-if="answer.option.is_correct" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
{{ answer.option.option_text }}
</div>
</div>
<!-- Open Answer -->
<div v-else>
<div class="p-6 bg-slate-50 dark:bg-slate-900 border border-slate-100 dark:border-slate-800 rounded-3xl text-[13px] text-slate-600 dark:text-slate-400 leading-relaxed italic shadow-inner">
" {{ answer.text_content || 'Aucune réponse fournie.' }} "
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="py-12 text-center text-slate-400 italic">
Ce candidat n'a pas encore terminé de test.
</div>
</div>
</div>
</div>
<!-- Document Preview Modal -->
<Modal :show="!!selectedDocument" @close="selectedDocument = null" max-width="4xl">
<div class="p-6 h-[80vh] flex flex-col">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-bold">Aperçu : {{ selectedDocument.original_name }}</h3>
<SecondaryButton @click="selectedDocument = null">Fermer</SecondaryButton>
</div>
<div class="flex-1 bg-slate-100 dark:bg-slate-900 rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700">
<iframe
v-if="selectedDocument"
:src="route('admin.documents.show', selectedDocument.id)"
class="w-full h-full"
></iframe>
</div>
</div>
</Modal>
</AdminLayout>
</template>

View File

@@ -0,0 +1,124 @@
<script setup>
import AdminLayout from '@/Layouts/AdminLayout.vue';
import { Head, Link } from '@inertiajs/vue3';
import { ref, computed } from 'vue';
const props = defineProps({
candidates: Array
});
const searchQuery = ref('');
const filteredCandidates = computed(() => {
return props.candidates.filter(c =>
c.user.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
c.user.email.toLowerCase().includes(searchQuery.value.toLowerCase())
).sort((a, b) => {
const scoreA = a.attempts[0]?.score || 0;
const scoreB = b.attempts[0]?.score || 0;
return scoreB - scoreA;
});
});
const getScorePercentage = (candidate) => {
const attempt = candidate.attempts[0];
if (!attempt || !attempt.quiz) return 0;
// Total points possible (this would ideally be passed from backend or calculated)
// For now, we assume attempt.score is the absolute score.
return attempt.score;
};
const formatDuration = (started, finished) => {
if (!started || !finished) return '--';
const start = new Date(started);
const end = new Date(finished);
const diff = Math.floor((end - start) / 1000 / 60);
return diff + ' min';
};
</script>
<template>
<Head title="Comparateur de Candidats" />
<AdminLayout>
<template #header>
Comparateur de Candidats
</template>
<div class="mb-8 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<h3 class="text-2xl font-bold">Classement par Score</h3>
<div class="relative w-full sm:w-64">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-slate-400">
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</span>
<input
v-model="searchQuery"
type="text"
placeholder="Rechercher..."
class="block w-full pl-10 pr-3 py-2 border border-slate-300 dark:border-slate-700 rounded-lg leading-5 bg-white dark:bg-slate-800 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm transition-all"
>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
<table class="w-full text-left">
<thead class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
<tr>
<th class="px-6 py-4 font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider text-xs">Rang</th>
<th class="px-6 py-4 font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider text-xs">Candidat</th>
<th class="px-6 py-4 font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider text-xs text-center">Score</th>
<th class="px-6 py-4 font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider text-xs">Temps passé</th>
<th class="px-6 py-4 font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider text-xs">Test</th>
<th class="px-6 py-4 font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider text-xs text-right">Date</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
<tr v-for="(candidate, index) in filteredCandidates" :key="candidate.id" class="hover:bg-slate-50/50 dark:hover:bg-slate-700/30 transition-colors group">
<td class="px-6 py-4">
<div
class="w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm"
:class="[
index === 0 ? 'bg-yellow-100 text-yellow-700' :
index === 1 ? 'bg-slate-200 text-slate-700' :
index === 2 ? 'bg-orange-100 text-orange-700' : 'text-slate-500'
]"
>
{{ index + 1 }}
</div>
</td>
<td class="px-6 py-4">
<div class="font-bold text-slate-900 dark:text-slate-100">{{ candidate.user.name }}</div>
<div class="text-xs text-slate-500">{{ candidate.user.email }}</div>
</td>
<td class="px-6 py-4">
<div class="flex flex-col items-center">
<span class="text-xl font-black text-indigo-600 dark:text-indigo-400">{{ candidate.attempts[0]?.score }}</span>
<span class="text-[10px] uppercase font-bold text-slate-400">Points</span>
</div>
</td>
<td class="px-6 py-4 text-slate-600 dark:text-slate-400 font-medium">
{{ formatDuration(candidate.attempts[0]?.started_at, candidate.attempts[0]?.finished_at) }}
</td>
<td class="px-6 py-4">
<span class="text-sm px-2 py-1 bg-slate-100 dark:bg-slate-700 rounded-md font-medium">
{{ candidate.attempts[0]?.quiz?.title }}
</span>
</td>
<td class="px-6 py-4 text-right text-slate-500 text-sm">
{{ new Date(candidate.attempts[0]?.finished_at).toLocaleDateString() }}
</td>
</tr>
<tr v-if="filteredCandidates.length === 0">
<td colspan="6" class="px-6 py-12 text-center text-slate-500 italic">
Aucun candidat n'a encore terminé de test.
</td>
</tr>
</tbody>
</table>
</div>
</AdminLayout>
</template>

View File

@@ -0,0 +1,171 @@
<script setup>
import AdminLayout from '@/Layouts/AdminLayout.vue';
import { Head, useForm, Link, usePage, router } from '@inertiajs/vue3';
import { ref, computed } from 'vue';
import Modal from '@/Components/Modal.vue';
import InputLabel from '@/Components/InputLabel.vue';
import TextInput from '@/Components/TextInput.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DangerButton from '@/Components/DangerButton.vue';
import InputError from '@/Components/InputError.vue';
const props = defineProps({
quizzes: Array
});
const page = usePage();
const flashSuccess = computed(() => page.props.flash?.success);
const isModalOpen = ref(false);
const editingQuiz = ref(null);
const form = useForm({
title: '',
description: '',
duration_minutes: 60,
});
const openEditModal = (quiz) => {
editingQuiz.value = quiz;
form.title = quiz.title;
form.description = quiz.description;
form.duration_minutes = quiz.duration_minutes;
isModalOpen.value = true;
};
const deleteQuiz = (id) => {
if (confirm('Voulez-vous vraiment supprimer ce quiz ? Toutes les questions et tentatives associées seront supprimées.')) {
router.delete(route('admin.quizzes.destroy', id));
}
};
const closeModal = () => {
isModalOpen.value = false;
editingQuiz.value = null;
form.reset();
};
const submit = () => {
if (editingQuiz.value) {
form.put(route('admin.quizzes.update', editingQuiz.value.id), {
onSuccess: () => closeModal(),
});
} else {
form.post(route('admin.quizzes.store'), {
onSuccess: () => closeModal(),
});
}
};
</script>
<template>
<Head title="Gestion des Quiz" />
<AdminLayout>
<template #header>
Gestion des Quiz
</template>
<!-- Flash Messages -->
<div v-if="flashSuccess" class="mb-8 p-6 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-2xl flex items-center gap-4 animate-in fade-in slide-in-from-top-4 duration-500">
<div class="p-2 bg-emerald-500 rounded-lg text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<p class="font-bold text-emerald-800 dark:text-emerald-400">Succès !</p>
<p class="text-emerald-700 dark:text-emerald-500 text-sm">{{ flashSuccess }}</p>
</div>
</div>
<div class="flex justify-between items-center mb-8">
<h3 class="text-2xl font-bold">Liste des Quiz</h3>
<PrimaryButton @click="isModalOpen = true">
Créer un Quiz
</PrimaryButton>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="quiz in quizzes" :key="quiz.id" class="bg-white dark:bg-slate-800 rounded-3xl shadow-sm border border-slate-200 dark:border-slate-700 flex flex-col h-full group hover:shadow-xl transition-all duration-300">
<div class="p-8 pb-4">
<div class="flex justify-between items-start mb-4">
<h4 class="text-xl font-bold text-slate-900 dark:text-slate-100 group-hover:text-indigo-600 transition-colors">{{ quiz.title }}</h4>
<span class="px-3 py-1 bg-indigo-50 text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-400 text-[10px] font-black uppercase tracking-widest rounded-full">
{{ quiz.duration_minutes }} min
</span>
</div>
<p class="text-slate-500 dark:text-slate-400 text-sm line-clamp-3">
{{ quiz.description || 'Aucune description' }}
</p>
</div>
<div class="mt-auto p-4 bg-slate-50/50 dark:bg-slate-900/30 border-t border-slate-100 dark:border-slate-800 flex justify-between items-center rounded-b-3xl">
<Link :href="route('admin.quizzes.show', quiz.id)" class="px-4 py-2 text-indigo-600 dark:text-indigo-400 font-bold text-xs uppercase tracking-widest hover:text-indigo-900 dark:hover:text-white transition-colors">
Questions
</Link>
<div class="flex items-center gap-1">
<button @click="openEditModal(quiz)" class="p-2 text-amber-500 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-xl transition-colors" title="Modifier">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button @click="deleteQuiz(quiz.id)" class="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-xl transition-colors" title="Supprimer">
<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>
</div>
</div>
<div v-if="quizzes.length === 0" class="col-span-full py-12 text-center text-slate-500 italic">
Aucun quiz créé.
</div>
</div>
<!-- Quiz Modal (Create/Edit) -->
<Modal :show="isModalOpen" @close="closeModal">
<div class="p-8">
<h3 class="text-2xl font-black uppercase tracking-tight mb-8">
{{ editingQuiz ? 'Modifier le Quiz' : 'Créer un nouveau Quiz' }}
</h3>
<form @submit.prevent="submit" class="space-y-6">
<div>
<InputLabel for="title" value="Titre du Quiz" />
<TextInput id="title" type="text" class="mt-1 block w-full" v-model="form.title" required />
<InputError class="mt-2" :message="form.errors.title" />
</div>
<div>
<InputLabel for="description" value="Description" />
<textarea
id="description"
class="mt-1 block w-full border-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm"
rows="3"
v-model="form.description"
></textarea>
<InputError class="mt-2" :message="form.errors.description" />
</div>
<div>
<InputLabel for="duration" value="Durée (minutes)" />
<TextInput id="duration" type="number" class="mt-1 block w-full" v-model="form.duration_minutes" required />
<InputError class="mt-2" :message="form.errors.duration_minutes" />
</div>
<div class="flex justify-end gap-3 mt-8">
<SecondaryButton @click="isModalOpen = false" :disabled="form.processing">
Annuler
</SecondaryButton>
<PrimaryButton :disabled="form.processing">
{{ editingQuiz ? 'Modifier Quiz' : 'Créer Quiz' }}
</PrimaryButton>
</div>
</form>
</div>
</Modal>
</AdminLayout>
</template>

View File

@@ -0,0 +1,289 @@
<script setup>
import AdminLayout from '@/Layouts/AdminLayout.vue';
import { Head, useForm, Link, router, usePage } from '@inertiajs/vue3';
import { ref, computed } from 'vue';
import Modal from '@/Components/Modal.vue';
import InputLabel from '@/Components/InputLabel.vue';
import TextInput from '@/Components/TextInput.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DangerButton from '@/Components/DangerButton.vue';
import InputError from '@/Components/InputError.vue';
const props = defineProps({
quiz: Object
});
const page = usePage();
const flashSuccess = computed(() => page.props.flash?.success);
const isModalOpen = ref(false);
const editingQuestion = ref(null);
const form = useForm({
title: '',
context: '',
label: '',
points: 1,
type: 'qcm',
options: [
{ option_text: '', is_correct: false },
{ option_text: '', is_correct: false },
]
});
const openCreateModal = () => {
editingQuestion.value = null;
form.reset();
isModalOpen.value = true;
};
const openEditModal = (question) => {
editingQuestion.value = question;
form.title = question.title || '';
form.context = question.context || '';
form.label = question.label;
form.points = question.points;
form.type = question.type;
form.options = question.options.map(o => ({
option_text: o.option_text,
is_correct: !!o.is_correct
}));
if (form.options.length === 0 && form.type === 'qcm') {
form.options = [{ option_text: '', is_correct: false }, { option_text: '', is_correct: false }];
}
isModalOpen.value = true;
};
const deleteQuestion = (questionId) => {
if (confirm('Voulez-vous vraiment supprimer cette question ?')) {
router.delete(route('admin.quizzes.questions.destroy', [props.quiz.id, questionId]));
}
};
const addOption = () => {
form.options.push({ option_text: '', is_correct: false });
};
const removeOption = (index) => {
form.options.splice(index, 1);
};
const submit = () => {
if (editingQuestion.value) {
form.put(route('admin.quizzes.questions.update', [props.quiz.id, editingQuestion.value.id]), {
onSuccess: () => {
isModalOpen.value = false;
form.reset();
editingQuestion.value = null;
},
});
} else {
form.post(route('admin.quizzes.questions.store', [props.quiz.id]), {
onSuccess: () => {
isModalOpen.value = false;
form.reset();
},
});
}
};
</script>
<template>
<Head :title="'Gestion - ' + quiz.title" />
<AdminLayout>
<template #header>
<div class="flex items-center gap-4">
<Link :href="route('admin.quizzes.index')" class="p-2 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors">
<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="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</Link>
<span>Quiz: {{ quiz.title }}</span>
</div>
</template>
<div class="flex justify-between items-center mb-8">
<h3 class="text-2xl font-bold">Banque de Questions</h3>
<PrimaryButton @click="openCreateModal">
Ajouter une Question
</PrimaryButton>
</div>
<!-- Flash Messages -->
<div v-if="flashSuccess" class="mb-8 p-6 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-2xl flex items-center gap-4 animate-in fade-in slide-in-from-top-4 duration-500">
<div class="p-2 bg-emerald-500 rounded-lg text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<p class="font-bold text-emerald-800 dark:text-emerald-400">Succès !</p>
<p class="text-emerald-700 dark:text-emerald-500 text-sm">{{ flashSuccess }}</p>
</div>
</div>
<div class="space-y-6">
<div
v-for="(question, qIndex) in quiz.questions"
:key="question.id"
class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden"
>
<div class="p-6 bg-slate-50 dark:bg-slate-700/30 border-b border-slate-200 dark:border-slate-700 flex justify-between items-start">
<div>
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest block mb-1">Question #{{ qIndex + 1 }} - {{ question.points }} pts</span>
<h4 class="text-lg font-bold">
<span v-if="question.title" class="text-indigo-600 mr-2">[{{ question.title }}]</span>
{{ question.label }}
</h4>
<div v-if="question.context" class="mt-2 p-3 bg-slate-100 dark:bg-slate-900/50 rounded-lg text-sm text-slate-600 dark:text-slate-400 border-l-4 border-slate-300">
{{ question.context }}
</div>
</div>
<div class="flex items-center gap-3">
<span class="px-2 py-1 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-600 rounded text-xs font-bold uppercase tracking-tight mr-2">
{{ question.type }}
</span>
<button @click="openEditModal(question)" class="p-2 text-indigo-600 hover:bg-white dark:hover:bg-slate-700 rounded-lg transition-colors" title="Modifier">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button @click="deleteQuestion(question.id)" class="p-2 text-red-600 hover:bg-white dark:hover:bg-slate-700 rounded-lg transition-colors" title="Supprimer">
<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>
</div>
<div v-if="question.type === 'qcm'" class="p-6">
<ul class="space-y-3">
<li v-for="option in question.options" :key="option.id" class="flex items-center gap-3 text-sm p-3 rounded-lg border border-slate-100 dark:border-slate-700">
<div
class="w-2 h-2 rounded-full"
:class="[option.is_correct ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]' : 'bg-slate-300 dark:bg-slate-600']"
></div>
<span :class="[option.is_correct ? 'font-bold text-emerald-600 dark:text-emerald-400' : 'text-slate-600 dark:text-slate-400']">
{{ option.option_text }}
</span>
</li>
</ul>
</div>
<div v-else class="p-6 text-slate-500 italic text-sm">
Question ouverte (reponse libre).
</div>
</div>
<div v-if="quiz.questions.length === 0" class="py-12 text-center text-slate-500 italic">
Aucune question dans ce quiz.
</div>
</div>
<!-- Add/Edit Question Modal -->
<Modal :show="isModalOpen" @close="isModalOpen = false">
<div class="p-6">
<h3 class="text-xl font-bold mb-6">
{{ editingQuestion ? 'Modifier la Question' : 'Ajouter une Question' }}
</h3>
<form @submit.prevent="submit" class="space-y-6">
<div v-if="Object.keys(form.errors).length > 0" class="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl text-red-600 dark:text-red-400 text-sm">
<p class="font-bold">Veuillez corriger les erreurs suivantes :</p>
<ul class="list-disc list-inside mt-1">
<li v-for="(error, field) in form.errors" :key="field">{{ error }}</li>
</ul>
</div>
<div class="space-y-4">
<div>
<InputLabel for="title" value="Titre de la question (optionnel)" />
<TextInput id="title" type="text" class="mt-1 block w-full" v-model="form.title" placeholder="Ex: Algorithmique, Debugging..." />
<InputError class="mt-2" :message="form.errors.title" />
</div>
<div>
<InputLabel for="context" value="Contexte ou Enoncé détaillé (optionnel)" />
<textarea
id="context"
v-model="form.context"
rows="3"
class="mt-1 block w-full border-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm"
placeholder="Donnez du contexte au candidat..."
></textarea>
<InputError class="mt-2" :message="form.errors.context" />
</div>
<div>
<InputLabel for="label" value="Question" />
<TextInput id="label" type="text" class="mt-1 block w-full" v-model="form.label" required placeholder="La question précise à poser" />
<InputError class="mt-2" :message="form.errors.label" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<InputLabel for="points" value="Points" />
<TextInput id="points" type="number" class="mt-1 block w-full" v-model="form.points" required />
<InputError class="mt-2" :message="form.errors.points" />
</div>
<div>
<InputLabel for="type" value="Type" />
<select
id="type"
v-model="form.type"
class="mt-1 block w-full border-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm"
>
<option value="qcm">QCM</option>
<option value="open">Question Ouverte</option>
</select>
<InputError class="mt-2" :message="form.errors.type" />
</div>
</div>
<div v-if="form.type === 'qcm'" class="space-y-4">
<div class="flex justify-between items-center">
<InputLabel value="Options de réponse" />
<SecondaryButton type="button" @click="addOption" class="!py-1 !px-2 text-xs">
+ Ajouter
</SecondaryButton>
</div>
<div class="space-y-3">
<div v-for="(option, index) in form.options" :key="index" class="flex items-center gap-3">
<input
type="checkbox"
v-model="option.is_correct"
class="rounded dark:bg-slate-900 border-slate-300 dark:border-slate-700 text-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 shadow-sm"
/>
<TextInput
type="text"
class="block w-full text-sm"
v-model="option.option_text"
placeholder="Libellé de l'option"
required
/>
<button v-if="form.options.length > 2" @click="removeOption(index)" type="button" class="text-slate-400 hover:text-red-500">
<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>
</div>
<p class="text-[11px] text-slate-500 italic">Cochez les cases pour indiquer les bonnes réponses.</p>
</div>
<div class="flex justify-end gap-3 mt-8 pt-6 border-t border-slate-100 dark:border-slate-700">
<SecondaryButton @click="isModalOpen = false" :disabled="form.processing">
Annuler
</SecondaryButton>
<PrimaryButton :disabled="form.processing">
{{ editingQuestion ? 'Mettre à jour' : 'Enregistrer Question' }}
</PrimaryButton>
</div>
</form>
</div>
</Modal>
</AdminLayout>
</template>

View File

@@ -0,0 +1,55 @@
<script setup>
import GuestLayout from '@/Layouts/GuestLayout.vue';
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { Head, useForm } from '@inertiajs/vue3';
const form = useForm({
password: '',
});
const submit = () => {
form.post(route('password.confirm'), {
onFinish: () => form.reset(),
});
};
</script>
<template>
<GuestLayout>
<Head title="Confirm Password" />
<div class="mb-4 text-sm text-gray-600">
This is a secure area of the application. Please confirm your
password before continuing.
</div>
<form @submit.prevent="submit">
<div>
<InputLabel for="password" value="Password" />
<TextInput
id="password"
type="password"
class="mt-1 block w-full"
v-model="form.password"
required
autocomplete="current-password"
autofocus
/>
<InputError class="mt-2" :message="form.errors.password" />
</div>
<div class="mt-4 flex justify-end">
<PrimaryButton
class="ms-4"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
Confirm
</PrimaryButton>
</div>
</form>
</GuestLayout>
</template>

View File

@@ -0,0 +1,68 @@
<script setup>
import GuestLayout from '@/Layouts/GuestLayout.vue';
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { Head, useForm } from '@inertiajs/vue3';
defineProps({
status: {
type: String,
},
});
const form = useForm({
email: '',
});
const submit = () => {
form.post(route('password.email'));
};
</script>
<template>
<GuestLayout>
<Head title="Forgot Password" />
<div class="mb-4 text-sm text-gray-600">
Forgot your password? No problem. Just let us know your email
address and we will email you a password reset link that will allow
you to choose a new one.
</div>
<div
v-if="status"
class="mb-4 text-sm font-medium text-green-600"
>
{{ status }}
</div>
<form @submit.prevent="submit">
<div>
<InputLabel for="email" value="Email" />
<TextInput
id="email"
type="email"
class="mt-1 block w-full"
v-model="form.email"
required
autofocus
autocomplete="username"
/>
<InputError class="mt-2" :message="form.errors.email" />
</div>
<div class="mt-4 flex items-center justify-end">
<PrimaryButton
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
Email Password Reset Link
</PrimaryButton>
</div>
</form>
</GuestLayout>
</template>

View File

@@ -0,0 +1,100 @@
<script setup>
import Checkbox from '@/Components/Checkbox.vue';
import GuestLayout from '@/Layouts/GuestLayout.vue';
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
defineProps({
canResetPassword: {
type: Boolean,
},
status: {
type: String,
},
});
const form = useForm({
email: '',
password: '',
remember: false,
});
const submit = () => {
form.post(route('login'), {
onFinish: () => form.reset('password'),
});
};
</script>
<template>
<GuestLayout>
<Head title="Log in" />
<div v-if="status" class="mb-4 text-sm font-medium text-green-600">
{{ status }}
</div>
<form @submit.prevent="submit">
<div>
<InputLabel for="email" value="Email" />
<TextInput
id="email"
type="email"
class="mt-1 block w-full"
v-model="form.email"
required
autofocus
autocomplete="username"
/>
<InputError class="mt-2" :message="form.errors.email" />
</div>
<div class="mt-4">
<InputLabel for="password" value="Password" />
<TextInput
id="password"
type="password"
class="mt-1 block w-full"
v-model="form.password"
required
autocomplete="current-password"
/>
<InputError class="mt-2" :message="form.errors.password" />
</div>
<div class="mt-4 block">
<label class="flex items-center">
<Checkbox name="remember" v-model:checked="form.remember" />
<span class="ms-2 text-sm text-gray-600"
>Remember me</span
>
</label>
</div>
<div class="mt-4 flex items-center justify-end">
<Link
v-if="canResetPassword"
:href="route('password.request')"
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Forgot your password?
</Link>
<PrimaryButton
class="ms-4"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
Log in
</PrimaryButton>
</div>
</form>
</GuestLayout>
</template>

View File

@@ -0,0 +1,113 @@
<script setup>
import GuestLayout from '@/Layouts/GuestLayout.vue';
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
const form = useForm({
name: '',
email: '',
password: '',
password_confirmation: '',
});
const submit = () => {
form.post(route('register'), {
onFinish: () => form.reset('password', 'password_confirmation'),
});
};
</script>
<template>
<GuestLayout>
<Head title="Register" />
<form @submit.prevent="submit">
<div>
<InputLabel for="name" value="Name" />
<TextInput
id="name"
type="text"
class="mt-1 block w-full"
v-model="form.name"
required
autofocus
autocomplete="name"
/>
<InputError class="mt-2" :message="form.errors.name" />
</div>
<div class="mt-4">
<InputLabel for="email" value="Email" />
<TextInput
id="email"
type="email"
class="mt-1 block w-full"
v-model="form.email"
required
autocomplete="username"
/>
<InputError class="mt-2" :message="form.errors.email" />
</div>
<div class="mt-4">
<InputLabel for="password" value="Password" />
<TextInput
id="password"
type="password"
class="mt-1 block w-full"
v-model="form.password"
required
autocomplete="new-password"
/>
<InputError class="mt-2" :message="form.errors.password" />
</div>
<div class="mt-4">
<InputLabel
for="password_confirmation"
value="Confirm Password"
/>
<TextInput
id="password_confirmation"
type="password"
class="mt-1 block w-full"
v-model="form.password_confirmation"
required
autocomplete="new-password"
/>
<InputError
class="mt-2"
:message="form.errors.password_confirmation"
/>
</div>
<div class="mt-4 flex items-center justify-end">
<Link
:href="route('login')"
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Already registered?
</Link>
<PrimaryButton
class="ms-4"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
Register
</PrimaryButton>
</div>
</form>
</GuestLayout>
</template>

View File

@@ -0,0 +1,101 @@
<script setup>
import GuestLayout from '@/Layouts/GuestLayout.vue';
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { Head, useForm } from '@inertiajs/vue3';
const props = defineProps({
email: {
type: String,
required: true,
},
token: {
type: String,
required: true,
},
});
const form = useForm({
token: props.token,
email: props.email,
password: '',
password_confirmation: '',
});
const submit = () => {
form.post(route('password.store'), {
onFinish: () => form.reset('password', 'password_confirmation'),
});
};
</script>
<template>
<GuestLayout>
<Head title="Reset Password" />
<form @submit.prevent="submit">
<div>
<InputLabel for="email" value="Email" />
<TextInput
id="email"
type="email"
class="mt-1 block w-full"
v-model="form.email"
required
autofocus
autocomplete="username"
/>
<InputError class="mt-2" :message="form.errors.email" />
</div>
<div class="mt-4">
<InputLabel for="password" value="Password" />
<TextInput
id="password"
type="password"
class="mt-1 block w-full"
v-model="form.password"
required
autocomplete="new-password"
/>
<InputError class="mt-2" :message="form.errors.password" />
</div>
<div class="mt-4">
<InputLabel
for="password_confirmation"
value="Confirm Password"
/>
<TextInput
id="password_confirmation"
type="password"
class="mt-1 block w-full"
v-model="form.password_confirmation"
required
autocomplete="new-password"
/>
<InputError
class="mt-2"
:message="form.errors.password_confirmation"
/>
</div>
<div class="mt-4 flex items-center justify-end">
<PrimaryButton
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
Reset Password
</PrimaryButton>
</div>
</form>
</GuestLayout>
</template>

View File

@@ -0,0 +1,61 @@
<script setup>
import { computed } from 'vue';
import GuestLayout from '@/Layouts/GuestLayout.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
const props = defineProps({
status: {
type: String,
},
});
const form = useForm({});
const submit = () => {
form.post(route('verification.send'));
};
const verificationLinkSent = computed(
() => props.status === 'verification-link-sent',
);
</script>
<template>
<GuestLayout>
<Head title="Email Verification" />
<div class="mb-4 text-sm text-gray-600">
Thanks for signing up! Before getting started, could you verify your
email address by clicking on the link we just emailed to you? If you
didn't receive the email, we will gladly send you another.
</div>
<div
class="mb-4 text-sm font-medium text-green-600"
v-if="verificationLinkSent"
>
A new verification link has been sent to the email address you
provided during registration.
</div>
<form @submit.prevent="submit">
<div class="mt-4 flex items-center justify-between">
<PrimaryButton
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
>
Resend Verification Email
</PrimaryButton>
<Link
:href="route('logout')"
method="post"
as="button"
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>Log Out</Link
>
</div>
</form>
</GuestLayout>
</template>

View File

@@ -0,0 +1,262 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { Head, router } from '@inertiajs/vue3';
import axios from 'axios';
const props = defineProps({
quiz: Object,
attempt: Object
});
const currentQuestionIndex = ref(0);
const answers = ref({});
const timeLeft = ref(props.quiz.duration_minutes * 60);
let timer = null;
// Initialize answers from existing attempt answers if any
onMounted(() => {
// Initialize all questions with empty answers to avoid v-model errors
props.quiz.questions.forEach(q => {
answers.value[q.id] = { option_id: null, text_content: '' };
});
// Populate with existing answers
props.attempt.answers.forEach(ans => {
answers.value[ans.question_id] = {
option_id: ans.option_id,
text_content: ans.text_content || ''
};
});
startTimer();
});
const startTimer = () => {
// Calculate elapsed time since start
const startTime = new Date(props.attempt.started_at).getTime();
const now = new Date().getTime();
const elapsedSeconds = Math.floor((now - startTime) / 1000);
timeLeft.value = Math.max(0, (props.quiz.duration_minutes * 60) - elapsedSeconds);
timer = setInterval(() => {
if (timeLeft.value > 0) {
timeLeft.value--;
} else {
finishQuiz();
}
}, 1000);
};
onUnmounted(() => {
if (timer) clearInterval(timer);
});
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const currentQuestion = computed(() => props.quiz.questions[currentQuestionIndex.value]);
const progress = computed(() => ((currentQuestionIndex.value + 1) / props.quiz.questions.length) * 100);
const saveAnswer = async () => {
const qid = currentQuestion.value.id;
const ans = answers.value[qid] || {};
try {
await axios.post(route('attempts.save', props.attempt.id), {
question_id: qid,
...ans
});
} catch (e) {
console.error('Save failed', e);
}
};
const selectOption = (optionId) => {
answers.value[currentQuestion.value.id] = {
option_id: optionId,
text_content: null
};
saveAnswer();
};
const updateOpenAnswer = (text) => {
answers.value[currentQuestion.value.id] = {
option_id: null,
text_content: text
};
// Debounce save for text content in a real app, here we just save on "Next" or small timeout
};
const nextQuestion = () => {
if (currentQuestionIndex.value < props.quiz.questions.length - 1) {
currentQuestionIndex.value++;
} else {
finishQuiz();
}
};
const prevQuestion = () => {
if (currentQuestionIndex.value > 0) {
currentQuestionIndex.value--;
}
};
const finishQuiz = () => {
if (timer) clearInterval(timer);
router.post(route('attempts.finish', props.attempt.id));
};
</script>
<template>
<Head :title="quiz.title" />
<div class="min-h-screen bg-slate-900 text-slate-100 flex flex-col font-sans selection:bg-indigo-500/30">
<!-- Header -->
<header class="h-20 flex items-center justify-between px-8 border-b border-slate-800 bg-slate-900/80 backdrop-blur sticky top-0 z-10">
<div class="flex items-center gap-4">
<div class="w-10 h-10 bg-indigo-600 rounded-lg flex items-center justify-center font-bold text-xl shadow-[0_0_15px_rgba(79,70,229,0.4)]">
Q
</div>
<div>
<h1 class="font-bold text-lg leading-none">{{ quiz.title }}</h1>
<p class="text-slate-500 text-xs mt-1 uppercase tracking-widest font-semibold">{{ currentQuestionIndex + 1 }} sur {{ quiz.questions.length }}</p>
</div>
</div>
<div class="flex items-center gap-6">
<!-- Progress Circular (CSS only simplification) -->
<div class="hidden sm:flex items-center gap-2">
<span class="text-xs text-slate-500 font-bold uppercase tracking-wider">Progression</span>
<div class="w-32 h-2 bg-slate-800 rounded-full overflow-hidden">
<div class="h-full bg-indigo-500 transition-all duration-500" :style="{ width: progress + '%' }"></div>
</div>
</div>
<div class="flex items-center gap-2 bg-slate-800/50 px-4 py-2 rounded-xl border border-slate-700 shadow-inner">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-indigo-400" 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>
<span class="font-mono text-xl font-bold" :class="{ 'text-red-400 animate-pulse': timeLeft < 300 }">
{{ formatTime(timeLeft) }}
</span>
</div>
</div>
</header>
<!-- Main Content -->
<main class="flex-1 flex flex-col items-center justify-center p-8">
<div class="w-full max-w-3xl">
<!-- Question Card -->
<div class="bg-slate-800/50 backdrop-blur-xl rounded-3xl p-8 sm:p-12 border border-slate-700 shadow-2xl relative overflow-hidden group">
<!-- Subtle background glow -->
<div class="absolute -top-24 -right-24 w-64 h-64 bg-indigo-600/10 blur-[100px] rounded-full group-hover:bg-indigo-600/20 transition-all duration-700"></div>
<div class="relative z-10">
<div class="mb-8">
<div v-if="currentQuestion.title" class="px-3 py-1 bg-indigo-500/20 text-indigo-400 border border-indigo-500/30 rounded-lg text-xs font-black uppercase tracking-widest inline-block mb-4">
{{ currentQuestion.title }}
</div>
<h2 class="text-2xl sm:text-3xl font-bold leading-tight">
{{ currentQuestion.label }}
</h2>
<div v-if="currentQuestion.context" class="mt-6 p-6 bg-slate-700/20 border-l-4 border-indigo-500 rounded-r-2xl text-slate-300 leading-relaxed italic">
{{ currentQuestion.context }}
</div>
</div>
<!-- QCM Options -->
<div v-if="currentQuestion.type === 'qcm'" class="grid gap-4">
<button
v-for="option in currentQuestion.options"
:key="option.id"
@click="selectOption(option.id)"
class="w-full text-left p-6 rounded-2xl border transition-all duration-200 flex items-center justify-between group/opt"
:class="[
answers[currentQuestion.id]?.option_id === option.id
? 'bg-indigo-600 border-indigo-400 shadow-lg shadow-indigo-600/20 translate-x-1'
: 'bg-slate-700/30 border-slate-600 hover:border-slate-400 hover:bg-slate-700/50'
]"
>
<span class="font-medium text-lg">{{ option.option_text }}</span>
<div
class="w-6 h-6 rounded-full border-2 flex items-center justify-center transition-colors"
:class="[
answers[currentQuestion.id]?.option_id === option.id
? 'border-white bg-white'
: 'border-slate-500 group-hover/opt:border-slate-300'
]"
>
<div v-if="answers[currentQuestion.id]?.option_id === option.id" class="w-2.5 h-2.5 bg-indigo-600 rounded-full"></div>
</div>
</button>
</div>
<!-- Open Question -->
<div v-else>
<textarea
class="w-full h-48 bg-slate-700/30 border border-slate-600 rounded-2xl p-6 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all outline-none resize-none text-lg"
placeholder="Saisissez votre réponse ici..."
v-model="answers[currentQuestion.id].text_content"
@blur="saveAnswer"
></textarea>
</div>
</div>
</div>
<!-- Navigation Controls -->
<div class="mt-12 flex justify-between items-center px-4">
<button
@click="prevQuestion"
:disabled="currentQuestionIndex === 0"
class="flex items-center gap-2 font-bold uppercase tracking-widest text-sm text-slate-400 hover:text-white transition-colors disabled:opacity-30 disabled:pointer-events-none"
>
<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 19l-7-7 7-7" />
</svg>
Précédent
</button>
<div class="flex items-center gap-1">
<div
v-for="(_, i) in quiz.questions"
:key="i"
class="w-1.5 h-1.5 rounded-full transition-all duration-300"
:class="[i === currentQuestionIndex ? 'bg-indigo-500 w-6' : (answers[quiz.questions[i].id] ? 'bg-slate-500' : 'bg-slate-700')]"
></div>
</div>
<button
@click="nextQuestion"
class="group flex items-center gap-3 bg-white text-slate-900 px-8 py-4 rounded-2xl font-bold uppercase tracking-widest text-sm hover:bg-indigo-500 hover:text-white transition-all duration-300 active:scale-95 shadow-xl shadow-white/5"
>
{{ currentQuestionIndex === quiz.questions.length - 1 ? 'Terminer' : 'Suivant' }}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</main>
<!-- Footer / Feedback -->
<footer class="h-12 flex items-center px-8 text-[10px] text-slate-600 uppercase tracking-[0.2em] font-bold">
RecruitQuizz Pro v1.0 Vos réponses sont sauvegardées automatiquement.
</footer>
</div>
</template>
<style scoped>
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
</style>

View File

@@ -0,0 +1,33 @@
<script setup>
import { Head, Link } from '@inertiajs/vue3';
</script>
<template>
<Head title="Merci !" />
<div class="min-h-screen bg-slate-900 text-slate-100 flex flex-col items-center justify-center p-8 selection:bg-indigo-500/30">
<div class="max-w-xl text-center">
<div class="w-24 h-24 bg-emerald-500/10 border border-emerald-500/20 text-emerald-500 rounded-full flex items-center justify-center mx-auto mb-8 shadow-[0_0_50px_rgba(16,185,129,0.2)] scale-110">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 class="text-4xl font-black mb-4 tracking-tight">Merci pour votre participation !</h1>
<p class="text-slate-400 text-lg mb-12">
Votre test technique a été validé avec succès. Notre équipe va maintenant analyser vos résultats et reviendra vers vous dès que possible.
</p>
<Link
:href="route('dashboard')"
class="inline-block bg-white text-slate-900 px-10 py-4 rounded-2xl font-black uppercase tracking-[0.2em] text-sm hover:bg-emerald-500 hover:text-white transition-all duration-500 shadow-xl shadow-white/5 active:scale-95"
>
Retour à l'accueil
</Link>
</div>
<footer class="mt-20 text-[10px] text-slate-600 uppercase tracking-[0.4em] font-bold">
RecruitQuizz Pro Système de recrutement automatisé
</footer>
</div>
</template>

View File

@@ -0,0 +1,91 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import AdminLayout from '@/Layouts/AdminLayout.vue';
import { Head, usePage, Link } from '@inertiajs/vue3';
import { computed } from 'vue';
const props = defineProps({
stats: Object,
quizzes: Array
});
const page = usePage();
const user = computed(() => page.props.auth.user);
const layout = computed(() => user.value.role === 'admin' ? AdminLayout : AuthenticatedLayout);
</script>
<template>
<Head title="Tableau de bord" />
<component :is="layout">
<template #header>
<h2 class="text-xl font-semibold leading-tight capitalize">
Tableau de bord {{ user.role }}
</h2>
</template>
<div v-if="user.role === 'admin'" class="space-y-6">
<!-- 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-2xl shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-lg transition-all duration-300">
<div class="text-slate-500 dark:text-slate-400 text-xs font-black uppercase tracking-widest">Total Candidats</div>
<div class="text-4xl font-black mt-2 text-indigo-600 dark:text-indigo-400">{{ stats.total_candidates }}</div>
</div>
<div class="bg-white dark:bg-slate-800 p-6 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-lg transition-all duration-300">
<div class="text-slate-500 dark:text-slate-400 text-xs font-black uppercase tracking-widest">Tests terminés</div>
<div class="text-4xl font-black mt-2 text-emerald-600 dark:text-emerald-400">{{ stats.finished_tests }}</div>
</div>
<div class="bg-white dark:bg-slate-800 p-6 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-lg transition-all duration-300">
<div class="text-slate-500 dark:text-slate-400 text-xs font-black uppercase tracking-widest">Moyenne Générale</div>
<div class="text-4xl font-black mt-2 text-blue-600 dark:text-blue-400">{{ stats.average_score }}</div>
</div>
<div class="bg-white dark:bg-slate-800 p-6 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-lg transition-all duration-300">
<div class="text-slate-500 dark:text-slate-400 text-xs font-black uppercase tracking-widest">Meilleur Score</div>
<div class="text-4xl font-black mt-2 text-amber-600 dark:text-amber-400">{{ stats.best_score }}</div>
</div>
</div>
<div class="bg-white dark:bg-slate-800 p-6 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700">
<p>Bienvenue dans votre espace d'administration.</p>
</div>
</div>
<div v-else class="py-12">
<div class="mx-auto max-w-3xl sm:px-6 lg:px-8">
<div class="bg-white dark:bg-slate-800 p-12 rounded-3xl shadow-xl border border-slate-200 dark:border-slate-700 text-center">
<h3 class="text-3xl font-black mb-4">Bienvenue, {{ user.name }} !</h3>
<p class="text-slate-500 dark:text-slate-400 mb-12">
Veuillez sélectionner le test technique auquel vous avez été invité.
Prenez le temps de vous installer confortablement avant de commencer.
</p>
<div v-if="quizzes.length > 0" class="space-y-4">
<div v-for="quiz in quizzes" :key="quiz.id" class="p-6 bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-2xl flex flex-col sm:flex-row items-center justify-between gap-6 group hover:border-indigo-500 transition-all duration-300">
<div class="text-left flex-1">
<h4 class="text-xl font-bold group-hover:text-indigo-600 transition-colors">{{ quiz.title }}</h4>
<p class="text-sm text-slate-500 mt-1">{{ quiz.duration_minutes }} minutes • {{ quiz.description }}</p>
</div>
<div v-if="quiz.has_finished_attempt" class="flex items-center gap-2 px-6 py-3 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-xl text-emerald-600 dark:text-emerald-400 font-bold whitespace-nowrap">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Test effectué
</div>
<Link
v-else
:href="route('quizzes.take', quiz.id)"
class="bg-indigo-600 text-white px-8 py-3 rounded-xl font-bold hover:bg-slate-900 dark:hover:bg-white dark:hover:text-slate-900 transition-all duration-300 shadow-lg shadow-indigo-600/20 active:scale-95 whitespace-nowrap"
>
Démarrer le test
</Link>
</div>
</div>
<div v-else class="py-12 text-slate-500 italic">
Aucun test ne vous est assigné pour le moment.
</div>
</div>
</div>
</div>
</component>
</template>

View File

@@ -0,0 +1,56 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import DeleteUserForm from './Partials/DeleteUserForm.vue';
import UpdatePasswordForm from './Partials/UpdatePasswordForm.vue';
import UpdateProfileInformationForm from './Partials/UpdateProfileInformationForm.vue';
import { Head } from '@inertiajs/vue3';
defineProps({
mustVerifyEmail: {
type: Boolean,
},
status: {
type: String,
},
});
</script>
<template>
<Head title="Profile" />
<AuthenticatedLayout>
<template #header>
<h2
class="text-xl font-semibold leading-tight text-gray-800"
>
Profile
</h2>
</template>
<div class="py-12">
<div class="mx-auto max-w-7xl space-y-6 sm:px-6 lg:px-8">
<div
class="bg-white p-4 shadow sm:rounded-lg sm:p-8"
>
<UpdateProfileInformationForm
:must-verify-email="mustVerifyEmail"
:status="status"
class="max-w-xl"
/>
</div>
<div
class="bg-white p-4 shadow sm:rounded-lg sm:p-8"
>
<UpdatePasswordForm class="max-w-xl" />
</div>
<div
class="bg-white p-4 shadow sm:rounded-lg sm:p-8"
>
<DeleteUserForm class="max-w-xl" />
</div>
</div>
</div>
</AuthenticatedLayout>
</template>

View File

@@ -0,0 +1,108 @@
<script setup>
import DangerButton from '@/Components/DangerButton.vue';
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import Modal from '@/Components/Modal.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { useForm } from '@inertiajs/vue3';
import { nextTick, ref } from 'vue';
const confirmingUserDeletion = ref(false);
const passwordInput = ref(null);
const form = useForm({
password: '',
});
const confirmUserDeletion = () => {
confirmingUserDeletion.value = true;
nextTick(() => passwordInput.value.focus());
};
const deleteUser = () => {
form.delete(route('profile.destroy'), {
preserveScroll: true,
onSuccess: () => closeModal(),
onError: () => passwordInput.value.focus(),
onFinish: () => form.reset(),
});
};
const closeModal = () => {
confirmingUserDeletion.value = false;
form.clearErrors();
form.reset();
};
</script>
<template>
<section class="space-y-6">
<header>
<h2 class="text-lg font-medium text-gray-900">
Delete Account
</h2>
<p class="mt-1 text-sm text-gray-600">
Once your account is deleted, all of its resources and data will
be permanently deleted. Before deleting your account, please
download any data or information that you wish to retain.
</p>
</header>
<DangerButton @click="confirmUserDeletion">Delete Account</DangerButton>
<Modal :show="confirmingUserDeletion" @close="closeModal">
<div class="p-6">
<h2
class="text-lg font-medium text-gray-900"
>
Are you sure you want to delete your account?
</h2>
<p class="mt-1 text-sm text-gray-600">
Once your account is deleted, all of its resources and data
will be permanently deleted. Please enter your password to
confirm you would like to permanently delete your account.
</p>
<div class="mt-6">
<InputLabel
for="password"
value="Password"
class="sr-only"
/>
<TextInput
id="password"
ref="passwordInput"
v-model="form.password"
type="password"
class="mt-1 block w-3/4"
placeholder="Password"
@keyup.enter="deleteUser"
/>
<InputError :message="form.errors.password" class="mt-2" />
</div>
<div class="mt-6 flex justify-end">
<SecondaryButton @click="closeModal">
Cancel
</SecondaryButton>
<DangerButton
class="ms-3"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
@click="deleteUser"
>
Delete Account
</DangerButton>
</div>
</div>
</Modal>
</section>
</template>

View File

@@ -0,0 +1,122 @@
<script setup>
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
const passwordInput = ref(null);
const currentPasswordInput = ref(null);
const form = useForm({
current_password: '',
password: '',
password_confirmation: '',
});
const updatePassword = () => {
form.put(route('password.update'), {
preserveScroll: true,
onSuccess: () => form.reset(),
onError: () => {
if (form.errors.password) {
form.reset('password', 'password_confirmation');
passwordInput.value.focus();
}
if (form.errors.current_password) {
form.reset('current_password');
currentPasswordInput.value.focus();
}
},
});
};
</script>
<template>
<section>
<header>
<h2 class="text-lg font-medium text-gray-900">
Update Password
</h2>
<p class="mt-1 text-sm text-gray-600">
Ensure your account is using a long, random password to stay
secure.
</p>
</header>
<form @submit.prevent="updatePassword" class="mt-6 space-y-6">
<div>
<InputLabel for="current_password" value="Current Password" />
<TextInput
id="current_password"
ref="currentPasswordInput"
v-model="form.current_password"
type="password"
class="mt-1 block w-full"
autocomplete="current-password"
/>
<InputError
:message="form.errors.current_password"
class="mt-2"
/>
</div>
<div>
<InputLabel for="password" value="New Password" />
<TextInput
id="password"
ref="passwordInput"
v-model="form.password"
type="password"
class="mt-1 block w-full"
autocomplete="new-password"
/>
<InputError :message="form.errors.password" class="mt-2" />
</div>
<div>
<InputLabel
for="password_confirmation"
value="Confirm Password"
/>
<TextInput
id="password_confirmation"
v-model="form.password_confirmation"
type="password"
class="mt-1 block w-full"
autocomplete="new-password"
/>
<InputError
:message="form.errors.password_confirmation"
class="mt-2"
/>
</div>
<div class="flex items-center gap-4">
<PrimaryButton :disabled="form.processing">Save</PrimaryButton>
<Transition
enter-active-class="transition ease-in-out"
enter-from-class="opacity-0"
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p
v-if="form.recentlySuccessful"
class="text-sm text-gray-600"
>
Saved.
</p>
</Transition>
</div>
</form>
</section>
</template>

View File

@@ -0,0 +1,112 @@
<script setup>
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { Link, useForm, usePage } from '@inertiajs/vue3';
defineProps({
mustVerifyEmail: {
type: Boolean,
},
status: {
type: String,
},
});
const user = usePage().props.auth.user;
const form = useForm({
name: user.name,
email: user.email,
});
</script>
<template>
<section>
<header>
<h2 class="text-lg font-medium text-gray-900">
Profile Information
</h2>
<p class="mt-1 text-sm text-gray-600">
Update your account's profile information and email address.
</p>
</header>
<form
@submit.prevent="form.patch(route('profile.update'))"
class="mt-6 space-y-6"
>
<div>
<InputLabel for="name" value="Name" />
<TextInput
id="name"
type="text"
class="mt-1 block w-full"
v-model="form.name"
required
autofocus
autocomplete="name"
/>
<InputError class="mt-2" :message="form.errors.name" />
</div>
<div>
<InputLabel for="email" value="Email" />
<TextInput
id="email"
type="email"
class="mt-1 block w-full"
v-model="form.email"
required
autocomplete="username"
/>
<InputError class="mt-2" :message="form.errors.email" />
</div>
<div v-if="mustVerifyEmail && user.email_verified_at === null">
<p class="mt-2 text-sm text-gray-800">
Your email address is unverified.
<Link
:href="route('verification.send')"
method="post"
as="button"
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Click here to re-send the verification email.
</Link>
</p>
<div
v-show="status === 'verification-link-sent'"
class="mt-2 text-sm font-medium text-green-600"
>
A new verification link has been sent to your email address.
</div>
</div>
<div class="flex items-center gap-4">
<PrimaryButton :disabled="form.processing">Save</PrimaryButton>
<Transition
enter-active-class="transition ease-in-out"
enter-from-class="opacity-0"
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p
v-if="form.recentlySuccessful"
class="text-sm text-gray-600"
>
Saved.
</p>
</Transition>
</div>
</form>
</section>
</template>

View File

@@ -0,0 +1,185 @@
<script setup>
import { Head, Link } from '@inertiajs/vue3';
defineProps({
canLogin: Boolean,
canRegister: Boolean,
});
</script>
<template>
<Head title="Bienvenue sur RecruitQuizz" />
<div class="min-h-screen bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100 selection:bg-indigo-500 selection:text-white font-sans overflow-x-hidden">
<!-- Animated Background Blobs -->
<div class="fixed inset-0 pointer-events-none overflow-hidden">
<div class="absolute -top-[10%] -left-[10%] w-[40%] h-[40%] bg-indigo-500/10 dark:bg-indigo-500/5 rounded-full blur-[120px] animate-pulse"></div>
<div class="absolute top-[20%] -right-[10%] w-[35%] h-[35%] bg-purple-500/10 dark:bg-purple-500/5 rounded-full blur-[120px] animate-pulse" style="animation-delay: 2s;"></div>
<div class="absolute -bottom-[10%] left-[20%] w-[30%] h-[30%] bg-emerald-500/10 dark:bg-emerald-500/5 rounded-full blur-[120px] animate-pulse" style="animation-delay: 4s;"></div>
</div>
<!-- Navigation -->
<nav class="relative z-50 flex items-center justify-between px-6 py-8 md:px-12 max-w-7xl mx-auto">
<div class="flex items-center gap-2 group cursor-default">
<div class="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-indigo-600/20 group-hover:scale-110 transition-transform duration-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9l-.707.707M12 18v3m4.95-4.95l.707.707M12 3c-4.418 0-8 3.582-8 8 0 2.209.895 4.209 2.343 5.657L12 21l5.657-5.343A7.994 7.994 0 0020 11c0-4.418-3.582-8-8-8z" />
</svg>
</div>
<span class="text-2xl font-black tracking-tighter uppercase italic text-slate-900 dark:text-white">RECRU<span class="text-indigo-600">IT</span></span>
</div>
<div class="flex items-center gap-4">
<template v-if="$page.props.auth.user">
<Link :href="route('dashboard')" class="px-6 py-2.5 bg-slate-900 dark:bg-white text-white dark:text-slate-900 rounded-full font-bold text-sm hover:scale-105 transition-all shadow-xl shadow-slate-900/10 dark:shadow-none">
Aller au Dashboard
</Link>
</template>
<template v-else>
<Link :href="route('login')" class="text-slate-600 dark:text-slate-400 font-bold text-sm hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors hidden md:block px-4">
Connexion
</Link>
<Link
v-if="canRegister"
:href="route('register')"
class="px-8 py-3 bg-indigo-600 text-white rounded-full font-bold text-sm hover:bg-indigo-700 hover:scale-105 hover:shadow-2xl hover:shadow-indigo-600/30 transition-all duration-300"
>
Créer un compte
</Link>
</template>
</div>
</nav>
<!-- Hero Section -->
<main class="relative z-10 max-w-7xl mx-auto px-6 pt-20 pb-32 md:px-12 md:pt-32">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-16 items-center">
<!-- Hero Content -->
<div class="lg:col-span-7 space-y-10">
<div class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-100 dark:border-indigo-800 rounded-full text-indigo-600 dark:text-indigo-400 text-xs font-black uppercase tracking-widest animate-bounce">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-indigo-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-indigo-500"></span>
</span>
Tests de recrutements
</div>
<h1 class="text-6xl md:text-8xl font-black tracking-tight leading-[0.9] text-slate-900 dark:text-white">
Evualuation <br>
<span class="text-transparent bg-clip-text bg-gradient-to-r from-indigo-600 to-purple-600">des candidats.</span>
</h1>
<p class="text-xl text-slate-600 dark:text-slate-400 max-w-xl leading-relaxed">
Recru.IT transforme le processus de sélection technique. Testez les candidats avec des parcours personnalisés et des évaluations précises en quelques minutes.
</p>
<div class="flex flex-col sm:flex-row items-center gap-6">
<Link
:href="route('login')"
class="group relative w-full sm:w-auto px-10 py-5 bg-slate-900 dark:bg-white text-white dark:text-slate-900 rounded-3xl font-black uppercase tracking-widest text-sm text-center overflow-hidden hover:scale-105 transition-all duration-300"
>
<span class="relative z-10">Démarrer maintenant</span>
<div class="absolute inset-0 bg-gradient-to-r from-indigo-600 to-purple-600 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
</Link>
<div class="flex -space-x-4">
<img class="w-12 h-12 rounded-full border-4 border-white dark:border-slate-950 shadow-xl" src="https://i.pravatar.cc/150?u=1" alt="User 1">
<img class="w-12 h-12 rounded-full border-4 border-white dark:border-slate-950 shadow-xl" src="https://i.pravatar.cc/150?u=2" alt="User 2">
<img class="w-12 h-12 rounded-full border-4 border-white dark:border-slate-950 shadow-xl" src="https://i.pravatar.cc/150?u=3" alt="User 3">
<div class="w-12 h-12 rounded-full border-4 border-white dark:border-slate-950 bg-indigo-600 flex items-center justify-center text-white text-xs font-bold shadow-xl">
:)
</div>
</div>
<p class="text-xs font-bold text-slate-400"></p>
</div>
</div>
<!-- Hero Illustration / Mockup -->
<div class="lg:col-span-5 relative hidden lg:block">
<div class="absolute -inset-4 bg-gradient-to-tr from-indigo-600 to-purple-600 rounded-[4rem] blur-3xl opacity-20 animate-pulse"></div>
<div class="relative bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-[3rem] shadow-2xl overflow-hidden aspect-[4/5] p-2">
<div class="bg-slate-50 dark:bg-slate-950 rounded-[2.5rem] h-full w-full p-8 border border-slate-100 dark:border-slate-800 flex flex-col justify-center gap-12 text-center">
<div class="w-24 h-24 bg-indigo-600/10 rounded-3xl mx-auto flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="space-y-4">
<div class="text-3xl font-black uppercase tracking-tighter">Félicitations !</div>
<p class="text-slate-500 text-sm">Votre score est de 95%. <br> Vous êtes prêt pour la suite.</p>
</div>
<div class="flex flex-col gap-3">
<div class="h-4 w-full bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden">
<div class="h-full w-[95%] bg-indigo-600 rounded-full shadow-lg shadow-indigo-600/30"></div>
</div>
<div class="flex justify-between text-[10px] font-black uppercase text-slate-400">
<span>Rang S+</span>
<span>Recruté</span>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Features Grid -->
<section class="relative z-10 bg-white dark:bg-slate-900 border-y border-slate-200 dark:border-slate-800 px-6 py-24 md:px-12">
<div class="max-w-7xl mx-auto">
<div class="text-center mb-20 space-y-4">
<h2 class="text-4xl font-black uppercase tracking-tight text-slate-900 dark:text-white">Conçu pour les recruteurs</h2>
<p class="text-slate-500 dark:text-slate-400 max-w-xl mx-auto">Une plateforme intuitive pour automatiser vos entretiens techniques et valoriser le potentiel de chaque candidat.</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<!-- Feature 1 -->
<div class="p-10 bg-slate-50 dark:bg-slate-950 rounded-[2.5rem] border border-slate-100 dark:border-slate-800 hover:border-indigo-500 transition-colors group">
<div class="w-14 h-14 bg-indigo-600/10 rounded-2xl flex items-center justify-center mb-8 group-hover:scale-110 transition-transform">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
</div>
<h3 class="text-xl font-bold mb-4">Quiz Dynamiques</h3>
<p class="text-slate-500 dark:text-slate-400 text-sm leading-relaxed">Questions à choix multiples ou réponses ouvertes, adaptez vos tests au poste visé en quelques clics.</p>
</div>
<!-- Feature 2 -->
<div class="p-10 bg-slate-50 dark:bg-slate-950 rounded-[2.5rem] border border-slate-100 dark:border-slate-800 hover:border-indigo-500 transition-colors group">
<div class="w-14 h-14 bg-emerald-600/10 rounded-2xl flex items-center justify-center mb-8 group-hover:scale-110 transition-transform">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<h3 class="text-xl font-bold mb-4">Audit & Sécurité</h3>
<p class="text-slate-500 dark:text-slate-400 text-sm leading-relaxed">Chaque action critique est journalisée pour une transparence totale sur vos recrutements.</p>
</div>
<!-- Feature 3 -->
<div class="p-10 bg-slate-50 dark:bg-slate-950 rounded-[2.5rem] border border-slate-100 dark:border-slate-800 hover:border-indigo-500 transition-colors group">
<div class="w-14 h-14 bg-purple-600/10 rounded-2xl flex items-center justify-center mb-8 group-hover:scale-110 transition-transform">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<h3 class="text-xl font-bold mb-4">Mobile First</h3>
<p class="text-slate-500 dark:text-slate-400 text-sm leading-relaxed">Les candidats passent leurs tests sur mobile ou desktop avec un confort inégalé.</p>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer class="relative z-10 px-6 py-20 text-center text-slate-400 text-xs font-black uppercase tracking-[0.2em]">
&copy; 2026 RecruitQuizz Advanced Recruitment Intelligence
</footer>
</div>
</template>
<style>
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;700;900&display=swap');
.font-sans {
font-family: 'Outfit', sans-serif;
}
</style>