Refactoring AI candidate analysis: UI improvements, data normalization, provider management and real-time score clamping

This commit is contained in:
jeremy bayse
2026-04-21 06:41:37 +02:00
parent abfe01190b
commit 2216de1a02
19 changed files with 1793 additions and 1402 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npm run *)"
]
}
}

View File

@@ -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']);

View File

@@ -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]);

View File

@@ -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; })
])),
]
];

View File

@@ -152,6 +152,18 @@ class AIAnalysisService
$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'];
}
@@ -160,13 +172,62 @@ class AIAnalysisService
$normalized['summary'] = $data['synthese'];
}
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'];
// 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($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;
}

View File

@@ -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' => [

View File

@@ -1,3 +1,4 @@
@import './tokens.css';
@tailwind base;
@tailwind components;
@tailwind utilities;

271
resources/css/tokens.css Normal file
View File

@@ -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; }

View File

@@ -0,0 +1,35 @@
<script setup>
/**
* RqBadge.vue — Badge de statut candidat
*
* @prop status 'en_attente' | 'en_cours' | 'termine' | 'refuse'
* @prop label Override du texte affiché (optionnel)
*/
const props = defineProps({
status: { type: String, required: true },
label: { type: String, default: null },
});
const labels = {
en_attente: 'En attente',
en_cours: 'En cours',
termine: 'Terminé',
refuse: 'Refusé',
};
const displayLabel = props.label ?? labels[props.status] ?? props.status;
</script>
<template>
<span
:class="[
'inline-block px-2.5 py-0.5 rounded-full text-2xs font-black uppercase tracking-[0.12em]',
status === 'en_attente' && 'bg-ink/5 text-ink/55',
status === 'en_cours' && 'bg-primary/10 text-primary',
status === 'termine' && 'bg-success/10 text-success',
status === 'refuse' && 'bg-accent/10 text-accent',
]"
>
{{ displayLabel }}
</span>
</template>

View File

@@ -0,0 +1,45 @@
<script setup>
/**
* RqButton.vue — Bouton principal RecruitQuizz
*
* @prop variant 'primary' | 'ghost' | 'danger' | 'outline'
* @prop size 'sm' | 'md' | 'lg'
* @prop disabled Boolean
* @prop loading Boolean — affiche un spinner
* @prop icon SVG path string optionnel (gauche du label)
*/
defineProps({
variant: { type: String, default: 'primary' },
size: { type: String, default: 'md' },
disabled: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
});
</script>
<template>
<button
v-bind="$attrs"
:disabled="disabled || loading"
:class="[
// Base
'inline-flex items-center justify-center gap-1.5 font-sans font-extrabold uppercase tracking-[0.08em] transition-all duration-150 select-none focus:outline-none focus:ring-2 focus:ring-offset-2 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed',
// Size
size === 'sm' && 'text-2xs px-3.5 py-2 rounded-lg',
size === 'md' && 'text-xs px-5 py-2.5 rounded-[10px]',
size === 'lg' && 'text-sm px-7 py-3.5 rounded-[10px]',
// Variant
variant === 'primary' && 'bg-highlight text-highlight-dark shadow-gold hover:brightness-105 hover:-translate-y-px hover:shadow-lg focus:ring-highlight/50',
variant === 'ghost' && 'bg-transparent text-ink border border-ink/10 hover:bg-primary/5 hover:border-primary hover:text-primary focus:ring-primary/30',
variant === 'outline' && 'bg-transparent text-primary border-2 border-primary hover:bg-primary hover:text-white focus:ring-primary/30',
variant === 'danger' && 'bg-accent/10 text-accent border border-accent/20 hover:bg-accent hover:text-white focus:ring-accent/30',
]"
>
<!-- Spinner -->
<svg v-if="loading" class="animate-spin h-4 w-4 opacity-70" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
<slot />
</button>
</template>

View File

