818 lines
61 KiB
Vue
818 lines
61 KiB
Vue
<script setup>
|
|
import AdminLayout from '@/Layouts/AdminLayout.vue';
|
|
import axios from 'axios';
|
|
import { Head, Link, router, useForm, usePage } from '@inertiajs/vue3';
|
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
|
import { marked } from 'marked';
|
|
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';
|
|
import Chart from 'chart.js/auto';
|
|
|
|
const props = defineProps({
|
|
candidate: Object,
|
|
jobPositions: Array,
|
|
tenants: Array,
|
|
ai_config: Object
|
|
});
|
|
|
|
const page = usePage();
|
|
const flashSuccess = computed(() => page.props.flash?.success);
|
|
const activeTab = ref('overview');
|
|
|
|
const positionForm = useForm({ job_position_id: props.candidate.job_position_id || '' });
|
|
|
|
const showEditDetailsModal = ref(false);
|
|
const detailsForm = useForm({
|
|
name: props.candidate.user.name,
|
|
email: props.candidate.user.email,
|
|
phone: props.candidate.phone || '',
|
|
linkedin_url: props.candidate.linkedin_url || '',
|
|
});
|
|
const updateDetails = () => {
|
|
detailsForm.put(route('admin.candidates.update', props.candidate.id), {
|
|
preserveScroll: true,
|
|
onSuccess: () => { showEditDetailsModal.value = false; },
|
|
});
|
|
};
|
|
const updatePosition = () => positionForm.patch(route('admin.candidates.update-position', props.candidate.id), { preserveScroll: true });
|
|
|
|
const tenantForm = useForm({ tenant_id: props.candidate.tenant_id || '' });
|
|
const updateTenant = () => tenantForm.patch(route('admin.candidates.update-tenant', props.candidate.id), { preserveScroll: true });
|
|
const toggleSelection = () => router.patch(route('admin.candidates.toggle-selection', props.candidate.id), {}, { preserveScroll: true });
|
|
|
|
const selectedDocument = ref(null);
|
|
const docForm = useForm({ cv: null, cover_letter: null, _method: 'PUT' });
|
|
|
|
const rawInterviewDetails = props.candidate.interview_details || {};
|
|
const notesForm = useForm({
|
|
notes: props.candidate.notes || '',
|
|
interview_details: {
|
|
questions: rawInterviewDetails.questions || [],
|
|
appreciation: rawInterviewDetails.appreciation || 0,
|
|
soft_skills: rawInterviewDetails.soft_skills || [
|
|
{ name: 'Communication & Pédagogie', score: 0 },
|
|
{ name: "Esprit d'équipe & Collaboration", score: 0 },
|
|
{ name: 'Résolution de problèmes & Logique', score: 0 },
|
|
{ name: 'Adaptabilité & Résilience', score: 0 },
|
|
{ name: 'Autonomie & Proactivité', score: 0 }
|
|
]
|
|
}
|
|
});
|
|
|
|
const scoreForm = useForm({
|
|
cv_score: props.candidate.cv_score || 0,
|
|
motivation_score: props.candidate.motivation_score || 0,
|
|
interview_score: props.candidate.interview_score || 0,
|
|
});
|
|
|
|
const isPreview = ref(true);
|
|
const renderedNotes = computed(() => marked.parse(notesForm.notes || ''));
|
|
const openAttempts = ref([]);
|
|
const toggleAttempt = (id) => {
|
|
openAttempts.value = openAttempts.value.includes(id)
|
|
? openAttempts.value.filter(i => i !== id)
|
|
: [...openAttempts.value, id];
|
|
};
|
|
const formatDateTime = (date) => new Date(date).toLocaleString('fr-FR', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
|
|
const deleteCandidate = () => {
|
|
if (confirm('Supprimer ce candidat ? Toutes ses données seront définitivement perdues.'))
|
|
router.delete(route('admin.candidates.destroy', props.candidate.id));
|
|
};
|
|
const deleteAttempt = (id) => {
|
|
if (confirm('Supprimer cette tentative ?'))
|
|
router.delete(route('admin.attempts.destroy', id), { preserveScroll: true });
|
|
};
|
|
const resetPassword = () => {
|
|
if (confirm('Générer un nouveau mot de passe ?'))
|
|
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 saveScores = () => {
|
|
scoreForm.cv_score = Math.min(20, Math.max(0, scoreForm.cv_score));
|
|
scoreForm.motivation_score = Math.min(10, Math.max(0, scoreForm.motivation_score));
|
|
scoreForm.interview_score = Math.min(30, Math.max(0, scoreForm.interview_score));
|
|
scoreForm.patch(route('admin.candidates.update-scores', props.candidate.id), { preserveScroll: true });
|
|
};
|
|
const openPreview = (doc) => { selectedDocument.value = doc; };
|
|
const updateAnswerScore = (answerId, score, max) => {
|
|
const clamped = Math.min(max, Math.max(0, parseFloat(score) || 0));
|
|
router.patch(route('admin.answers.update-score', answerId), { score: clamped }, { preserveScroll: true });
|
|
};
|
|
const clampScore = (key, max) => {
|
|
if (scoreForm[key] > max) scoreForm[key] = max;
|
|
if (scoreForm[key] < 0) scoreForm[key] = 0;
|
|
};
|
|
|
|
// ─── Radar Chart ──────────────────────────────────────────────────────────────
|
|
const radarCanvasRef = ref(null);
|
|
let radarChartInstance = null;
|
|
|
|
const bestTestScore = computed(() => {
|
|
if (!props.candidate.attempts?.length) return 0;
|
|
const finished = props.candidate.attempts.filter(a => a.finished_at && a.max_score > 0);
|
|
return finished.length ? Math.max(...finished.map(a => (a.score / a.max_score) * 20)) : 0;
|
|
});
|
|
const softSkillsScore = computed(() => {
|
|
const skills = notesForm.interview_details.soft_skills || [];
|
|
if (!skills.length) return 0;
|
|
return Number((skills.reduce((a, s) => a + (parseFloat(s.score) || 0), 0) / skills.length).toFixed(1));
|
|
});
|
|
const radarData = computed(() => ([
|
|
Math.round((parseFloat(scoreForm.cv_score) / 20) * 100),
|
|
Math.round((parseFloat(scoreForm.motivation_score) / 10) * 100),
|
|
Math.round((parseFloat(scoreForm.interview_score) / 30) * 100),
|
|
Math.round((bestTestScore.value / 20) * 100),
|
|
Math.round((softSkillsScore.value / 10) * 100),
|
|
]));
|
|
|
|
const buildRadarChart = () => {
|
|
if (!radarCanvasRef.value) return;
|
|
if (radarChartInstance) { radarChartInstance.destroy(); radarChartInstance = null; }
|
|
radarChartInstance = new Chart(radarCanvasRef.value, {
|
|
type: 'radar',
|
|
data: {
|
|
labels: ['Analyse CV', 'Lettre Motiv.', 'Entretien', 'Test Technique', 'Soft Skills'],
|
|
datasets: [{
|
|
label: 'Profil (%)',
|
|
data: radarData.value,
|
|
backgroundColor: 'rgba(26,75,140,0.12)',
|
|
borderColor: '#1a4b8c',
|
|
borderWidth: 2.5,
|
|
pointBackgroundColor: '#f5a800',
|
|
pointBorderColor: '#fff',
|
|
pointBorderWidth: 2,
|
|
pointRadius: 5,
|
|
pointHoverRadius: 7,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true, maintainAspectRatio: true,
|
|
animation: { duration: 600, easing: 'easeInOutQuart' },
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: { callbacks: { label: (ctx) => ` ${ctx.raw}%` }, backgroundColor:'#fff', titleColor:'#1a4b8c', bodyColor:'#2d2d2d', borderColor:'rgba(45,45,45,0.1)', borderWidth:1, padding:10, cornerRadius:10 }
|
|
},
|
|
scales: {
|
|
r: {
|
|
min: 0, max: 100,
|
|
ticks: { stepSize:25, color:'rgba(45,45,45,0.3)', backdropColor:'transparent', font:{size:9,weight:'bold'}, callback: v => v+'%' },
|
|
grid: { color:'rgba(45,45,45,0.07)' },
|
|
angleLines: { color:'rgba(45,45,45,0.07)' },
|
|
pointLabels: { color:'rgba(45,45,45,0.55)', font:{size:11,weight:'bold'} }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
onMounted(() => nextTick(() => buildRadarChart()));
|
|
onUnmounted(() => { if (radarChartInstance) radarChartInstance.destroy(); });
|
|
watch(() => [scoreForm.cv_score, scoreForm.motivation_score, scoreForm.interview_score, bestTestScore.value, softSkillsScore.value], () => {
|
|
if (radarChartInstance) { radarChartInstance.data.datasets[0].data = radarData.value; radarChartInstance.update(); }
|
|
});
|
|
watch(activeTab, t => { if (t === 'overview') nextTick(() => buildRadarChart()); });
|
|
|
|
// ─── AI ───────────────────────────────────────────────────────────────────────
|
|
const aiAnalysis = ref(props.candidate.ai_analysis || null);
|
|
const isAnalyzing = ref(false);
|
|
const selectedProvider = ref(props.ai_config?.default || 'ollama');
|
|
const forceAnalysis = ref(false);
|
|
|
|
const calculatedInterviewScore = computed(() => {
|
|
const qScore = (notesForm.interview_details.questions || []).reduce((a, q) => a + (parseFloat(q.score) || 0), 0);
|
|
return Math.min(30, qScore + (parseFloat(notesForm.interview_details.appreciation) || 0));
|
|
});
|
|
|
|
watch(aiAnalysis, (val) => {
|
|
if (val?.questions_entretien_suggerees && !notesForm.interview_details.questions?.length) {
|
|
notesForm.interview_details.questions = val.questions_entretien_suggerees.map(q => ({ question: q, score: 0, comment: '' }));
|
|
}
|
|
}, { immediate: true });
|
|
|
|
watch(calculatedInterviewScore, v => { scoreForm.interview_score = v; });
|
|
|
|
const saveNotes = () => notesForm.transform(d => ({ ...d, interview_score: calculatedInterviewScore.value }))
|
|
.patch(route('admin.candidates.update-notes', props.candidate.id), { preserveScroll: true });
|
|
|
|
const showErrorModal = ref(false);
|
|
const modalErrorMessage = ref('');
|
|
|
|
const runAI = async () => {
|
|
if (!props.candidate.job_position_id) {
|
|
modalErrorMessage.value = "Veuillez d'abord associer une fiche de poste à ce candidat.";
|
|
showErrorModal.value = true;
|
|
return;
|
|
}
|
|
isAnalyzing.value = true;
|
|
try {
|
|
const r = await axios.post(route('admin.candidates.analyze', props.candidate.id), { provider: selectedProvider.value, force: forceAnalysis.value });
|
|
aiAnalysis.value = r.data;
|
|
} catch (e) {
|
|
modalErrorMessage.value = e.response?.data?.error || "Erreur lors de l'analyse.";
|
|
showErrorModal.value = true;
|
|
} finally { isAnalyzing.value = false; }
|
|
};
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
const statusMap = {
|
|
en_attente: { label:'En attente', cls:'bg-ink/5 text-ink/55' },
|
|
en_cours: { label:'En cours', cls:'bg-primary/10 text-primary' },
|
|
termine: { label:'Terminé', cls:'bg-success/10 text-success' },
|
|
refuse: { label:'Refusé', cls:'bg-accent/10 text-accent' },
|
|
};
|
|
const statusInfo = computed(() => statusMap[props.candidate.status] ?? statusMap.en_attente);
|
|
|
|
const scoreColor = (pct) => pct >= 80 ? 'text-success' : pct >= 60 ? 'text-highlight' : 'text-accent';
|
|
const barColor = (pct) => pct >= 80 ? 'bg-success' : pct >= 60 ? 'bg-highlight' : 'bg-accent';
|
|
</script>
|
|
|
|
<template>
|
|
<Head :title="`${candidate.user.name} — Profil`" />
|
|
|
|
<AdminLayout>
|
|
<template #header>
|
|
<div class="flex items-center gap-3">
|
|
<Link :href="route('admin.candidates.index')" class="text-ink/30 hover:text-primary transition-colors">
|
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
|
|
</Link>
|
|
<span class="text-ink/20">/</span>
|
|
<span>{{ candidate.user.name }}</span>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ─── Flash ─────────────────────────────────────────────────────── -->
|
|
<Transition enter-active-class="transition ease-out duration-200" enter-from-class="opacity-0 -translate-y-2" leave-active-class="transition ease-in duration-150" leave-to-class="opacity-0 -translate-y-2">
|
|
<div v-if="flashSuccess" class="mb-5 flex items-center gap-3 bg-success/10 border border-success/20 text-success rounded-xl px-5 py-3 text-sm font-bold">
|
|
<svg class="w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
|
{{ flashSuccess }}
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- ─── Layout: Sidebar + Main ────────────────────────────────────── -->
|
|
<div class="flex gap-5 items-start">
|
|
|
|
<!-- ══ Left panel ══════════════════════════════════════════════ -->
|
|
<div class="w-[280px] shrink-0 flex flex-col gap-4 sticky top-0 self-start">
|
|
|
|
<!-- Identity card -->
|
|
<div class="bg-surface rounded-2xl border border-ink/[0.07] shadow-sm">
|
|
<!-- Header band -->
|
|
<div class="h-16 bg-primary relative rounded-t-2xl overflow-hidden">
|
|
<div class="absolute inset-0 opacity-10" style="background: radial-gradient(circle at top right, #f5a800, transparent 70%)"></div>
|
|
<!-- Selection star -->
|
|
<button @click="toggleSelection" :title="candidate.is_selected ? 'Retirer la sélection' : 'Retenir ce candidat'"
|
|
class="absolute top-3 right-3 p-1.5 rounded-lg transition-all"
|
|
:class="candidate.is_selected ? 'text-highlight bg-highlight/20' : 'text-white/30 hover:text-highlight hover:bg-white/10'">
|
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Avatar (overlapping) -->
|
|
<div class="relative flex justify-center -mt-8 mb-3 z-10">
|
|
<div class="w-16 h-16 rounded-full bg-sand border-4 border-surface flex items-center justify-center text-2xl font-black text-primary shadow-sm">
|
|
{{ candidate.user.name.charAt(0) }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="px-5 pb-5 text-center">
|
|
<h2 class="font-serif font-black text-base text-ink leading-tight mb-1">{{ candidate.user.name }}</h2>
|
|
<p class="text-xs text-ink/50 font-semibold mb-3">{{ candidate.job_position?.title || 'Poste non assigné' }}</p>
|
|
<span :class="['inline-block px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-[0.12em]', statusInfo.cls]">
|
|
{{ statusInfo.label }}
|
|
</span>
|
|
|
|
<!-- Contacts -->
|
|
<div class="mt-4 space-y-2 text-left">
|
|
<div class="flex items-center gap-2.5 text-xs text-ink/55 font-semibold">
|
|
<svg class="w-3.5 h-3.5 text-ink/30 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2zM22 6l-10 7L2 6"/></svg>
|
|
<span class="truncate">{{ candidate.user.email }}</span>
|
|
</div>
|
|
<div v-if="candidate.phone" class="flex items-center gap-2.5 text-xs text-ink/55 font-semibold">
|
|
<svg class="w-3.5 h-3.5 text-ink/30 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"/></svg>
|
|
<span>{{ candidate.phone }}</span>
|
|
</div>
|
|
<a v-if="candidate.linkedin_url" :href="candidate.linkedin_url" target="_blank" class="flex items-center gap-2.5 text-xs text-primary font-bold hover:underline">
|
|
<svg class="w-3.5 h-3.5 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M16 8a6 6 0 016 6v7h-4v-7a2 2 0 00-2-2 2 2 0 00-2 2v7h-4v-7a6 6 0 016-6zM2 9h4v12H2z"/><circle cx="4" cy="4" r="2"/></svg>
|
|
LinkedIn
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="mt-5 grid grid-cols-2 gap-2">
|
|
<button @click="showEditDetailsModal = true"
|
|
class="flex items-center justify-center gap-1.5 py-2 rounded-[10px] border border-ink/10 bg-transparent text-xs font-extrabold uppercase tracking-[0.07em] text-ink hover:bg-primary/5 hover:border-primary hover:text-primary transition-all duration-150">
|
|
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
|
Éditer
|
|
</button>
|
|
<a :href="route('admin.candidates.export-dossier', candidate.id)" target="_blank"
|
|
class="flex items-center justify-center gap-1.5 py-2 rounded-[10px] border border-ink/10 bg-transparent text-xs font-extrabold uppercase tracking-[0.07em] text-ink hover:bg-primary/5 hover:border-primary hover:text-primary transition-all duration-150">
|
|
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
Exporter
|
|
</a>
|
|
</div>
|
|
<button @click="toggleSelection"
|
|
:class="['mt-2 w-full flex items-center justify-center gap-2 py-2.5 rounded-[10px] border-none text-xs font-extrabold uppercase tracking-[0.08em] transition-all duration-150',
|
|
candidate.is_selected
|
|
? 'bg-highlight/15 text-highlight border border-highlight/30 hover:bg-highlight/25'
|
|
: 'bg-highlight text-highlight-dark shadow-gold hover:brightness-105 hover:-translate-y-px']">
|
|
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
|
|
{{ candidate.is_selected ? 'Retirer la sélection' : 'Retenir ce candidat' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Score global card -->
|
|
<div class="bg-primary rounded-2xl p-5 relative overflow-hidden">
|
|
<div class="absolute inset-0 opacity-10" style="background: radial-gradient(circle at bottom right, #f5a800, transparent 60%)"></div>
|
|
<div class="relative z-10">
|
|
<p class="text-[9px] font-black uppercase tracking-[0.18em] text-white/40 mb-2">Score Global Pondéré</p>
|
|
<div class="flex items-end gap-2 mb-4">
|
|
<span class="font-serif font-black text-4xl text-highlight leading-none">{{ candidate.weighted_score }}</span>
|
|
<span class="text-sm text-white/40 font-semibold mb-1">/ 20</span>
|
|
</div>
|
|
<!-- Mini bars -->
|
|
<div class="space-y-2">
|
|
<div v-for="(item, i) in [
|
|
{ label:'CV', val: scoreForm.cv_score, max:20 },
|
|
{ label:'Lettre', val: scoreForm.motivation_score, max:10 },
|
|
{ label:'Entretien', val: scoreForm.interview_score, max:30 },
|
|
{ label:'Test', val: bestTestScore, max:20 },
|
|
]" :key="i" class="flex items-center gap-2">
|
|
<span class="text-[9px] font-black text-white/35 uppercase w-14 shrink-0">{{ item.label }}</span>
|
|
<div class="flex-1 h-1.5 bg-white/10 rounded-full overflow-hidden">
|
|
<div class="h-full bg-white/60 rounded-full transition-all duration-500" :style="{width: `${Math.min(100,(item.val/item.max)*100)}%`}" />
|
|
</div>
|
|
<span class="text-[9px] font-black text-white/50 tabular-nums w-8 text-right">{{ parseFloat(item.val).toFixed(1) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- AI Summary card (if analysed) -->
|
|
<div v-if="aiAnalysis" class="bg-surface rounded-2xl border border-ink/[0.07] shadow-sm p-5">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<span class="text-[9px] font-black uppercase tracking-[0.16em] text-ink/35">Analyse IA</span>
|
|
<div :class="['w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-black',
|
|
aiAnalysis.match_score >= 80 ? 'bg-success/12 text-success' :
|
|
aiAnalysis.match_score >= 60 ? 'bg-highlight/15 text-highlight-on' : 'bg-accent/10 text-accent']">
|
|
{{ aiAnalysis.match_score }}%
|
|
</div>
|
|
</div>
|
|
<p class="text-xs text-ink/60 font-semibold leading-relaxed line-clamp-4">{{ aiAnalysis.synthese }}</p>
|
|
</div>
|
|
|
|
<!-- Fiche de poste -->
|
|
<div class="bg-surface rounded-2xl border border-ink/[0.07] shadow-sm p-5">
|
|
<p class="text-[9px] font-black uppercase tracking-[0.16em] text-ink/35 mb-3">Fiche de Poste</p>
|
|
<select v-model="positionForm.job_position_id" @change="updatePosition"
|
|
class="w-full rounded-[10px] border border-ink/10 bg-neutral px-3 py-2 text-sm font-semibold text-ink focus:border-primary focus:ring-2 focus:ring-primary/15 outline-none transition-all">
|
|
<option value="">— Non assigné —</option>
|
|
<option v-for="p in jobPositions" :key="p.id" :value="p.id">{{ p.title }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Danger zone -->
|
|
<div class="bg-surface rounded-2xl border border-ink/[0.07] shadow-sm p-5">
|
|
<p class="text-[9px] font-black uppercase tracking-[0.16em] text-ink/35 mb-3">Actions</p>
|
|
<div class="space-y-2">
|
|
<button @click="resetPassword"
|
|
class="w-full py-2 px-3 rounded-[10px] border border-ink/10 text-xs font-extrabold uppercase tracking-[0.07em] text-ink/60 hover:bg-ink/5 transition-all text-left flex items-center gap-2">
|
|
<svg class="w-3.5 h-3.5 text-ink/30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
|
Réinitialiser MDP
|
|
</button>
|
|
<button @click="deleteCandidate"
|
|
class="w-full py-2 px-3 rounded-[10px] border border-accent/20 bg-accent/5 text-xs font-extrabold uppercase tracking-[0.07em] text-accent hover:bg-accent hover:text-white transition-all text-left flex items-center gap-2">
|
|
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>
|
|
Supprimer le candidat
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ══ Right panel ═════════════════════════════════════════════ -->
|
|
<div class="flex-1 min-w-0 flex flex-col gap-4">
|
|
|
|
<!-- Tabs card -->
|
|
<div class="bg-surface rounded-2xl border border-ink/[0.07] shadow-sm overflow-hidden flex flex-col">
|
|
<!-- Tab bar -->
|
|
<div class="flex border-b border-ink/[0.07] overflow-x-auto scrollbar-none">
|
|
<button v-for="tab in [
|
|
{ id:'overview', label:'Vue d\'ensemble' },
|
|
{ id:'ai_analysis', label:'Analyse IA' },
|
|
{ id:'interview', label:'Évaluation' },
|
|
{ id:'documents', label:'Documents', count: candidate.documents?.length },
|
|
{ id:'tests', label:'Tests', count: candidate.attempts?.length },
|
|
]" :key="tab.id" @click="activeTab = tab.id"
|
|
class="relative flex items-center gap-2 px-5 py-4 text-[11px] font-black uppercase tracking-[0.1em] whitespace-nowrap transition-all duration-150"
|
|
:class="activeTab === tab.id ? 'text-primary' : 'text-ink/35 hover:text-ink/60'">
|
|
{{ tab.label }}
|
|
<span v-if="tab.count != null" :class="['text-[9px] px-1.5 py-0.5 rounded font-black', activeTab===tab.id ? 'bg-primary/10 text-primary' : 'bg-ink/5 text-ink/40']">{{ tab.count }}</span>
|
|
<div v-if="activeTab === tab.id" class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ── Tab: Vue d'ensemble ── -->
|
|
<div v-if="activeTab === 'overview'" class="p-6 space-y-6">
|
|
|
|
<!-- Save scores button -->
|
|
<div v-if="scoreForm.isDirty" class="flex justify-end">
|
|
<button @click="saveScores"
|
|
class="flex items-center gap-2 px-5 py-2.5 rounded-[10px] bg-highlight text-highlight-dark text-xs font-extrabold uppercase tracking-[0.08em] shadow-gold hover:brightness-105 transition-all">
|
|
<svg class="w-3.5 h-3.5 animate-pulse" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
|
Enregistrer les modifications
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Score inputs grid -->
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<div v-for="(item, i) in [
|
|
{ label:'Analyse CV', key:'cv_score', max:20, color:'text-primary' },
|
|
{ label:'Lettre Motiv.', key:'motivation_score', max:10, color:'text-success' },
|
|
{ label:'Entretien', key:'interview_score', max:30, color:'text-sky-600', readonly:true },
|
|
{ label:'Test Technique', key:'_test', max:20, color:'text-highlight', readonly:true },
|
|
]" :key="i" class="bg-neutral rounded-xl p-4">
|
|
<p class="text-[9px] font-black uppercase tracking-[0.14em] text-ink/35 mb-2">{{ item.label }}</p>
|
|
<div class="relative">
|
|
<input v-if="!item.readonly"
|
|
type="number" v-model="scoreForm[item.key]" :min="0" :max="item.max" step="0.5"
|
|
@input="clampScore(item.key, item.max)"
|
|
class="w-full bg-surface border border-ink/10 rounded-lg px-3 py-2 font-black text-lg focus:border-primary focus:ring-2 focus:ring-primary/15 outline-none transition-all"
|
|
:class="item.color" />
|
|
<div v-else class="w-full bg-surface border border-ink/10 rounded-lg px-3 py-2 font-black text-lg" :class="item.color">
|
|
{{ item.key === '_test' ? bestTestScore.toFixed(2) : scoreForm[item.key] }}
|
|
</div>
|
|
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-ink/25 font-bold">/ {{ item.max }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Radar chart -->
|
|
<div class="grid md:grid-cols-2 gap-6 items-center">
|
|
<div class="flex items-center justify-center">
|
|
<canvas ref="radarCanvasRef" class="max-h-64 w-full" />
|
|
</div>
|
|
<div class="space-y-3">
|
|
<div v-for="(item, i) in [
|
|
{ label:'Analyse CV', val: scoreForm.cv_score, max:20 },
|
|
{ label:'Lettre Motiv.', val: scoreForm.motivation_score, max:10 },
|
|
{ label:'Entretien', val: scoreForm.interview_score, max:30 },
|
|
{ label:'Test Technique', val: bestTestScore, max:20 },
|
|
{ label:'Soft Skills', val: softSkillsScore, max:10 },
|
|
]" :key="i">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-[10px] font-black uppercase tracking-[0.1em] text-ink/45">{{ item.label }}</span>
|
|
<span class="text-xs font-black" :class="scoreColor(Math.round((item.val/item.max)*100))">{{ parseFloat(item.val).toFixed(1) }}<span class="text-[9px] text-ink/30 font-semibold">/ {{ item.max }}</span></span>
|
|
</div>
|
|
<div class="h-1.5 bg-ink/[0.06] rounded-full overflow-hidden">
|
|
<div :class="['h-full rounded-full transition-all duration-500', barColor(Math.round((item.val/item.max)*100))]" :style="{width: `${Math.min(100,(item.val/item.max)*100)}%`}" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Tab: Analyse IA ── -->
|
|
<div v-if="activeTab === 'ai_analysis'" class="p-6">
|
|
<!-- Run AI -->
|
|
<div class="flex items-center gap-3 mb-6 flex-wrap">
|
|
<select v-model="selectedProvider" class="min-w-[140px] w-auto rounded-[10px] border border-ink/10 bg-neutral px-4 py-2 text-sm font-bold text-ink focus:border-primary focus:ring-2 focus:ring-primary/15 outline-none cursor-pointer hover:border-ink/20 transition-colors">
|
|
<option v-for="p in ai_config?.providers" :key="p" :value="p">{{ p.toUpperCase() }}</option>
|
|
</select>
|
|
<label class="flex items-center gap-2 text-xs font-semibold text-ink/55 cursor-pointer">
|
|
<input type="checkbox" v-model="forceAnalysis" class="rounded border-ink/20 text-primary focus:ring-primary/30" />
|
|
Forcer une nouvelle analyse
|
|
</label>
|
|
<button @click="runAI" :disabled="isAnalyzing"
|
|
class="flex items-center gap-2 px-5 py-2.5 rounded-[10px] bg-primary text-white text-xs font-extrabold uppercase tracking-[0.08em] shadow-primary hover:brightness-110 transition-all disabled:opacity-50">
|
|
<svg v-if="isAnalyzing" class="w-3.5 h-3.5 animate-spin" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" class="opacity-25"/><path fill="currentColor" class="opacity-75" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/></svg>
|
|
<svg v-else class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 2A2.5 2.5 0 017 4.5v0A2.5 2.5 0 014.5 7v0A2.5 2.5 0 012 9.5v5A2.5 2.5 0 004.5 17v0A2.5 2.5 0 007 19.5v0A2.5 2.5 0 009.5 22h5a2.5 2.5 0 002.5-2.5v0a2.5 2.5 0 002.5-2.5v0a2.5 2.5 0 002.5-2.5v-5a2.5 2.5 0 00-2.5-2.5v0A2.5 2.5 0 0017 4.5v0A2.5 2.5 0 0014.5 2h-5z"/></svg>
|
|
{{ isAnalyzing ? 'Analyse en cours…' : 'Lancer l\'analyse IA' }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="aiAnalysis" class="space-y-5">
|
|
<!-- Match score + synthèse -->
|
|
<div class="flex items-start gap-5 p-5 rounded-xl border border-ink/[0.07] bg-neutral">
|
|
<div :class="['w-14 h-14 rounded-full flex items-center justify-center text-lg font-black shrink-0',
|
|
aiAnalysis.match_score >= 80 ? 'bg-success/12 text-success' :
|
|
aiAnalysis.match_score >= 60 ? 'bg-highlight/15 text-highlight' : 'bg-accent/10 text-accent']">
|
|
{{ aiAnalysis.match_score }}%
|
|
</div>
|
|
<div>
|
|
<p class="text-[9px] font-black uppercase tracking-[0.14em] text-ink/35 mb-1">Adéquation poste</p>
|
|
<p class="text-base font-semibold text-ink/70 leading-relaxed">{{ aiAnalysis.summary || aiAnalysis.synthese }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Scores détaillés -->
|
|
<div v-if="aiAnalysis.scores_detailles" class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
<div v-for="(data, key) in aiAnalysis.scores_detailles" :key="key" class="p-4 rounded-xl border border-ink/[0.05] bg-neutral/50">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="text-[10px] font-black uppercase tracking-widest text-ink/40">{{ key }}</span>
|
|
<span class="text-xs font-black text-primary">{{ data.score }}%</span>
|
|
</div>
|
|
<div class="h-1.5 bg-ink/5 rounded-full overflow-hidden mb-3">
|
|
<div class="h-full bg-primary rounded-full" :style="{ width: `${data.score}%` }" />
|
|
</div>
|
|
<p class="text-xs text-ink/55 leading-relaxed font-semibold italic">{{ data.justification }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Points forts / faibles / bloquants -->
|
|
<div class="grid md:grid-cols-2 gap-4">
|
|
<div class="p-4 rounded-xl bg-success/5 border border-success/15">
|
|
<p class="text-[9px] font-black uppercase tracking-[0.14em] text-success/70 mb-3">Points forts</p>
|
|
<ul class="space-y-2">
|
|
<li v-for="(p, i) in (aiAnalysis.strengths || aiAnalysis.points_forts)" :key="i" class="flex items-start gap-2 text-sm font-semibold text-ink/70 leading-snug">
|
|
<svg class="w-3.5 h-3.5 text-success shrink-0 mt-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
|
{{ p }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<div class="p-4 rounded-xl bg-accent/5 border border-accent/15">
|
|
<p class="text-[9px] font-black uppercase tracking-[0.14em] text-accent/70 mb-3">Points d'attention</p>
|
|
<ul class="space-y-2">
|
|
<li v-for="(p, i) in (aiAnalysis.gaps || aiAnalysis.points_faibles)" :key="i" class="flex items-start gap-2 text-sm font-semibold text-ink/70 leading-snug">
|
|
<svg class="w-3.5 h-3.5 text-accent shrink-0 mt-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
{{ p }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Éléments bloquants -->
|
|
<div v-if="aiAnalysis.elements_bloquants?.length" class="p-4 rounded-xl bg-accent border border-accent/30 text-white shadow-lg shadow-accent/20">
|
|
<div class="flex items-center gap-2 mb-3">
|
|
<svg class="w-4 h-4 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
|
<p class="text-[10px] font-black uppercase tracking-widest">Points Critiques / Bloquants</p>
|
|
</div>
|
|
<ul class="space-y-1.5">
|
|
<li v-for="(p, i) in aiAnalysis.elements_bloquants" :key="i" class="text-xs font-bold flex items-center gap-2">
|
|
<div class="w-1 h-1 rounded-full bg-white/50" /> {{ p }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Questions suggérées -->
|
|
<div v-if="aiAnalysis.questions_entretien_suggerees?.length" class="p-5 rounded-xl border border-primary/10 bg-primary/5">
|
|
<p class="text-[9px] font-black uppercase tracking-[0.14em] text-primary mb-4">Questions d'entretien suggérées</p>
|
|
<div class="grid md:grid-cols-2 gap-4">
|
|
<div v-for="(q, i) in aiAnalysis.questions_entretien_suggerees" :key="i" class="flex gap-3">
|
|
<span class="text-primary/30 font-serif font-black text-lg italic leading-none">{{ i + 1 }}</span>
|
|
<p class="text-sm font-bold text-ink/70 leading-relaxed">{{ q }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Verdict -->
|
|
<div v-if="aiAnalysis.verdict || aiAnalysis.recommandation" class="p-4 rounded-xl bg-highlight/10 border border-highlight/20 border-l-4 border-l-highlight">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<p class="text-[9px] font-black uppercase tracking-[0.14em] text-highlight">Verdict Final</p>
|
|
<span class="px-2 py-0.5 rounded bg-highlight text-highlight-dark text-[10px] font-black">{{ aiAnalysis.verdict || 'Analyse en cours' }}</span>
|
|
</div>
|
|
<p class="text-base font-semibold text-ink/70 leading-relaxed">{{ aiAnalysis.recommandation || aiAnalysis.summary }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="flex flex-col items-center justify-center py-16 text-center">
|
|
<div class="w-14 h-14 rounded-full bg-primary/8 flex items-center justify-center mb-4">
|
|
<svg class="w-7 h-7 text-primary/40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 2A2.5 2.5 0 017 4.5v0A2.5 2.5 0 014.5 7v0A2.5 2.5 0 012 9.5v5A2.5 2.5 0 004.5 17v0A2.5 2.5 0 007 19.5v0A2.5 2.5 0 009.5 22h5a2.5 2.5 0 002.5-2.5v0a2.5 2.5 0 002.5-2.5v0a2.5 2.5 0 002.5-2.5v-5a2.5 2.5 0 00-2.5-2.5v0A2.5 2.5 0 0017 4.5v0A2.5 2.5 0 0014.5 2h-5z"/></svg>
|
|
</div>
|
|
<p class="text-sm font-bold text-ink/40">Aucune analyse disponible</p>
|
|
<p class="text-xs text-ink/30 mt-1">Lancez une analyse IA pour obtenir un profil détaillé.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Tab: Évaluation & Entretien ── -->
|
|
<div v-if="activeTab === 'interview'" class="p-6 space-y-6">
|
|
<!-- Notes admin -->
|
|
<div>
|
|
<div class="flex items-center justify-between mb-3">
|
|
<p class="text-[9px] font-black uppercase tracking-[0.16em] text-ink/35">Notes & compte-rendu</p>
|
|
<button @click="isPreview = !isPreview" class="text-[10px] font-bold uppercase tracking-wider text-primary/60 hover:text-primary transition-colors">
|
|
{{ isPreview ? 'Éditer' : 'Prévisualiser' }}
|
|
</button>
|
|
</div>
|
|
<div v-if="!isPreview">
|
|
<textarea v-model="notesForm.notes" rows="6"
|
|
class="w-full rounded-xl border border-ink/10 bg-neutral px-4 py-3 text-sm font-semibold text-ink leading-relaxed focus:border-primary focus:ring-2 focus:ring-primary/15 outline-none resize-y transition-all"
|
|
placeholder="Saisir vos notes… (Markdown supporté)" />
|
|
</div>
|
|
<div v-else class="min-h-[120px] p-4 rounded-xl border border-ink/[0.07] bg-neutral prose prose-sm max-w-none text-ink/70" v-html="renderedNotes || '<p class=\'text-ink/30 italic\'>Aucune note.</p>'" />
|
|
</div>
|
|
|
|
<!-- Soft skills -->
|
|
<div>
|
|
<p class="text-[9px] font-black uppercase tracking-[0.16em] text-ink/35 mb-3">Soft Skills <span class="text-ink/20">/10</span></p>
|
|
<div class="space-y-3">
|
|
<div v-for="(skill, i) in notesForm.interview_details.soft_skills" :key="i" class="flex items-center gap-4 p-3 rounded-xl bg-neutral">
|
|
<span class="flex-1 text-xs font-semibold text-ink/70">{{ skill.name }}</span>
|
|
<div class="flex gap-1.5">
|
|
<button v-for="n in [0,2,4,6,8,10]" :key="n" @click="skill.score = n"
|
|
:class="['w-7 h-7 rounded-lg text-[10px] font-black transition-all border',
|
|
skill.score === n ? 'bg-primary text-white border-primary' : 'bg-surface border-ink/10 text-ink/40 hover:border-primary/40 hover:text-primary']">
|
|
{{ n }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Questions entretien -->
|
|
<div v-if="notesForm.interview_details.questions?.length">
|
|
<p class="text-[9px] font-black uppercase tracking-[0.16em] text-ink/35 mb-3">Questions d'entretien</p>
|
|
<div class="space-y-3">
|
|
<div v-for="(q, i) in notesForm.interview_details.questions" :key="i" class="p-4 rounded-xl border border-ink/[0.07] bg-neutral">
|
|
<p class="text-xs font-bold text-ink mb-3">{{ i + 1 }}. {{ q.question }}</p>
|
|
<div class="flex gap-3">
|
|
<div class="flex-1">
|
|
<label class="text-[9px] font-black uppercase tracking-wider text-ink/30 mb-1 block">Commentaire</label>
|
|
<input v-model="q.comment" type="text" placeholder="Réponse du candidat…"
|
|
class="w-full rounded-lg border border-ink/10 bg-surface px-3 py-2 text-xs font-semibold text-ink focus:border-primary focus:ring-2 focus:ring-primary/15 outline-none" />
|
|
</div>
|
|
<div class="w-20">
|
|
<label class="text-[9px] font-black uppercase tracking-wider text-ink/30 mb-1 block">Score /5</label>
|
|
<input v-model="q.score" type="number" min="0" max="5" step="0.5"
|
|
class="w-full rounded-lg border border-ink/10 bg-surface px-3 py-2 text-xs font-black text-primary focus:border-primary focus:ring-2 focus:ring-primary/15 outline-none text-center" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Save -->
|
|
<div class="flex justify-end">
|
|
<button @click="saveNotes" :disabled="notesForm.processing"
|
|
class="flex items-center gap-2 px-5 py-2.5 rounded-[10px] bg-highlight text-highlight-dark text-xs font-extrabold uppercase tracking-[0.08em] shadow-gold hover:brightness-105 transition-all disabled:opacity-50">
|
|
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
|
Enregistrer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Tab: Documents ── -->
|
|
<div v-if="activeTab === 'documents'" class="p-6 space-y-5">
|
|
<!-- Documents list -->
|
|
<div class="space-y-2.5">
|
|
<div v-for="doc in candidate.documents" :key="doc.id"
|
|
class="flex items-center gap-4 p-4 rounded-xl border border-ink/[0.07] hover:bg-neutral transition-colors">
|
|
<div :class="['w-10 h-10 rounded-xl flex items-center justify-center shrink-0', doc.type === 'cv' ? 'bg-primary/10' : 'bg-accent/10']">
|
|
<svg :class="['w-5 h-5', doc.type === 'cv' ? 'text-primary' : 'text-accent']" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
|
</svg>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-bold text-ink truncate">{{ doc.original_name }}</p>
|
|
<p class="text-xs text-ink/40 font-semibold">{{ doc.type === 'cv' ? 'Curriculum Vitæ' : 'Lettre de motivation' }}</p>
|
|
</div>
|
|
<a :href="route('admin.documents.show', doc.id)" target="_blank"
|
|
class="px-4 py-2 rounded-lg border border-ink/10 text-xs font-extrabold uppercase tracking-[0.07em] text-ink/60 hover:bg-primary/5 hover:border-primary hover:text-primary transition-all">
|
|
Voir
|
|
</a>
|
|
</div>
|
|
<div v-if="!candidate.documents?.length" class="text-center py-10 text-ink/30 text-sm font-semibold italic">
|
|
Aucun document uploadé.
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Document preview -->
|
|
<div v-if="selectedDocument" class="rounded-xl border border-ink/[0.07] overflow-hidden">
|
|
<iframe :src="route('admin.documents.show', selectedDocument.id)" class="w-full h-[500px] border-none" />
|
|
</div>
|
|
|
|
<!-- Upload form -->
|
|
<form @submit.prevent="updateDocuments" class="p-5 rounded-xl border border-ink/[0.07] bg-neutral space-y-4">
|
|
<p class="text-[9px] font-black uppercase tracking-[0.16em] text-ink/35">Uploader des documents</p>
|
|
<div class="grid md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label class="text-[9px] font-black uppercase tracking-[0.12em] text-ink/40 mb-2 block">CV (PDF)</label>
|
|
<input type="file" accept=".pdf" @change="docForm.cv = $event.target.files[0]"
|
|
class="w-full text-xs text-ink/60 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:bg-primary file:text-white file:text-xs file:font-bold file:uppercase file:tracking-wider hover:file:bg-primary-dark file:cursor-pointer" />
|
|
</div>
|
|
<div>
|
|
<label class="text-[9px] font-black uppercase tracking-[0.12em] text-ink/40 mb-2 block">Lettre de motivation (PDF)</label>
|
|
<input type="file" accept=".pdf" @change="docForm.cover_letter = $event.target.files[0]"
|
|
class="w-full text-xs text-ink/60 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:bg-primary file:text-white file:text-xs file:font-bold file:uppercase file:tracking-wider hover:file:bg-primary-dark file:cursor-pointer" />
|
|
</div>
|
|
</div>
|
|
<button type="submit" :disabled="docForm.processing"
|
|
class="px-5 py-2.5 rounded-[10px] bg-highlight text-highlight-dark text-xs font-extrabold uppercase tracking-[0.08em] shadow-gold hover:brightness-105 transition-all disabled:opacity-50">
|
|
Envoyer
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- ── Tab: Tests ── -->
|
|
<div v-if="activeTab === 'tests'" class="p-6 space-y-3">
|
|
<div v-if="!candidate.attempts?.length" class="text-center py-12 text-ink/30 text-sm font-semibold italic">
|
|
Aucune tentative de test enregistrée.
|
|
</div>
|
|
|
|
<div v-for="attempt in candidate.attempts" :key="attempt.id"
|
|
class="rounded-xl border border-ink/[0.07] overflow-hidden">
|
|
<!-- Attempt header -->
|
|
<button @click="toggleAttempt(attempt.id)"
|
|
class="w-full flex items-center justify-between p-4 hover:bg-neutral transition-colors text-left">
|
|
<div class="flex items-center gap-4">
|
|
<div :class="['w-9 h-9 rounded-lg flex items-center justify-center text-xs font-black shrink-0',
|
|
attempt.finished_at ? 'bg-success/12 text-success' : 'bg-highlight/15 text-highlight']">
|
|
{{ attempt.finished_at && attempt.max_score > 0 ? ((attempt.score / attempt.max_score) * 20).toFixed(1) : '…' }}
|
|
</div>
|
|
<div>
|
|
<p class="text-sm font-bold text-ink">{{ attempt.quiz?.title }}</p>
|
|
<p class="text-xs text-ink/40 font-semibold">{{ formatDateTime(attempt.started_at) }}</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<span :class="['text-[10px] font-black uppercase tracking-[0.12em] px-2.5 py-1 rounded-full',
|
|
attempt.finished_at ? 'bg-success/10 text-success' : 'bg-highlight/12 text-highlight']">
|
|
{{ attempt.finished_at ? 'Terminé' : 'En cours' }}
|
|
</span>
|
|
<svg :class="['w-4 h-4 text-ink/30 transition-transform duration-200', openAttempts.includes(attempt.id) ? 'rotate-180' : '']" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
|
|
</div>
|
|
</button>
|
|
|
|
<!-- Attempt detail -->
|
|
<div v-if="openAttempts.includes(attempt.id)" class="border-t border-ink/[0.07] p-4 bg-neutral space-y-3">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<span class="text-[9px] font-black uppercase tracking-[0.14em] text-ink/35">Détail des réponses</span>
|
|
<button @click="deleteAttempt(attempt.id)" class="text-[10px] font-bold text-accent/60 hover:text-accent uppercase tracking-wider transition-colors">
|
|
Supprimer
|
|
</button>
|
|
</div>
|
|
<div v-for="answer in attempt.answers" :key="answer.id"
|
|
class="p-3 rounded-xl bg-surface border border-ink/[0.07]">
|
|
<p class="text-xs font-bold text-ink mb-2">{{ answer.question?.label }}</p>
|
|
<div class="flex items-center justify-between gap-4">
|
|
<div class="flex-1">
|
|
<p class="text-xs text-ink/55 font-semibold">
|
|
{{ answer.option?.option_text || answer.text_content || '—' }}
|
|
</p>
|
|
<span v-if="answer.question?.type === 'qcm'" :class="['text-[9px] font-black uppercase tracking-wider mt-1 inline-block',
|
|
answer.option?.is_correct ? 'text-success' : 'text-accent']">
|
|
{{ answer.option?.is_correct ? '✓ Correct' : '✗ Incorrect' }}
|
|
</span>
|
|
</div>
|
|
<div v-if="answer.question?.type === 'open'" class="flex items-center gap-2 shrink-0">
|
|
<input type="number" :value="answer.score" min="0" :max="answer.question.points" step="0.5"
|
|
@change="updateAnswerScore(answer.id, $event.target.value, answer.question.points)"
|
|
class="w-16 rounded-lg border border-ink/10 bg-neutral px-2 py-1.5 text-xs font-black text-primary text-center focus:border-primary focus:ring-2 focus:ring-primary/15 outline-none" />
|
|
<span class="text-[10px] text-ink/30 font-bold">/ {{ answer.question.points }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div><!-- end tabs card -->
|
|
</div><!-- end right panel -->
|
|
</div><!-- end flex layout -->
|
|
|
|
<!-- ─── Modal: Éditer les infos ────────────────────────────────────── -->
|
|
<Modal :show="showEditDetailsModal" @close="showEditDetailsModal = false" max-width="lg">
|
|
<div class="p-6 space-y-5">
|
|
<h3 class="font-serif font-black text-lg text-primary">Modifier les informations</h3>
|
|
<div class="grid md:grid-cols-2 gap-4">
|
|
<div v-for="(field, key) in { name:'Nom complet', email:'Email', phone:'Téléphone', linkedin_url:'LinkedIn URL' }" :key="key">
|
|
<label class="text-[9px] font-black uppercase tracking-[0.14em] text-ink/35 mb-1.5 block">{{ field }}</label>
|
|
<input v-model="detailsForm[key]" type="text"
|
|
class="w-full rounded-[10px] border border-ink/10 bg-neutral px-3 py-2.5 text-sm font-semibold text-ink focus:border-primary focus:ring-2 focus:ring-primary/15 outline-none" />
|
|
<InputError :message="detailsForm.errors[key]" class="mt-1" />
|
|
</div>
|
|
</div>
|
|
<div class="flex justify-end gap-3 pt-2">
|
|
<SecondaryButton @click="showEditDetailsModal = false">Annuler</SecondaryButton>
|
|
<button @click="updateDetails" :disabled="detailsForm.processing"
|
|
class="px-5 py-2.5 rounded-[10px] bg-highlight text-highlight-dark text-xs font-extrabold uppercase tracking-[0.08em] shadow-gold hover:brightness-105 transition-all disabled:opacity-50">
|
|
Enregistrer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
<!-- ─── Modal: Erreur IA ────────────────────────────────────────────── -->
|
|
<Modal :show="showErrorModal" @close="showErrorModal = false" max-width="md">
|
|
<div class="p-6 space-y-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-10 h-10 rounded-full bg-accent/10 flex items-center justify-center shrink-0">
|
|
<svg class="w-5 h-5 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
</div>
|
|
<h3 class="font-serif font-black text-base text-ink">Erreur d'analyse</h3>
|
|
</div>
|
|
<p class="text-sm text-ink/60 font-semibold leading-relaxed">{{ modalErrorMessage }}</p>
|
|
<div class="flex justify-end">
|
|
<button @click="showErrorModal = false"
|
|
class="px-5 py-2.5 rounded-[10px] bg-accent text-white text-xs font-extrabold uppercase tracking-[0.08em] hover:brightness-110 transition-all">
|
|
Fermer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
</AdminLayout>
|
|
</template>
|