530 lines
35 KiB
Vue
530 lines
35 KiB
Vue
<script setup>
|
|
import AdminLayout from '@/Layouts/AdminLayout.vue';
|
|
import { Head, useForm, Link, usePage, router } from '@inertiajs/vue3';
|
|
import { ref, computed } from 'vue';
|
|
import axios from 'axios';
|
|
|
|
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,
|
|
jobPositions: Array,
|
|
tenants: Array
|
|
});
|
|
|
|
const isModalOpen = ref(false);
|
|
const selectedDocument = ref(null);
|
|
|
|
const form = useForm({
|
|
name: '',
|
|
email: '',
|
|
phone: '',
|
|
linkedin_url: '',
|
|
cv: null,
|
|
cover_letter: null,
|
|
tenant_id: '',
|
|
job_position_id: '',
|
|
});
|
|
|
|
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), { preserveScroll: true });
|
|
}
|
|
};
|
|
|
|
const toggleSelection = (id) => {
|
|
router.patch(route('admin.candidates.toggle-selection', id), {}, { preserveScroll: true });
|
|
};
|
|
|
|
const openPreview = (doc) => {
|
|
selectedDocument.value = doc;
|
|
};
|
|
|
|
// Sorting Logic
|
|
const sortKey = ref('ai_analysis.match_score');
|
|
const sortOrder = ref(-1); // 1 = asc, -1 = desc
|
|
|
|
const sortBy = (key) => {
|
|
if (sortKey.value === key) {
|
|
sortOrder.value *= -1;
|
|
} else {
|
|
sortKey.value = key;
|
|
sortOrder.value = 1;
|
|
}
|
|
};
|
|
|
|
const getNestedValue = (obj, path) => {
|
|
return path.split('.').reduce((o, i) => (o ? o[i] : null), obj);
|
|
};
|
|
|
|
const selectedJobPosition = ref('');
|
|
const showOnlySelected = ref(false);
|
|
|
|
const filteredCandidates = computed(() => {
|
|
let result = props.candidates;
|
|
|
|
if (showOnlySelected.value) {
|
|
result = result.filter(c => c.is_selected);
|
|
}
|
|
|
|
if (selectedJobPosition.value !== '') {
|
|
if (selectedJobPosition.value === 'none') {
|
|
result = result.filter(c => !c.job_position_id);
|
|
} else {
|
|
result = result.filter(c => c.job_position_id === selectedJobPosition.value);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
});
|
|
|
|
const sortedCandidates = computed(() => {
|
|
return [...filteredCandidates.value].sort((a, b) => {
|
|
let valA = getNestedValue(a, sortKey.value);
|
|
let valB = getNestedValue(b, sortKey.value);
|
|
|
|
if (typeof valA === 'string') valA = valA.toLowerCase();
|
|
if (typeof valB === 'string') valB = valB.toLowerCase();
|
|
|
|
if (valA < valB) return -1 * sortOrder.value;
|
|
if (valA > valB) return 1 * sortOrder.value;
|
|
return 0;
|
|
});
|
|
});
|
|
|
|
const selectedIds = ref([]);
|
|
const isBatchAnalyzing = ref(false);
|
|
const analysisProgress = ref({ current: 0, total: 0 });
|
|
|
|
const toggleSelectAll = (e) => {
|
|
if (e.target.checked) {
|
|
selectedIds.value = sortedCandidates.value.map(c => c.id);
|
|
} else {
|
|
selectedIds.value = [];
|
|
}
|
|
};
|
|
|
|
const batchAnalyze = async () => {
|
|
if (selectedIds.value.length === 0) return;
|
|
|
|
if (!confirm(`Voulez-vous lancer l'analyse IA pour les ${selectedIds.value.length} candidats sélectionnés ?`)) {
|
|
return;
|
|
}
|
|
|
|
isBatchAnalyzing.value = true;
|
|
analysisProgress.value = { current: 0, total: selectedIds.value.length };
|
|
|
|
const results = { success: 0, errors: 0, details: [] };
|
|
|
|
// Copy the IDs to avoid issues if selection changes during process
|
|
const idsToProcess = [...selectedIds.value];
|
|
|
|
for (const id of idsToProcess) {
|
|
analysisProgress.value.current++;
|
|
try {
|
|
await axios.post(route('admin.candidates.analyze', id));
|
|
results.success++;
|
|
} catch (error) {
|
|
results.errors++;
|
|
const candidate = props.candidates.find(c => c.id === id);
|
|
results.details.push({
|
|
candidate: candidate?.user?.name || `ID #${id}`,
|
|
error: error.response?.data?.error || error.message
|
|
});
|
|
}
|
|
}
|
|
|
|
// Finished processing all
|
|
router.reload({
|
|
onSuccess: () => {
|
|
isBatchAnalyzing.value = false;
|
|
selectedIds.value = [];
|
|
alert(`Analyse terminée : ${results.success} succès, ${results.errors} erreurs.`);
|
|
if (results.details.length > 0) {
|
|
console.table(results.details);
|
|
}
|
|
}
|
|
});
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<Head title="Gestion des Candidats" />
|
|
|
|
<AdminLayout>
|
|
<template #header>
|
|
Gestion des Candidats
|
|
</template>
|
|
|
|
<div class="sticky top-[-28px] z-20 bg-neutral/80 backdrop-blur-xl -mx-7 px-7 pt-7 pb-6 mb-4 border-b border-ink/[0.03]">
|
|
<div class="flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
|
|
<div class="space-y-4 w-full md:w-auto">
|
|
<h3 class="text-3xl font-serif font-black text-primary capitalize tracking-tight flex items-center gap-3">
|
|
<div class="w-1.5 h-8 bg-highlight rounded-full"></div>
|
|
Liste des Candidats
|
|
</h3>
|
|
<div class="flex flex-col sm:flex-row items-center gap-4">
|
|
<div class="flex items-center gap-3 bg-white p-2 rounded-xl border border-anthracite/5 shadow-sm min-w-max">
|
|
<label class="flex items-center gap-2 cursor-pointer px-2">
|
|
<input type="checkbox" v-model="showOnlySelected" class="rounded border-highlight/50 text-highlight focus:ring-highlight/20 cursor-pointer">
|
|
<span class="text-xs font-bold text-primary uppercase tracking-widest">Retenus uniquement</span>
|
|
</label>
|
|
</div>
|
|
<div class="flex items-center gap-3 w-full sm:w-auto">
|
|
<select
|
|
v-model="selectedJobPosition"
|
|
class="block w-full sm:w-72 rounded-xl border-anthracite/10 shadow-sm focus:border-primary focus:ring-primary/20 text-sm font-medium text-anthracite transition-all"
|
|
>
|
|
<option value="">Toutes les fiches de poste</option>
|
|
<option value="none" class="italic">➜ Candidature Spontanée</option>
|
|
<option v-for="jp in jobPositions" :key="jp.id" :value="jp.id">
|
|
{{ jp.title }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-4 w-full md:w-auto justify-end">
|
|
<div v-if="selectedIds.length > 0" class="flex items-center gap-3 animate-in fade-in slide-in-from-right-4 duration-300">
|
|
<span class="text-xs font-black uppercase tracking-widest text-primary/50">{{ selectedIds.length }} sélectionné(s)</span>
|
|
<PrimaryButton
|
|
@click="batchAnalyze"
|
|
:disabled="isBatchAnalyzing"
|
|
class="!bg-primary hover:!bg-primary/90 !text-white flex items-center gap-2 shadow-primary/20"
|
|
>
|
|
<svg v-if="isBatchAnalyzing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</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="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9l-.707.707M12 21v-1m4.243-4.243l-.707-.707m2.828-9.9l-.707.707" />
|
|
</svg>
|
|
{{ isBatchAnalyzing ? `Analyse ${analysisProgress.current}/${analysisProgress.total}...` : 'Analyse IA groupée' }}
|
|
</PrimaryButton>
|
|
<div class="h-8 w-px bg-anthracite/10 mx-2 hidden sm:block"></div>
|
|
</div>
|
|
<PrimaryButton @click="isModalOpen = true">
|
|
Ajouter un Candidat
|
|
</PrimaryButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Flash Messages -->
|
|
<div v-if="flashSuccess" class="mb-8 p-6 bg-emerald-50 border border-emerald-200 rounded-2xl flex items-center gap-4 animate-in fade-in slide-in-from-top-4 duration-500 shadow-sm">
|
|
<div class="p-2 bg-emerald-500 rounded-lg text-white shadow-sm">
|
|
<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">Succès !</p>
|
|
<p class="text-emerald-700 text-sm font-medium">{{ flashSuccess }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Candidates Table -->
|
|
<div class="bg-white rounded-3xl shadow-sm border border-anthracite/5 overflow-hidden">
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-left border-collapse">
|
|
<thead class="bg-neutral/50 border-b border-anthracite/5">
|
|
<tr>
|
|
<th class="w-12 px-8 py-5">
|
|
<input
|
|
type="checkbox"
|
|
:checked="selectedIds.length === sortedCandidates.length && sortedCandidates.length > 0"
|
|
@change="toggleSelectAll"
|
|
class="rounded border-anthracite/20 text-primary focus:ring-primary/20 cursor-pointer"
|
|
>
|
|
</th>
|
|
<th class="w-12 px-4 py-5"></th>
|
|
<th @click="sortBy('user.name')" class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40 cursor-pointer hover:text-primary transition-colors">
|
|
<div class="flex items-center gap-2">
|
|
Nom
|
|
<svg v-show="sortKey === 'user.name'" xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
|
|
</div>
|
|
</th>
|
|
<th @click="sortBy('user.email')" class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40 cursor-pointer hover:text-primary transition-colors">
|
|
<div class="flex items-center gap-2">
|
|
Contact
|
|
<svg v-show="sortKey === 'user.email'" xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
|
|
</div>
|
|
</th>
|
|
<th @click="sortBy('tenant.name')" v-if="$page.props.auth.user.role === 'super_admin'" class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40 cursor-pointer hover:text-primary transition-colors">
|
|
<div class="flex items-center gap-2">
|
|
Structure
|
|
<svg v-show="sortKey === 'tenant.name'" xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
|
|
</div>
|
|
</th>
|
|
<th @click="sortBy('job_position.title')" class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40 cursor-pointer hover:text-primary transition-colors">
|
|
<div class="flex items-center gap-2">
|
|
Poste Ciblé
|
|
<svg v-show="sortKey === 'job_position.title'" xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
|
|
</div>
|
|
</th>
|
|
<th @click="sortBy('status')" class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40 cursor-pointer hover:text-primary transition-colors">
|
|
<div class="flex items-center gap-2">
|
|
Statut
|
|
<svg v-show="sortKey === 'status'" xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
|
|
</div>
|
|
</th>
|
|
<th @click="sortBy('weighted_score')" class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40 cursor-pointer hover:text-primary transition-colors">
|
|
<div class="flex items-center gap-2">
|
|
Score
|
|
<svg v-show="sortKey === 'weighted_score'" xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
|
|
</div>
|
|
</th>
|
|
<th @click="sortBy('ai_analysis.match_score')" class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40 cursor-pointer hover:text-primary transition-colors">
|
|
<div class="flex items-center gap-2">
|
|
IA Match
|
|
<svg v-show="sortKey === 'ai_analysis.match_score'" xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
|
|
</div>
|
|
</th>
|
|
<th class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40">Docs</th>
|
|
<th class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40 text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-anthracite/5">
|
|
<tr v-for="candidate in sortedCandidates" :key="candidate.id" class="hover:bg-sand/30 transition-colors group" :class="{ 'bg-primary/5': selectedIds.includes(candidate.id) }">
|
|
<td class="px-8 py-5">
|
|
<input
|
|
type="checkbox"
|
|
:value="candidate.id"
|
|
v-model="selectedIds"
|
|
class="rounded border-anthracite/20 text-primary focus:ring-primary/20 cursor-pointer"
|
|
>
|
|
</td>
|
|
<td class="px-4 py-5 text-center">
|
|
<button @click="toggleSelection(candidate.id)" class="text-anthracite/20 hover:text-highlight hover:-translate-y-0.5 transition-all focus:outline-none" :class="{ '!text-highlight drop-shadow-sm scale-110': candidate.is_selected }" :title="candidate.is_selected ? 'Retirer des retenus' : 'Marquer comme retenu'">
|
|
<svg v-if="candidate.is_selected" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
|
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
|
</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="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
|
</svg>
|
|
</button>
|
|
</td>
|
|
<td class="px-8 py-5">
|
|
<Link :href="route('admin.candidates.show', candidate.id)" class="font-black text-primary group-hover:text-highlight transition-colors block">
|
|
{{ candidate.user.name }}
|
|
</Link>
|
|
<div class="text-[10px] text-anthracite/50 font-bold uppercase tracking-tight mt-0.5">{{ candidate.phone || 'Pas de numéro' }}</div>
|
|
</td>
|
|
<td class="px-8 py-5 text-xs text-anthracite/70 font-medium">
|
|
{{ candidate.user.email }}
|
|
</td>
|
|
<td class="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-primary/60" v-if="$page.props.auth.user.role === 'super_admin'">
|
|
{{ candidate.tenant ? candidate.tenant.name : 'Aucune' }}
|
|
</td>
|
|
<td class="px-8 py-5 text-xs font-bold text-anthracite">
|
|
{{ candidate.job_position ? candidate.job_position.title : 'Non assigné' }}
|
|
</td>
|
|
<td class="px-8 py-5">
|
|
<span
|
|
class="px-3 py-1 text-[10px] font-black uppercase tracking-[0.15em] rounded-full"
|
|
:class="{
|
|
'bg-anthracite/5 text-anthracite/60 border border-anthracite/10': candidate.status === 'en_attente',
|
|
'bg-sky/10 text-sky border border-sky/20': candidate.status === 'en_cours',
|
|
'bg-emerald-50 text-emerald-700 border border-emerald-200': candidate.status === 'termine',
|
|
'bg-accent/10 text-accent border border-accent/20': candidate.status === 'refuse'
|
|
}"
|
|
>
|
|
{{ candidate.status }}
|
|
</span>
|
|
</td>
|
|
<td class="px-8 py-5">
|
|
<div class="inline-flex items-center gap-2 px-3 py-1 bg-primary/5 text-primary rounded-xl font-black text-sm border border-primary/10 shadow-sm">
|
|
{{ candidate.weighted_score }} <span class="opacity-50 text-[10px]">/ 20</span>
|
|
</div>
|
|
</td>
|
|
<td class="px-8 py-5">
|
|
<div v-if="candidate.ai_analysis" class="flex items-center gap-2">
|
|
<div
|
|
class="px-2 py-0.5 rounded-lg text-[10px] font-black shadow-sm"
|
|
:class="[
|
|
candidate.ai_analysis.match_score >= 80 ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' :
|
|
candidate.ai_analysis.match_score >= 60 ? 'bg-highlight/10 text-[#3a2800] border border-highlight/30' :
|
|
'bg-accent/10 text-accent border border-accent/20'
|
|
]"
|
|
>
|
|
{{ candidate.ai_analysis.match_score }}%
|
|
</div>
|
|
<span class="text-[9px] font-bold text-anthracite/40 uppercase truncate max-w-[60px]" :title="candidate.ai_analysis.verdict">{{ candidate.ai_analysis.verdict }}</span>
|
|
</div>
|
|
<span v-else class="text-[9px] font-bold uppercase tracking-widest text-anthracite/30 italic">Non analysé</span>
|
|
</td>
|
|
<td class="px-8 py-5">
|
|
<div class="flex gap-1.5">
|
|
<button
|
|
v-for="doc in candidate.documents"
|
|
:key="doc.id"
|
|
@click="openPreview(doc)"
|
|
class="p-1.5 bg-neutral text-anthracite/40 rounded-lg hover:bg-primary/10 hover:text-primary transition-colors"
|
|
:title="doc.type.toUpperCase()"
|
|
>
|
|
<svg v-if="doc.type === 'cv'" 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="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" 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>
|
|
<span v-if="candidate.documents.length === 0" class="text-anthracite/20 text-xs">-</span>
|
|
</div>
|
|
</td>
|
|
<td class="px-8 py-5 text-right">
|
|
<div class="flex items-center justify-end gap-2">
|
|
<Link :href="route('admin.candidates.show', candidate.id)" class="p-2 text-primary/40 hover:text-highlight hover:bg-highlight/10 rounded-xl transition-all" title="Détails">
|
|
<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 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
|
</Link>
|
|
<button @click="deleteCandidate(candidate.id)" class="p-2 text-anthracite/20 hover:text-accent hover:bg-accent/10 rounded-xl transition-all" 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.5" 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="11" class="px-8 py-16 text-center">
|
|
<div class="text-anthracite/40 italic font-medium font-subtitle">
|
|
Aucun candidat trouvé.
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</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-1 md:grid-cols-2 gap-4">
|
|
<div v-if="$page.props.auth.user.role === 'super_admin'">
|
|
<InputLabel for="tenant_id" value="Structure de rattachement" />
|
|
<select id="tenant_id" v-model="form.tenant_id" class="mt-1 block w-full rounded-md border-slate-300 dark:border-slate-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-slate-900">
|
|
<option value="">Aucune</option>
|
|
<option v-for="t in tenants" :key="t.id" :value="t.id">{{ t.name }}</option>
|
|
</select>
|
|
<InputError class="mt-2" :message="form.errors.tenant_id" />
|
|
</div>
|
|
<div :class="{ 'md:col-span-2': $page.props.auth.user.role !== 'super_admin' }">
|
|
<InputLabel for="job_position_id" value="Rattacher à une Fiche de poste" />
|
|
<select id="job_position_id" v-model="form.job_position_id" class="mt-1 block w-full rounded-md border-slate-300 dark:border-slate-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-slate-900">
|
|
<option value="">Aucune (Candidature spontanée)</option>
|
|
<option v-for="jp in jobPositions" :key="jp.id" :value="jp.id">{{ jp.title }}</option>
|
|
</select>
|
|
<InputError class="mt-2" :message="form.errors.job_position_id" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<InputLabel value="CV (PDF)" />
|
|
<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>
|