@@ -0,0 +1,33 @@
<script setup>
/**
* RqCard.vue — Carte conteneur RecruitQuizz
*
* @prop padding 'none' | 'sm' | 'md' | 'lg'
* @prop hover Boolean — active l'effet de survol (lift)
* @prop accent 'primary' | 'gold' | 'green' | 'red' — bordure colorée en bas
*/
defineProps({
padding: { type: String, default: 'md' },
hover: { type: Boolean, default: false },
accent: { type: String, default: null },
});
</script>
<template>
<div
:class="[
'bg-surface rounded-2xl border border-ink/[0.07] shadow-sm',
hover && 'transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
accent === 'primary' && 'border-b-2 border-b-primary',
accent === 'gold' && 'border-b-2 border-b-highlight',
accent === 'green' && 'border-b-2 border-b-success',
accent === 'red' && 'border-b-2 border-b-accent',
padding === 'none' && 'overflow-hidden',
padding === 'sm' && 'p-4',
padding === 'md' && 'p-5',
padding === 'lg' && 'p-6',
]"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,65 @@
<script setup>
/**
* RqInput.vue — Champ de saisie RecruitQuizz
*
* @prop modelValue String (v-model)
* @prop placeholder String
* @prop type String (text, email, password, search…)
* @prop icon 'search' | 'mail' | 'lock' — icône préfixe
* @prop error String — message d'erreur
* @prop label String — label au-dessus du champ
*/
import { computed } from 'vue';
const props = defineProps({
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '' },
type: { type: String, default: 'text' },
icon: { type: String, default: null },
error: { type: String, default: null },
label: { type: String, default: null },
});
const emit = defineEmits(['update:modelValue']);
const iconPaths = {
search: 'M11 17.25a6.25 6.25 0 110-12.5 6.25 6.25 0 010 12.5zM16 16l4.5 4.5',
mail: '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',
lock: 'M19 11H5a2 2 0 00-2 2v7a2 2 0 002 2h14a2 2 0 002-2v-7a2 2 0 00-2-2zM7 11V7a5 5 0 0110 0v4',
};
</script>
<template>
<div class="flex flex-col gap-1.5 w-full">
<!-- Label -->
<label v-if="label" class="text-2xs font-black uppercase tracking-[0.14em] text-ink/50">
{{ label }}
</label>
<!-- Input wrapper -->
<div class="relative">
<!-- Icon -->
<span v-if="icon" class="absolute left-3 top-1/2 -translate-y-1/2 text-ink/30 pointer-events-none">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path :d="iconPaths[icon]" />
</svg>
</span>
<input
:type="type"
:value="modelValue"
:placeholder="placeholder"
@input="emit('update:modelValue', $event.target.value)"
:class="[
'w-full rounded-[10px] border bg-surface font-sans text-sm font-semibold text-ink placeholder:text-ink/30 outline-none transition-all duration-150',
'focus:border-primary focus:ring-2 focus:ring-primary/15',
icon ? 'pl-9 pr-4 py-2.5' : 'px-4 py-2.5',
error ? 'border-accent focus:border-accent focus:ring-accent/15' : 'border-ink/10',
]"
/>
</div>
<!-- Error -->
<p v-if="error" class="text-2xs font-bold text-accent">{{ error }}</p>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script setup>
/**
* RqScoreBar.vue — Barre de score pondéré
*
* @prop value Number — score actuel
* @prop max Number — score maximum (défaut 20)
* @prop showLabel Boolean — affiche "X/max" à droite
*/
import { computed } from 'vue';
const props = defineProps({
value: { type: Number, required: true },
max: { type: Number, default: 20 },
showLabel: { type: Boolean, default: true },
});
const pct = computed(() => Math.min(100, (props.value / props.max) * 100));
const colorClass = computed(() => {
if (pct.value >= 80) return 'bg-success';
if (pct.value >= 60) return 'bg-highlight';
return 'bg-accent';
});
</script>
<template>
<div class="flex items-center gap-2.5 w-full">
<div class="flex-1 h-[5px] bg-ink/[0.07] rounded-full overflow-hidden">
<div
:class="['h-full rounded-full transition-all duration-500', colorClass]"
:style="{ width: `${pct}%` }"
/>
</div>
<span v-if="showLabel" class="text-xs font-black text-ink tabular-nums min-w-[44px] text-right">
{{ value }}<span class="text-[9px] text-ink/40 font-semibold">/{{ max }}</span>
</span>
</div>
</template>

