Refactoring AI candidate analysis: UI improvements, data normalization, provider management and real-time score clamping
This commit is contained in:
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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; })
|
||||
])),
|
||||
]
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@import './tokens.css';
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
271
resources/css/tokens.css
Normal file
271
resources/css/tokens.css
Normal 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; }
|
||||
35
resources/js/Components/Rq/RqBadge.vue
Normal file
35
resources/js/Components/Rq/RqBadge.vue
Normal 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>
|
||||
45
resources/js/Components/Rq/RqButton.vue
Normal file
45
resources/js/Components/Rq/RqButton.vue
Normal 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>
|
||||
33
resources/js/Components/Rq/RqCard.vue
Normal file
33
resources/js/Components/Rq/RqCard.vue
Normal 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>
|
||||
65
resources/js/Components/Rq/RqInput.vue
Normal file
65
resources/js/Components/Rq/RqInput.vue
Normal 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>
|
||||
38
resources/js/Components/Rq/RqScoreBar.vue
Normal file
38
resources/js/Components/Rq/RqScoreBar.vue
Normal 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>
|
||||
54
resources/js/Components/Rq/RqStatCard.vue
Normal file
54
resources/js/Components/Rq/RqStatCard.vue
Normal 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>
|
||||
@@ -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">
|
||||
<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>
|
||||
<!-- 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(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 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">{{ 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>
|
||||
<!-- 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>
|
||||
|
||||
<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>
|
||||
<template v-for="item in superAdminItems" :key="item.route">
|
||||
<Link
|
||||
: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 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">{{ 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">
|
||||
<!-- 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>
|
||||
<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="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" />
|
||||
<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>
|
||||
|
||||
203
resources/js/Layouts/AdminLayout.vue.backup
Normal file
203
resources/js/Layouts/AdminLayout.vue.backup
Normal 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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user