Initial commit
This commit is contained in:
250
resources/js/Pages/Admin/Candidates/Index.vue
Normal file
250
resources/js/Pages/Admin/Candidates/Index.vue
Normal 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>
|
||||
321
resources/js/Pages/Admin/Candidates/Show.vue
Normal file
321
resources/js/Pages/Admin/Candidates/Show.vue
Normal 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>
|
||||
124
resources/js/Pages/Admin/Comparative.vue
Normal file
124
resources/js/Pages/Admin/Comparative.vue
Normal 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>
|
||||
171
resources/js/Pages/Admin/Quizzes/Index.vue
Normal file
171
resources/js/Pages/Admin/Quizzes/Index.vue
Normal 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>
|
||||
289
resources/js/Pages/Admin/Quizzes/Show.vue
Normal file
289
resources/js/Pages/Admin/Quizzes/Show.vue
Normal 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>
|
||||
55
resources/js/Pages/Auth/ConfirmPassword.vue
Normal file
55
resources/js/Pages/Auth/ConfirmPassword.vue
Normal 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>
|
||||
68
resources/js/Pages/Auth/ForgotPassword.vue
Normal file
68
resources/js/Pages/Auth/ForgotPassword.vue
Normal 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>
|
||||
100
resources/js/Pages/Auth/Login.vue
Normal file
100
resources/js/Pages/Auth/Login.vue
Normal 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>
|
||||
113
resources/js/Pages/Auth/Register.vue
Normal file
113
resources/js/Pages/Auth/Register.vue
Normal 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>
|
||||
101
resources/js/Pages/Auth/ResetPassword.vue
Normal file
101
resources/js/Pages/Auth/ResetPassword.vue
Normal 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>
|
||||
61
resources/js/Pages/Auth/VerifyEmail.vue
Normal file
61
resources/js/Pages/Auth/VerifyEmail.vue
Normal 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>
|
||||
262
resources/js/Pages/Candidate/QuizInterface.vue
Normal file
262
resources/js/Pages/Candidate/QuizInterface.vue
Normal 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>
|
||||
33
resources/js/Pages/Candidate/Thanks.vue
Normal file
33
resources/js/Pages/Candidate/Thanks.vue
Normal 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>
|
||||
91
resources/js/Pages/Dashboard.vue
Normal file
91
resources/js/Pages/Dashboard.vue
Normal 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>
|
||||
56
resources/js/Pages/Profile/Edit.vue
Normal file
56
resources/js/Pages/Profile/Edit.vue
Normal 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>
|
||||
108
resources/js/Pages/Profile/Partials/DeleteUserForm.vue
Normal file
108
resources/js/Pages/Profile/Partials/DeleteUserForm.vue
Normal 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>
|
||||
122
resources/js/Pages/Profile/Partials/UpdatePasswordForm.vue
Normal file
122
resources/js/Pages/Profile/Partials/UpdatePasswordForm.vue
Normal 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>
|
||||
@@ -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>
|
||||
185
resources/js/Pages/Welcome.vue
Normal file
185
resources/js/Pages/Welcome.vue
Normal 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]">
|
||||
© 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>
|
||||
Reference in New Issue
Block a user