View File

@@ -0,0 +1,54 @@
<script setup>
/**
* RqStatCard.vue — Carte KPI dashboard admin
*
* @prop label String — libellé (ex: "Total Candidats")
* @prop value String | Number — valeur principale
* @prop sub String — sous-texte (ex: "+3 ce mois")
* @prop color 'primary' | 'gold' | 'green' | 'sky' | 'red'
* @prop unit String — unité affichée après la valeur (ex: "/ 20")
*/
import { computed } from 'vue';
const props = defineProps({
label: { type: String, required: true },
value: { type: [String, Number], required: true },
sub: { type: String, default: null },
color: { type: String, default: 'primary' },
unit: { type: String, default: null },
});
const colorMap = {
primary: { text: 'text-primary', glow: 'from-primary/10' },
gold: { text: 'text-highlight', glow: 'from-highlight/15' },
green: { text: 'text-success', glow: 'from-success/10' },
sky: { text: 'text-primary-light', glow: 'from-primary-light/10' },
red: { text: 'text-accent', glow: 'from-accent/10' },
};
const c = computed(() => colorMap[props.color] ?? colorMap.primary);
</script>
<template>
<div class="relative bg-surface rounded-2xl border border-ink/[0.07] shadow-sm p-5 overflow-hidden
transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md group cursor-default">
<!-- Label -->
<p class="text-2xs font-black uppercase tracking-[0.18em] text-ink/40 mb-2.5">
{{ label }}
</p>
<!-- Value -->
<p :class="['font-serif font-black leading-none', c.text]"
style="font-size: clamp(1.75rem, 2.5vw, 2.25rem);">
{{ value }}
<span v-if="unit" class="text-base font-sans font-bold text-ink/30 ml-1">{{ unit }}</span>
</p>
<!-- Sub -->
<p v-if="sub" class="mt-1.5 text-2xs font-semibold text-ink/40">{{ sub }}</p>
<!-- Decorative blob -->
<div :class="['absolute bottom-0 right-0 w-20 h-20 rounded-full bg-gradient-radial to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500', c.glow]" />
</div>
</template>

View File

