diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..5f15b9d --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(npm run *)" + ] + } +} diff --git a/app/Http/Controllers/AIAnalysisController.php b/app/Http/Controllers/AIAnalysisController.php index a5cea22..264157d 100644 --- a/app/Http/Controllers/AIAnalysisController.php +++ b/app/Http/Controllers/AIAnalysisController.php @@ -23,8 +23,8 @@ class AIAnalysisController extends Controller } // Restriction: Une analyse tous les 7 jours maximum par candidat - // Le super_admin peut outrepasser cette restriction via le paramètre 'force' - $shouldCheckRestriction = !($request->force && auth()->user()->isSuperAdmin()); + // Tout admin peut outrepasser cette restriction s'il utilise l'option 'force' + $shouldCheckRestriction = !$request->input('force', false); if ($shouldCheckRestriction && $candidate->ai_analysis && isset($candidate->ai_analysis['analyzed_at'])) { $lastAnalysis = Carbon::parse($candidate->ai_analysis['analyzed_at']); diff --git a/app/Http/Controllers/AttemptController.php b/app/Http/Controllers/AttemptController.php index 2886f96..2510d5b 100644 --- a/app/Http/Controllers/AttemptController.php +++ b/app/Http/Controllers/AttemptController.php @@ -149,7 +149,7 @@ class AttemptController extends Controller $this->authorizeAdmin(); $request->validate([ - 'score' => 'required|numeric|min:0' + 'score' => 'required|numeric|min:0|max:' . $answer->question->points ]); $answer->update(['score' => $request->score]); diff --git a/app/Http/Controllers/CandidateController.php b/app/Http/Controllers/CandidateController.php index e9916c4..ecd5331 100644 --- a/app/Http/Controllers/CandidateController.php +++ b/app/Http/Controllers/CandidateController.php @@ -103,12 +103,12 @@ class CandidateController extends Controller 'jobPositions' => \App\Models\JobPosition::all(), 'ai_config' => [ 'default' => env('AI_DEFAULT_PROVIDER', 'ollama'), - 'enabled_providers' => array_filter([ - 'ollama' => true, // Toujours dispo car local ou simulé + 'providers' => array_keys(array_filter([ + 'ollama' => true, 'openai' => !empty(env('OPENAI_API_KEY')), 'anthropic' => !empty(env('ANTHROPIC_API_KEY')), 'gemini' => !empty(env('GEMINI_API_KEY')), - ], function($v) { return $v; }) + ])), ] ]; diff --git a/app/Services/AIAnalysisService.php b/app/Services/AIAnalysisService.php index 5b7ad9e..4f03420 100644 --- a/app/Services/AIAnalysisService.php +++ b/app/Services/AIAnalysisService.php @@ -151,7 +151,19 @@ class AIAnalysisService if (isset($data['score_global']) && !isset($data['match_score'])) { $normalized['match_score'] = $data['score_global']; } + + if (isset($data['score']) && !isset($data['match_score'])) { + $normalized['match_score'] = $data['score']; + } + + if (isset($data['points_forts']) && !isset($data['strengths'])) { + $normalized['strengths'] = $data['points_forts']; + } + if (isset($data['points_faibles']) && !isset($data['gaps'])) { + $normalized['gaps'] = $data['points_faibles']; + } + if (isset($data['recommandation']) && !isset($data['verdict'])) { $normalized['verdict'] = $data['recommandation']; } @@ -159,14 +171,63 @@ class AIAnalysisService if (isset($data['synthese']) && !isset($data['summary'])) { $normalized['summary'] = $data['synthese']; } + + // List-specific normalization (handle list of objects or strings) + $cleanList = function($list) { + if (!is_array($list)) return []; + return array_map(function($item) { + if (is_array($item)) { + $type = $item['type'] ?? $item['title'] ?? $item['category'] ?? null; + $desc = $item['description'] ?? $item['value'] ?? $item['content'] ?? null; + if ($type && $desc) return "{$type} : {$desc}"; + if ($desc) return $desc; + if ($type) return $type; + return json_encode($item); + } + return (string) $item; + }, $list); + }; + + if (isset($normalized['strengths'])) { + $normalized['strengths'] = $cleanList($normalized['strengths']); + } - if (isset($data['points_vigilance']) && !isset($data['gaps'])) { - // Handle if points_vigilance is a list of objects (as in user's prompt) - if (is_array($data['points_vigilance']) && isset($data['points_vigilance'][0]) && is_array($data['points_vigilance'][0])) { - $normalized['gaps'] = array_map(fn($i) => ($i['type'] ?? '') . ': ' . ($i['description'] ?? ''), $data['points_vigilance']); - } else { - $normalized['gaps'] = $data['points_vigilance']; + if (isset($normalized['gaps'])) { + $normalized['gaps'] = $cleanList($normalized['gaps']); + } + + if (isset($normalized['elements_bloquants'])) { + $normalized['elements_bloquants'] = $cleanList($normalized['elements_bloquants']); + } + + // Ensure match_score is a numeric value and handle common AI formatting quirks + if (isset($normalized['match_score'])) { + $scoreValue = $normalized['match_score']; + + if (is_string($scoreValue)) { + // If AI returns something like "18/20", take the first part + if (str_contains($scoreValue, '/')) { + $scoreValue = explode('/', $scoreValue)[0]; + } + // Convert comma to dot for European decimals + $scoreValue = str_replace(',', '.', $scoreValue); + // Keep only digits and the first decimal point + $scoreValue = preg_replace('/[^0-9.]/', '', $scoreValue); } + + $num = (float)$scoreValue; + + // If the AI returned a ratio beneath 1 (e.g. 0.85 for 85%), scale it up + if ($num > 0 && $num < 1.1 && !is_int($normalized['match_score'])) { + // But be careful: a score of "1" might honestly be 1/100 + // but 0.95 is almost certainly a ratio. + if ($num < 1 || str_contains((string)$normalized['match_score'], '.')) { + $num *= 100; + } + } + + // Cap at 100 + $normalized['match_score'] = (int) min(100, round($num)); } // Ensure default keys exist even if empty @@ -175,6 +236,9 @@ class AIAnalysisService $normalized['verdict'] = $normalized['verdict'] ?? "Indéterminé"; $normalized['strengths'] = $normalized['strengths'] ?? []; $normalized['gaps'] = $normalized['gaps'] ?? []; + $normalized['scores_detailles'] = $normalized['scores_detailles'] ?? null; + $normalized['elements_bloquants'] = $normalized['elements_bloquants'] ?? []; + $normalized['questions_entretien_suggerees'] = $normalized['questions_entretien_suggerees'] ?? []; return $normalized; } diff --git a/config/ai.php b/config/ai.php index 5053638..10dab6a 100644 --- a/config/ai.php +++ b/config/ai.php @@ -22,7 +22,8 @@ return [ - gaps: liste des compétences manquantes ou points de vigilance - verdict: une conclusion (Favorable, Très Favorable, Réservé, Défavorable) - scores_detailles: un objet avec des clés (ex: technique, experience, soft_skills) contenant 'score' (0-100) et 'justification' - - elements_bloquants: liste des points critiques qui pourraient invalider la candidature (ou liste vide si aucun)", + - elements_bloquants: liste des points critiques qui pourraient invalider la candidature (ou liste vide si aucun) + - questions_entretien_suggerees: liste de 5 questions pertinentes à poser au candidat lors de l'entretien", ], 'providers' => [ diff --git a/resources/css/app.css b/resources/css/app.css index b5c61c9..83b3153 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,3 +1,4 @@ +@import './tokens.css'; @tailwind base; @tailwind components; -@tailwind utilities; +@tailwind utilities; \ No newline at end of file diff --git a/resources/css/tokens.css b/resources/css/tokens.css new file mode 100644 index 0000000..4df992f --- /dev/null +++ b/resources/css/tokens.css @@ -0,0 +1,271 @@ +/** + * design-tokens/tokens.css + * Variables CSS globales — RecruitQuizz / CABM + * À importer dans resources/css/app.css : + * @import '../../../design-tokens/tokens.css'; + */ + +/* ─── COULEURS ────────────────────────────────────────────────────────────── */ +:root { + /* Primaires */ + --color-primary: #1a4b8c; + --color-primary-dark: #122f5a; + --color-primary-light: #3a7abf; + --color-primary-soft: rgba(26, 75, 140, 0.08); + + /* Accent */ + --color-accent: #c8102e; + --color-accent-soft: rgba(200, 16, 46, 0.10); + + /* Or (highlight) */ + --color-gold: #f5a800; + --color-gold-soft: rgba(245, 168, 0, 0.12); + --color-gold-on: #3a2800; /* texte sur fond or */ + + /* Neutres warm */ + --color-bg: #f0ece4; /* fond global */ + --color-surface: #faf9f7; /* cartes */ + --color-sand: #e8e0d0; /* sable */ + --color-border: rgba(45, 45, 45, 0.07); + + /* Texte */ + --color-text: #2d2d2d; + --color-text-muted: rgba(45, 45, 45, 0.50); + --color-text-faint: rgba(45, 45, 45, 0.28); + + /* Sémantiques */ + --color-success: #10b981; + --color-success-soft: rgba(16, 185, 129, 0.10); + --color-warning: #f5a800; + --color-danger: #c8102e; + --color-info: #3a7abf; + + /* Sidebar */ + --sidebar-bg: #1a4b8c; + --sidebar-text: rgba(255, 255, 255, 0.72); + --sidebar-active-bg: #f5a800; + --sidebar-active-text: #3a2800; + --sidebar-border: rgba(255, 255, 255, 0.07); + --sidebar-width: 220px; + --sidebar-width-sm: 64px; + + /* Layout */ + --header-height: 60px; + --navbar-height: 40px; +} + +/* ─── DARK MODE ───────────────────────────────────────────────────────────── */ +.dark { + --color-primary: #4a8fd4; + --color-primary-soft: rgba(74, 143, 212, 0.12); + + --color-bg: #0f1923; + --color-surface: #162130; + --color-border: rgba(255, 255, 255, 0.06); + + --color-text: #e8e8e8; + --color-text-muted: rgba(232, 232, 232, 0.50); + --color-text-faint: rgba(232, 232, 232, 0.25); + + --color-success: #34d399; + --color-success-soft: rgba(16, 185, 129, 0.12); + --color-accent: #ff4d6a; + + --sidebar-bg: #0a111a; + --sidebar-border: rgba(255, 255, 255, 0.05); +} + +/* ─── TYPOGRAPHIE ─────────────────────────────────────────────────────────── */ +:root { + --font-sans: 'Nunito', 'Helvetica Neue', Arial, sans-serif; + --font-serif: 'Merriweather', Georgia, serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + + /* Échelle */ + --text-2xs: 0.625rem; /* 10px — labels uppercase */ + --text-xs: 0.75rem; /* 12px */ + --text-sm: 0.8125rem; /* 13px */ + --text-base: 0.875rem; /* 14px */ + --text-md: 1rem; /* 16px */ + --text-lg: 1.0625rem; /* 17px */ + --text-xl: 1.125rem; /* 18px */ + --text-2xl: 1.25rem; /* 20px */ + --text-3xl: 1.5rem; /* 24px */ + --text-4xl: 2rem; /* 32px */ +} + +/* ─── ESPACEMENT ──────────────────────────────────────────────────────────── */ +:root { + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-7: 28px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + --space-16: 64px; +} + +/* ─── RAYONS ──────────────────────────────────────────────────────────────── */ +:root { + --radius-sm: 6px; + --radius: 8px; + --radius-md: 10px; + --radius-lg: 12px; + --radius-xl: 14px; + --radius-2xl: 16px; + --radius-3xl: 20px; + --radius-full: 9999px; + /* Tokens sémantiques */ + --radius-card: 16px; + --radius-btn: 10px; + --radius-badge: 20px; + --radius-input: 10px; +} + +/* ─── OMBRES ──────────────────────────────────────────────────────────────── */ +:root { + --shadow-xs: 0 1px 3px rgba(0,0,0,0.05); + --shadow-sm: 0 1px 4px rgba(0,0,0,0.07); + --shadow: 0 2px 8px rgba(0,0,0,0.09); + --shadow-md: 0 4px 16px rgba(0,0,0,0.10); + --shadow-lg: 0 8px 28px rgba(0,0,0,0.12); + --shadow-primary: 0 4px 16px rgba(26,75,140,0.20); + --shadow-gold: 0 4px 16px rgba(245,168,0,0.30); + --shadow-accent: 0 4px 16px rgba(200,16,46,0.18); +} + +/* ─── TRANSITIONS ─────────────────────────────────────────────────────────── */ +:root { + --transition-fast: 120ms cubic-bezier(0.4, 0, 0.2, 1); + --transition: 180ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-spring: 300ms cubic-bezier(0.34, 1.56, 0.64, 1); +} + +/* ─── COMPOSANTS DE BASE ──────────────────────────────────────────────────── */ + +/* --- Carte --- */ +.rq-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + box-shadow: var(--shadow-sm); +} + +/* --- Bouton primaire (or) --- */ +.rq-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 20px; + border: none; + border-radius: var(--radius-btn); + font-family: var(--font-sans); + font-weight: 800; + font-size: var(--text-sm); + text-transform: uppercase; + letter-spacing: 0.08em; + cursor: pointer; + transition: all var(--transition); +} +.rq-btn-primary { + background: var(--color-gold); + color: var(--color-gold-on); + box-shadow: var(--shadow-gold); +} +.rq-btn-primary:hover { + filter: brightness(1.06); + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(245,168,0,0.40); +} +.rq-btn-ghost { + background: transparent; + color: var(--color-text); + border: 1.5px solid var(--color-border); +} +.rq-btn-ghost:hover { + background: var(--color-primary-soft); + border-color: var(--color-primary); + color: var(--color-primary); +} + +/* --- Input --- */ +.rq-input { + width: 100%; + padding: 10px 14px; + border-radius: var(--radius-input); + border: 1.5px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + font-family: var(--font-sans); + font-size: var(--text-base); + font-weight: 600; + outline: none; + transition: border-color var(--transition); +} +.rq-input:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-soft); +} + +/* --- Badge statut --- */ +.rq-badge { + display: inline-block; + padding: 3px 10px; + border-radius: var(--radius-badge); + font-size: var(--text-2xs); + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.12em; +} +.rq-badge--pending { background: rgba(45,45,45,0.06); color: rgba(45,45,45,0.55); } +.rq-badge--ongoing { background: rgba(58,122,191,0.10); color: #1a4b8c; } +.rq-badge--done { background: rgba(16,185,129,0.10); color: #059669; } +.rq-badge--rejected { background: rgba(200,16,46,0.10); color: #c8102e; } + +.dark .rq-badge--pending { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.45); } +.dark .rq-badge--ongoing { background: rgba(58,122,191,0.20); color: #7ab8f5; } +.dark .rq-badge--done { background: rgba(16,185,129,0.15); color: #34d399; } +.dark .rq-badge--rejected { background: rgba(255,77,106,0.15); color: #ff8099; } + +/* --- Label section uppercase --- */ +.rq-label { + font-size: var(--text-2xs); + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--color-text-faint); +} + +/* --- Score bar --- */ +.rq-score-bar { + height: 5px; + background: rgba(45,45,45,0.08); + border-radius: 99px; + overflow: hidden; +} +.rq-score-bar__fill { + height: 100%; + border-radius: 99px; + transition: width 0.4s ease; +} +.rq-score-bar__fill--high { background: var(--color-success); } +.rq-score-bar__fill--mid { background: var(--color-gold); } +.rq-score-bar__fill--low { background: var(--color-accent); } + +/* --- Animations utilitaires --- */ +@keyframes rq-fade-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes rq-slide-in { + from { opacity: 0; transform: translateX(-8px); } + to { opacity: 1; transform: translateX(0); } +} +.rq-animate-fade { animation: rq-fade-in 0.2s ease-out; } +.rq-animate-slide { animation: rq-slide-in 0.2s ease-out; } diff --git a/resources/js/Components/Rq/RqBadge.vue b/resources/js/Components/Rq/RqBadge.vue new file mode 100644 index 0000000..8ba5fa0 --- /dev/null +++ b/resources/js/Components/Rq/RqBadge.vue @@ -0,0 +1,35 @@ + + + diff --git a/resources/js/Components/Rq/RqButton.vue b/resources/js/Components/Rq/RqButton.vue new file mode 100644 index 0000000..8faffd5 --- /dev/null +++ b/resources/js/Components/Rq/RqButton.vue @@ -0,0 +1,45 @@ + + + diff --git a/resources/js/Components/Rq/RqCard.vue b/resources/js/Components/Rq/RqCard.vue new file mode 100644 index 0000000..c812746 --- /dev/null +++ b/resources/js/Components/Rq/RqCard.vue @@ -0,0 +1,33 @@ + + + diff --git a/resources/js/Components/Rq/RqInput.vue b/resources/js/Components/Rq/RqInput.vue new file mode 100644 index 0000000..1939fc3 --- /dev/null +++ b/resources/js/Components/Rq/RqInput.vue @@ -0,0 +1,65 @@ + + + diff --git a/resources/js/Components/Rq/RqScoreBar.vue b/resources/js/Components/Rq/RqScoreBar.vue new file mode 100644 index 0000000..b57887c --- /dev/null +++ b/resources/js/Components/Rq/RqScoreBar.vue @@ -0,0 +1,38 @@ + + + diff --git a/resources/js/Components/Rq/RqStatCard.vue b/resources/js/Components/Rq/RqStatCard.vue new file mode 100644 index 0000000..6619719 --- /dev/null +++ b/resources/js/Components/Rq/RqStatCard.vue @@ -0,0 +1,54 @@ + + + diff --git a/resources/js/Layouts/AdminLayout.vue b/resources/js/Layouts/AdminLayout.vue index 10d2830..4a26199 100644 --- a/resources/js/Layouts/AdminLayout.vue +++ b/resources/js/Layouts/AdminLayout.vue @@ -1,203 +1,264 @@ - diff --git a/resources/js/Layouts/AdminLayout.vue.backup b/resources/js/Layouts/AdminLayout.vue.backup new file mode 100644 index 0000000..10d2830 --- /dev/null +++ b/resources/js/Layouts/AdminLayout.vue.backup @@ -0,0 +1,203 @@ + + + + + diff --git a/resources/js/Pages/Admin/Candidates/Show.vue b/resources/js/Pages/Admin/Candidates/Show.vue index 286f246..fa80716 100644 --- a/resources/js/Pages/Admin/Candidates/Show.vue +++ b/resources/js/Pages/Admin/Candidates/Show.vue @@ -20,12 +20,9 @@ const props = defineProps({ 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 positionForm = useForm({ job_position_id: props.candidate.job_position_id || '' }); const showEditDetailsModal = ref(false); const detailsForm = useForm({ @@ -34,43 +31,20 @@ const detailsForm = useForm({ 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; - }, + onSuccess: () => { showEditDetailsModal.value = false; }, }); }; +const updatePosition = () => positionForm.patch(route('admin.candidates.update-position', props.candidate.id), { preserveScroll: true }); -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 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' // For file upload via PUT in Laravel -}); +const docForm = useForm({ cv: null, cover_letter: null, _method: 'PUT' }); const rawInterviewDetails = props.candidate.interview_details || {}; const notesForm = useForm({ @@ -80,7 +54,7 @@ const notesForm = useForm({ appreciation: rawInterviewDetails.appreciation || 0, soft_skills: rawInterviewDetails.soft_skills || [ { name: 'Communication & Pédagogie', score: 0 }, - { name: 'Esprit d\'équipe & Collaboration', 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 } @@ -96,246 +70,135 @@ const scoreForm = useForm({ const isPreview = ref(true); const renderedNotes = computed(() => marked.parse(notesForm.notes || '')); - const openAttempts = ref([]); - const toggleAttempt = (id) => { - if (openAttempts.value.includes(id)) { - openAttempts.value = openAttempts.value.filter(item => item !== id); - } else { - openAttempts.value.push(id); - } + openAttempts.value = openAttempts.value.includes(id) + ? openAttempts.value.filter(i => i !== id) + : [...openAttempts.value, 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 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('Voulez-vous vraiment supprimer ce candidat ? Toutes ses données seront DEFINITIVEMENT perdues.')) { + 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('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 - }); - } + 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 pour ce candidat ?')) { + 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 updateDocuments = () => docForm.post(route('admin.candidates.update', props.candidate.id), { onSuccess: () => docForm.reset() }); const saveScores = () => { - scoreForm.patch(route('admin.candidates.update-scores', props.candidate.id), { - preserveScroll: true, - }); + 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 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 updateAnswerScore = (answerId, score) => { - router.patch(route('admin.answers.update-score', answerId), { - score: score - }, { - 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; -// Calcul du score test (meilleure tentative ramenée sur 20) const bestTestScore = computed(() => { - if (!props.candidate.attempts || props.candidate.attempts.length === 0) return 0; + if (!props.candidate.attempts?.length) return 0; const finished = props.candidate.attempts.filter(a => a.finished_at && a.max_score > 0); - if (finished.length === 0) return 0; - return Math.max(...finished.map(a => (a.score / a.max_score) * 20)); + return finished.length ? Math.max(...finished.map(a => (a.score / a.max_score) * 20)) : 0; }); - -// Calculated Soft Skills average const softSkillsScore = computed(() => { const skills = notesForm.interview_details.soft_skills || []; - if (skills.length === 0) return 0; - const total = skills.reduce((acc, s) => acc + (parseFloat(s.score) || 0), 0); - return Number((total / skills.length).toFixed(1)); + if (!skills.length) return 0; + return Number((skills.reduce((a, s) => a + (parseFloat(s.score) || 0), 0) / skills.length).toFixed(1)); }); - -// Données radar normalisées en % (chaque axe / son max) 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), // Max is 10 for avg soft skills + Math.round((softSkillsScore.value / 10) * 100), ])); const buildRadarChart = () => { if (!radarCanvasRef.value) return; - if (radarChartInstance) { - radarChartInstance.destroy(); - radarChartInstance = null; - } - - const isDark = false; // Désactivation forcée du mode sombre - const gridColor = isDark ? 'rgba(148,163,184,0.15)' : 'rgba(100,116,139,0.15)'; - const labelColor = isDark ? '#94a3b8' : '#64748b'; - + 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 Candidat (%)', + label: 'Profil (%)', data: radarData.value, - backgroundColor: 'rgba(99,102,241,0.15)', - borderColor: 'rgba(99,102,241,0.9)', + backgroundColor: 'rgba(26,75,140,0.12)', + borderColor: '#1a4b8c', borderWidth: 2.5, - pointBackgroundColor: 'rgba(99,102,241,1)', + pointBackgroundColor: '#f5a800', pointBorderColor: '#fff', pointBorderWidth: 2, pointRadius: 5, pointHoverRadius: 7, - pointHoverBackgroundColor: 'rgba(139,92,246,1)', }] }, options: { - responsive: true, - maintainAspectRatio: true, - animation: { duration: 700, easing: 'easeInOutQuart' }, + responsive: true, maintainAspectRatio: true, + animation: { duration: 600, easing: 'easeInOutQuart' }, plugins: { legend: { display: false }, - tooltip: { - callbacks: { - label: (ctx) => ` ${ctx.raw}%`, - }, - backgroundColor: isDark ? '#1e293b' : '#fff', - titleColor: isDark ? '#e2e8f0' : '#0f172a', - bodyColor: isDark ? '#94a3b8' : '#475569', - borderColor: isDark ? '#334155' : '#e2e8f0', - borderWidth: 1, - padding: 10, - cornerRadius: 10, - } + 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: labelColor, - backdropColor: 'transparent', - font: { size: 9, weight: 'bold' }, - callback: (v) => v + '%', - }, - grid: { color: gridColor }, - angleLines: { color: gridColor }, - pointLabels: { - color: labelColor, - font: { size: 11, weight: 'bold' }, - } + 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()); +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()); }); -onUnmounted(() => { - if (radarChartInstance) radarChartInstance.destroy(); -}); - -// Mise à jour du radar quand les scores changent -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(); - } - } -); - -// Ré-initialisation du radar lors du switch d'onglet -watch(activeTab, (newTab) => { - if (newTab === '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); -// ─── Interview Scoring Logic ─────────────────────────────────────────────────── const calculatedInterviewScore = computed(() => { - const qScore = (notesForm.interview_details.questions || []).reduce((acc, q) => acc + (parseFloat(q.score) || 0), 0); - const appScore = parseFloat(notesForm.interview_details.appreciation) || 0; - return Math.min(30, qScore + appScore); + 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)); }); -// Auto-populate questions from AI analysis if empty -watch(aiAnalysis, (newVal) => { - if (newVal && newVal.questions_entretien_suggerees && (!notesForm.interview_details.questions || notesForm.interview_details.questions.length === 0)) { - notesForm.interview_details.questions = newVal.questions_entretien_suggerees.map(q => ({ - question: q, - score: 0, - comment: '' - })); +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 }); -// Sync with global score form and auto-save logic -watch(calculatedInterviewScore, (newVal) => { - scoreForm.interview_score = newVal; -}); +watch(calculatedInterviewScore, v => { scoreForm.interview_score = v; }); -const saveNotes = () => { - notesForm.transform((data) => ({ - ...data, - interview_score: calculatedInterviewScore.value - })).patch(route('admin.candidates.update-notes', props.candidate.id), { - preserveScroll: true, - onSuccess: () => { - // Update raw candidate data to reflect the new score in computed fields if necessary - props.candidate.interview_score = calculatedInterviewScore.value; - props.candidate.interview_details = notesForm.interview_details; - } - }); -}; +const saveNotes = () => notesForm.transform(d => ({ ...d, interview_score: calculatedInterviewScore.value })) + .patch(route('admin.candidates.update-notes', props.candidate.id), { preserveScroll: true }); -// Error Modal state const showErrorModal = ref(false); -const modalErrorMessage = ref(""); +const modalErrorMessage = ref(''); const runAI = async () => { if (!props.candidate.job_position_id) { @@ -343,1093 +206,612 @@ const runAI = async () => { showErrorModal.value = true; return; } - isAnalyzing.value = true; try { - const response = await axios.post(route('admin.candidates.analyze', props.candidate.id), { - provider: selectedProvider.value, - force: forceAnalysis.value - }); - aiAnalysis.value = response.data; - } catch (error) { - console.error('AI Analysis Error:', error); - modalErrorMessage.value = error.response?.data?.error || "Une erreur est survenue lors de l'analyse."; + 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; - } + } 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'; diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 558aac4..2fa4332 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -10,6 +10,9 @@ + + + @routes diff --git a/tailwind.config.js b/tailwind.config.js index 2c5b836..e8ef2c2 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -14,19 +14,147 @@ export default { theme: { extend: { + // ─── COULEURS ──────────────────────────────────────────────── colors: { - primary: '#1a4b8c', // Bleu Méditerranée - accent: '#c8102e', // Rouge Occitan - highlight: '#f5a800', // Or du Midi - sky: '#3a7abf', // Bleu ciel - sand: '#e8e0d0', // Sable garrigue - anthracite: '#2d2d2d', // Anthracite - neutral: '#f0ece4', // Fond neutre + // Palette principale — CABM + primary: { + DEFAULT: '#1a4b8c', // Bleu Méditerranée + dark: '#122f5a', // Bleu nuit + light: '#3a7abf', // Bleu ciel (ex: sky) + }, + accent: { + DEFAULT: '#c8102e', // Rouge Occitan + soft: 'rgba(200,16,46,0.10)', + }, + highlight: { + DEFAULT: '#f5a800', // Or du Midi + soft: 'rgba(245,168,0,0.12)', + dark: '#3a2800', // Texte sur fond or + }, + // Neutres chauds + sand: '#e8e0d0', // Sable garrigue (ex: sand) + neutral: '#f0ece4', // Fond neutre warm + surface: '#faf9f7', // Surface carte (ex: white) + // Texte + ink: { + DEFAULT: '#2d2d2d', // Anthracite + muted: 'rgba(45,45,45,0.50)', + faint: 'rgba(45,45,45,0.28)', + }, + // Dark mode + dark: { + bg: '#0f1923', + surface: '#162130', + sidebar: '#0a111a', + border: 'rgba(255,255,255,0.06)', + }, + // Sémantiques + success: '#10b981', + warning: '#f5a800', + danger: '#c8102e', + info: '#3a7abf', + + // Compat rétro (garde les anciens noms fonctionnels) + anthracite: '#2d2d2d', + sky: '#3a7abf', }, + + // ─── TYPOGRAPHIE ───────────────────────────────────────────── fontFamily: { - sans: ['Helvetica Neue', 'Arial', 'sans-serif'], - serif: ['Merriweather', 'Georgia', 'serif'], + sans: ['Nunito', 'Helvetica Neue', 'Arial', 'sans-serif'], + serif: ['Merriweather', 'Georgia', 'serif'], subtitle: ['Nunito', 'Gill Sans', 'sans-serif'], + mono: ['JetBrains Mono', 'Fira Code', 'monospace'], + }, + fontSize: { + '2xs': ['0.625rem', { lineHeight: '1rem', letterSpacing: '0.12em' }], // 10px — labels caps + 'xs': ['0.75rem', { lineHeight: '1.25rem' }], // 12px + 'sm': ['0.8125rem',{ lineHeight: '1.375rem' }], // 13px + 'base':['0.875rem', { lineHeight: '1.5rem' }], // 14px + 'md': ['1rem', { lineHeight: '1.625rem' }], // 16px + 'lg': ['1.0625rem',{ lineHeight: '1.75rem' }], // 17px + 'xl': ['1.125rem', { lineHeight: '1.75rem' }], // 18px + '2xl': ['1.25rem', { lineHeight: '1.875rem' }], // 20px + '3xl': ['1.5rem', { lineHeight: '2rem' }], // 24px + '4xl': ['2rem', { lineHeight: '2.25rem', letterSpacing: '-0.02em'}], // 32px + }, + + // ─── ESPACEMENT ────────────────────────────────────────────── + // Garde l'échelle Tailwind standard + quelques tokens nommés + spacing: { + 'sidebar': '220px', + 'sidebar-sm': '64px', + 'header': '60px', + 'navbar': '40px', + }, + + // ─── BORDER RADIUS ─────────────────────────────────────────── + borderRadius: { + 'none': '0', + 'sm': '6px', + 'DEFAULT':'8px', + 'md': '10px', + 'lg': '12px', + 'xl': '14px', + '2xl': '16px', + '3xl': '20px', + '4xl': '28px', + 'full': '9999px', + // Tokens sémantiques + 'card': '16px', + 'btn': '10px', + 'badge': '20px', + 'input': '10px', + }, + + // ─── OMBRES ────────────────────────────────────────────────── + boxShadow: { + 'xs': '0 1px 3px rgba(0,0,0,0.05)', + 'sm': '0 1px 4px rgba(0,0,0,0.07)', + 'DEFAULT':'0 2px 8px rgba(0,0,0,0.09)', + 'md': '0 4px 16px rgba(0,0,0,0.10)', + 'lg': '0 8px 28px rgba(0,0,0,0.12)', + 'xl': '0 16px 48px rgba(0,0,0,0.14)', + // Sémantiques couleurs + 'primary': '0 4px 16px rgba(26,75,140,0.20)', + 'gold': '0 4px 16px rgba(245,168,0,0.30)', + 'accent': '0 4px 16px rgba(200,16,46,0.18)', + // Dark mode + 'dark-sm': '0 2px 12px rgba(0,0,0,0.30)', + 'dark-md': '0 4px 24px rgba(0,0,0,0.40)', + 'none': 'none', + }, + + // ─── TRANSITIONS ───────────────────────────────────────────── + transitionDuration: { + 'fast': '120ms', + 'DEFAULT': '180ms', + 'slow': '300ms', + }, + transitionTimingFunction: { + 'smooth': 'cubic-bezier(0.4, 0, 0.2, 1)', + 'spring': 'cubic-bezier(0.34, 1.56, 0.64, 1)', + }, + + // ─── ANIMATIONS ────────────────────────────────────────────── + keyframes: { + 'fade-in': { + from: { opacity: '0', transform: 'translateY(6px)' }, + to: { opacity: '1', transform: 'translateY(0)' }, + }, + 'slide-in': { + from: { opacity: '0', transform: 'translateX(-8px)' }, + to: { opacity: '1', transform: 'translateX(0)' }, + }, + 'pulse-gold': { + '0%, 100%': { boxShadow: '0 0 0 0 rgba(245,168,0,0.3)' }, + '50%': { boxShadow: '0 0 0 6px rgba(245,168,0,0)' }, + }, + }, + animation: { + 'fade-in': 'fade-in 0.2s ease-out', + 'slide-in': 'slide-in 0.2s ease-out', + 'pulse-gold':'pulse-gold 2s ease-in-out infinite', }, }, },