@@ -1,203 +1,264 @@
<script setup>
/**
* AdminLayout.vue — Layout principal admin redesigné
* RecruitQuizz v2.4
*
* Changements vs version précédente :
* - Sidebar 220px (collapsible → 64px) avec transition douce
* - Header 60px épuré, titre avec accent bar or
* - Fond neutral (#f0ece4) sur le contenu principal
* - Items nav : rounded-xl, active bg-highlight text-highlight-dark
* - Footer sidebar : avatar + nom + version + logout
*/
import { ref } from 'vue';
import { Link } from '@inertiajs/vue3';
import ApplicationLogo from '@/Components/ApplicationLogo.vue';
import { Link, usePage } from '@inertiajs/vue3';
import Dropdown from '@/Components/Dropdown.vue';
import DropdownLink from '@/Components/DropdownLink.vue';
import EnvironmentBanner from '@/Components/EnvironmentBanner.vue';
const page = usePage();
const isSidebarOpen = ref(true);
// ─── Navigation ────────────────────────────────────────────────────────────
const navItems = [
{
route: 'dashboard',
label: 'Tableau de bord',
icon: 'M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2V9z M9 22V12h6v10',
},
{
route: 'admin.candidates.index',
match: 'admin.candidates.*',
label: 'Candidats',
icon: 'M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2 M9 11a4 4 0 100-8 4 4 0 000 8z M23 21v-2a4 4 0 00-3-3.87 M16 3.13a4 4 0 010 7.75',
},
{
route: 'admin.quizzes.index',
match: 'admin.quizzes.*',
label: 'Quiz',
icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2M9 12l2 2 4-4',
},
{
route: 'admin.job-positions.index',
match: 'admin.job-positions.*',
label: 'Fiches de Poste',
icon: 'M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z',
},
{
route: 'admin.comparative',
label: 'Comparateur',
icon: 'M18 20V10 M12 20V4 M6 20v-6',
},
];
const superAdminItems = [
{
route: 'admin.tenants.index',
match: 'admin.tenants.*',
label: 'Structures',
icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
},
{
route: 'admin.users.index',
match: 'admin.users.*',
label: 'Équipe SaaS',
icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z',
},
{
route: 'admin.logs.index',
match: 'admin.logs.*',
label: 'Logs connexion',
icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01',
},
];
const isActive = (item) => {
if (item.match) return route().current(item.match);
return route().current(item.route);
};
</script>
<template>
<EnvironmentBanner />
<div class="min-h-screen bg-sand flex text-anthracite font-sans selection:bg-highlight selection:text-anthracite">
<!-- Sidebar -->
<div class="min-h-screen flex bg-neutral font-sans text-ink selection:bg-highlight selection:text-highlight-dark">
<!-- Sidebar -->
<aside
:class="[isSidebarOpen ? 'w-64' : 'w-20']"
class="hidden md:flex flex-col bg-primary transition-all duration-300 shadow-xl z-20"
:class="[
isSidebarOpen ? 'w-[220px]' : 'w-16',
'hidden md:flex flex-col bg-primary shadow-xl z-20 transition-all duration-300 shrink-0'
]"
>
<div class="h-16 flex items-center px-5 bg-primary/90 border-b border-white/10">
<Link :href="route('dashboard')" class="flex items-center gap-3 overflow-hidden ml-1">
<div class="w-8 h-8 bg-highlight rounded flex items-center justify-center shrink-0 shadow-sm shadow-highlight/20">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#3a2800]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9l-.707.707M12 18v3m4.95-4.95l.707.707M12 3c-4.418 0-8 3.582-8 8 0 2.209.895 4.209 2.343 5.657L12 21l5.657-5.343A7.994 7.994 0 0020 11c0-4.418-3.582-8-8-8z" />
<!-- Logo -->
<div class="h-[60px] flex items-center border-b border-white/[0.07] px-4 shrink-0">
<Link :href="route('dashboard')" class="flex items-center gap-3 overflow-hidden">
<!-- Icône -->
<div class="w-[30px] h-[30px] bg-highlight rounded-lg flex items-center justify-center shrink-0 shadow-gold">
<svg class="w-4 h-4 text-highlight-dark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.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>
</div>
<span v-if="isSidebarOpen" class="font-serif font-black text-xl tracking-tight whitespace-nowrap text-white">RECRU<span class="text-accent italic px-0.5">IT</span></span>
<!-- Wordmark -->
<Transition name="fade">
<span v-if="isSidebarOpen" class="font-serif font-black text-[17px] text-white tracking-tight whitespace-nowrap">
RECRU<span class="text-highlight italic">IT</span>
</span>
</Transition>
</Link>
</div>
<nav class="flex-1 py-6 px-3 space-y-1.5 overflow-y-auto custom-scrollbar">
<!-- Nav principale -->
<nav class="flex-1 px-2.5 py-4 space-y-0.5 overflow-y-auto scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent">
<!-- Items principaux -->
<template v-for="item in navItems" :key="item.route">
<Link
:href="route('dashboard')"
class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('dashboard') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
:href="route(item.route)"
:title="!isSidebarOpen ? item.label : undefined"
:class="[
'flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-150 font-sans font-bold text-[12.5px] tracking-[0.01em]',
isSidebarOpen ? '' : 'justify-center',
isActive(item)
? 'bg-highlight text-highlight-dark shadow-md shadow-highlight/20'
: 'text-white/70 hover:bg-white/10 hover:text-white',
]"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
<svg class="w-[17px] h-[17px] shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path :d="item.icon"/>
</svg>
<span v-if="isSidebarOpen" class="truncate">Tableau de bord</span>
<span v-if="isSidebarOpen" class="truncate">{{ item.label }}</span>
</Link>
</template>
<Link
:href="route('admin.candidates.index')"
class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('admin.candidates.*') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<span v-if="isSidebarOpen" class="truncate">Candidats</span>
</Link>
<Link
:href="route('admin.quizzes.index')"
class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('admin.quizzes.*') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span v-if="isSidebarOpen" class="truncate">Quiz</span>
</Link>
<Link
:href="route('admin.job-positions.index')"
class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('admin.job-positions.*') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" 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>
<span v-if="isSidebarOpen" class="truncate">Fiches de Poste</span>
</Link>
<Link
:href="route('admin.comparative')"
class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('admin.comparative') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<span v-if="isSidebarOpen" class="truncate">Comparateur</span>
</Link>
<div v-if="$page.props.auth.user.role === 'super_admin'" class="pt-4 pb-2">
<div v-show="isSidebarOpen" class="px-3 text-[10px] font-black uppercase tracking-widest text-white/30">Configuration</div>
<div v-show="!isSidebarOpen" class="h-[1px] w-8 mx-auto bg-white/10"></div>
<!-- Section super admin -->
<template v-if="$page.props.auth.user.role === 'super_admin'">
<div class="pt-4 pb-2">
<div
v-if="isSidebarOpen"
class="px-3 text-[9px] font-black uppercase tracking-[0.18em] text-white/25"
>Configuration</div>
<div v-else class="h-px w-8 mx-auto bg-white/10" />
</div>
<template v-for="item in superAdminItems" :key="item.route">
<Link
v-if="$page.props.auth.user.role === 'super_admin'"
:href="route('admin.tenants.index')"
class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('admin.tenants.*') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
:href="route(item.route)"
:title="!isSidebarOpen ? item.label : undefined"
:class="[
'flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-150 font-sans font-bold text-[12.5px] tracking-[0.01em]',
isSidebarOpen ? '' : 'justify-center',
isActive(item)
? 'bg-highlight text-highlight-dark shadow-md shadow-highlight/20'
: 'text-white/70 hover:bg-white/10 hover:text-white',
]"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
<svg class="w-[17px] h-[17px] shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path :d="item.icon"/>
</svg>
<span v-if="isSidebarOpen" class="truncate">Structures</span>
</Link>
<Link
v-if="$page.props.auth.user.role === 'super_admin'"
:href="route('admin.users.index')"
class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('admin.users.*') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<span v-if="isSidebarOpen" class="truncate">Équipe SaaS</span>
</Link>
<Link
v-if="$page.props.auth.user.role === 'super_admin'"
:href="route('admin.logs.index')"
class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('admin.logs.*') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
<span v-if="isSidebarOpen" class="truncate">Logs de connexion</span>
<span v-if="isSidebarOpen" class="truncate">{{ item.label }}</span>
</Link>
</template>
</template>
</nav>
<div class="p-4 border-t border-white/10 bg-primary/80">
<button
@click="isSidebarOpen = !isSidebarOpen"
class="flex items-center justify-center w-full h-10 rounded-lg text-white/50 hover:bg-white/10 hover:text-white transition-all duration-300"
>
<svg v-if="isSidebarOpen" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
<div v-show="isSidebarOpen" class="mt-4 text-[9px] font-bold uppercase tracking-widest text-[#3a7abf] text-center">
App v{{ $page.props.app_version }}
</div>
</div>
</aside>
<!-- Main Content -->
<div class="flex-1 flex flex-col min-w-0 overflow-hidden bg-neutral">
<header class="h-16 shrink-0 flex items-center justify-between px-8 bg-white border-b border-anthracite/5 shadow-sm z-10 relative">
<div>
<h2 v-if="$slots.header" class="font-serif font-black text-xl text-primary capitalize tracking-tight flex items-center gap-3">
<div class="w-1.5 h-6 bg-highlight rounded-full hidden md:block"></div>
<slot name="header" />
</h2>
</div>
<div class="flex items-center gap-4">
<Dropdown align="right" width="48">
<template #trigger>
<button class="flex items-center gap-2 text-sm font-subtitle font-bold hover:text-primary transition-colors cursor-pointer py-2">
<div class="w-8 h-8 rounded-full bg-sand flex items-center justify-center text-primary border border-primary/10">
<!-- Footer sidebar : user + collapse -->
<div class="px-3 py-3 border-t border-white/[0.07] shrink-0">
<!-- User info (sidebar ouverte) -->
<div v-if="isSidebarOpen" class="flex items-center gap-2.5 mb-3">
<div class="w-8 h-8 rounded-full bg-highlight flex items-center justify-center text-[12px] font-black text-highlight-dark shrink-0">
{{ $page.props.auth.user.name.charAt(0) }}
</div>
<span class="hidden md:block">{{ $page.props.auth.user.name }}</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-anthracite/40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
<div class="overflow-hidden flex-1 min-w-0">
<div class="text-[12px] font-bold text-white truncate">{{ $page.props.auth.user.name }}</div>
<div class="text-[10px] text-white/40 truncate">{{ $page.props.auth.user.role }}</div>
</div>
<Dropdown align="right" width="48">
<template #trigger>
<button class="p-1.5 rounded-lg text-white/40 hover:text-white hover:bg-white/10 transition-colors">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>
</svg>
</button>
</template>
<template #content>
<DropdownLink :href="route('profile.edit')">Paramètres du profil</DropdownLink>
<DropdownLink :href="route('admin.backup')" as="a" class="!text-sky font-bold" v-if="$page.props.auth.user.role === 'super_admin'">Sauvegarde Base de données</DropdownLink>
<div class="border-t border-anthracite/5 my-1"></div>
<DropdownLink :href="route('logout')" method="post" as="button" class="!text-accent font-bold">Se déconnecter</DropdownLink>
<DropdownLink
v-if="$page.props.auth.user.role === 'super_admin'"
:href="route('admin.backup')"
as="a"
class="!text-primary-light font-bold"
>Sauvegarde BDD</DropdownLink>
<div class="border-t border-ink/5 my-1"/>
<DropdownLink :href="route('logout')" method="post" as="button" class="!text-accent font-bold">
Se déconnecter
</DropdownLink>
</template>
</Dropdown>
</div>
<!-- Collapse button -->
<button
@click="isSidebarOpen = !isSidebarOpen"
class="flex items-center justify-center w-full h-9 rounded-xl text-white/40 hover:bg-white/10 hover:text-white transition-all duration-200"
:title="isSidebarOpen ? 'Réduire' : 'Agrandir'"
>
<svg class="w-4 h-4 transition-transform duration-300" :class="isSidebarOpen ? '' : 'rotate-180'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 18l-6-6 6-6"/>
</svg>
</button>
<!-- Version -->
<div v-if="isSidebarOpen" class="mt-2 text-center text-[9px] font-bold uppercase tracking-[0.12em] text-white/20">
v{{ $page.props.app_version }}
</div>
</div>
</aside>
<!-- Main -->
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
<!-- Header -->
<header class="h-[60px] shrink-0 flex items-center justify-between px-8 bg-surface border-b border-ink/[0.05] shadow-xs z-10">
<div class="flex items-center gap-3">
<!-- Accent bar -->
<div class="w-[3px] h-5 bg-highlight rounded-full hidden md:block" />
<!-- Page title -->
<h2 v-if="$slots.header" class="font-serif font-black text-[17px] text-primary tracking-tight">
<slot name="header" />
</h2>
</div>
<div class="flex items-center gap-3">
<!-- Slot pour actions contextuelles (ex: bouton "Nouveau") -->
<slot name="actions" />
<!-- Badge rôle -->
<span
v-if="$page.props.auth.user.role === 'super_admin'"
class="bg-gradient-to-r from-accent to-highlight text-white px-3 py-1 rounded-full text-[9px] font-black tracking-widest uppercase shadow-sm"
>GOD MODE</span>
<span
v-else-if="$page.props.auth.user.tenant"
class="bg-primary/10 text-primary px-3 py-1 rounded-full text-[9px] font-black tracking-widest uppercase border border-primary/20"
>{{ $page.props.auth.user.tenant.name }}</span>
</div>
</header>
<main class="flex-1 overflow-y-auto p-4 md:p-8">
<!-- Content -->
<main class="flex-1 overflow-y-auto p-7">
<slot />
</main>
</div>
</div>
</template>
<style>
/* Firefox scrollbar config for sidebar */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
<style scoped>
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
/* Scrollbar sidebar Firefox */
.scrollbar-thin { scrollbar-width: thin; }
.scrollbar-thumb-white\/10 { scrollbar-color: rgba(255,255,255,0.1) transparent; }
</style>

View File

@@ -0,0 +1,203 @@
<script setup>
import { ref } from 'vue';
import { Link } from '@inertiajs/vue3';
import ApplicationLogo from '@/Components/ApplicationLogo.vue';
import Dropdown from '@/Components/Dropdown.vue';
import DropdownLink from '@/Components/DropdownLink.vue';
import EnvironmentBanner from '@/Components/EnvironmentBanner.vue';
const isSidebarOpen = ref(true);
</script>
<template>
<EnvironmentBanner />
<div class="min-h-screen bg-sand flex text-anthracite font-sans selection:bg-highlight selection:text-anthracite">
<!-- Sidebar -->
<aside
:class="[isSidebarOpen ? 'w-64' : 'w-20']"
class="hidden md:flex flex-col bg-primary transition-all duration-300 shadow-xl z-20"
>
<div class="h-16 flex items-center px-5 bg-primary/90 border-b border-white/10">
<Link :href="route('dashboard')" class="flex items-center gap-3 overflow-hidden ml-1">
<div class="w-8 h-8 bg-highlight rounded flex items-center justify-center shrink-0 shadow-sm shadow-highlight/20">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#3a2800]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9l-.707.707M12 18v3m4.95-4.95l.707.707M12 3c-4.418 0-8 3.582-8 8 0 2.209.895 4.209 2.343 5.657L12 21l5.657-5.343A7.994 7.994 0 0020 11c0-4.418-3.582-8-8-8z" />
</svg>
</div>
<span v-if="isSidebarOpen" class="font-serif font-black text-xl tracking-tight whitespace-nowrap text-white">RECRU<span class="text-accent italic px-0.5">IT</span></span>
</Link>
</div>
<nav class="flex-1 py-6 px-3 space-y-1.5 overflow-y-auto custom-scrollbar">
<Link
:href="route('dashboard')"
class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('dashboard') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<span v-if="isSidebarOpen" class="truncate">Tableau de bord</span>
</Link>
<Link
:href="route('admin.candidates.index')"
class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('admin.candidates.*') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<span v-if="isSidebarOpen" class="truncate">Candidats</span>
</Link>
<Link
:href="route('admin.quizzes.index')"
class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('admin.quizzes.*') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span v-if="isSidebarOpen" class="truncate">Quiz</span>
</Link>
<Link
:href="route('admin.job-positions.index')"
class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('admin.job-positions.*') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" 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>
<span v-if="isSidebarOpen" class="truncate">Fiches de Poste</span>
</Link>
<Link
:href="route('admin.comparative')"
class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('admin.comparative') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<span v-if="isSidebarOpen" class="truncate">Comparateur</span>
</Link>
<div v-if="$page.props.auth.user.role === 'super_admin'" class="pt-4 pb-2">
<div v-show="isSidebarOpen" class="px-3 text-[10px] font-black uppercase tracking-widest text-white/30">Configuration</div>
<div v-show="!isSidebarOpen" class="h-[1px] w-8 mx-auto bg-white/10"></div>
</div>
<Link
v-if="$page.props.auth.user.role === 'super_admin'"
:href="route('admin.tenants.index')"
class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('admin.tenants.*') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<span v-if="isSidebarOpen" class="truncate">Structures</span>
</Link>
<Link
v-if="$page.props.auth.user.role === 'super_admin'"
:href="route('admin.users.index')"
class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('admin.users.*') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<span v-if="isSidebarOpen" class="truncate">Équipe SaaS</span>
</Link>
<Link
v-if="$page.props.auth.user.role === 'super_admin'"
:href="route('admin.logs.index')"
class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('admin.logs.*') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
<span v-if="isSidebarOpen" class="truncate">Logs de connexion</span>
</Link>
</nav>
<div class="p-4 border-t border-white/10 bg-primary/80">
<button
@click="isSidebarOpen = !isSidebarOpen"
class="flex items-center justify-center w-full h-10 rounded-lg text-white/50 hover:bg-white/10 hover:text-white transition-all duration-300"
>
<svg v-if="isSidebarOpen" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
<div v-show="isSidebarOpen" class="mt-4 text-[9px] font-bold uppercase tracking-widest text-[#3a7abf] text-center">
App v{{ $page.props.app_version }}
</div>
</div>
</aside>
<!-- Main Content -->
<div class="flex-1 flex flex-col min-w-0 overflow-hidden bg-neutral">
<header class="h-16 shrink-0 flex items-center justify-between px-8 bg-white border-b border-anthracite/5 shadow-sm z-10 relative">
<div>
<h2 v-if="$slots.header" class="font-serif font-black text-xl text-primary capitalize tracking-tight flex items-center gap-3">
<div class="w-1.5 h-6 bg-highlight rounded-full hidden md:block"></div>
<slot name="header" />
</h2>
</div>
<div class="flex items-center gap-4">
<Dropdown align="right" width="48">
<template #trigger>
<button class="flex items-center gap-2 text-sm font-subtitle font-bold hover:text-primary transition-colors cursor-pointer py-2">
<div class="w-8 h-8 rounded-full bg-sand flex items-center justify-center text-primary border border-primary/10">
{{ $page.props.auth.user.name.charAt(0) }}
</div>
<span class="hidden md:block">{{ $page.props.auth.user.name }}</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-anthracite/40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</template>
<template #content>
<DropdownLink :href="route('profile.edit')">Paramètres du profil</DropdownLink>
<DropdownLink :href="route('admin.backup')" as="a" class="!text-sky font-bold" v-if="$page.props.auth.user.role === 'super_admin'">Sauvegarde Base de données</DropdownLink>
<div class="border-t border-anthracite/5 my-1"></div>
<DropdownLink :href="route('logout')" method="post" as="button" class="!text-accent font-bold">Se déconnecter</DropdownLink>
</template>
</Dropdown>
</div>
</header>
<main class="flex-1 overflow-y-auto p-4 md:p-8">
<slot />
</main>
</div>
</div>
</template>
<style>
/* Firefox scrollbar config for sidebar */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,9 @@
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Merriweather:wght@700;900&family=Nunito:wght@400;600;700;800;900&display=swap" rel="stylesheet" />
<!-- Scripts -->
@routes

View File

@@ -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'],
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',
},
},
},