feat(ai): optimize candidate analysis and implement batch processing
This commit is contained in:
@@ -73,7 +73,7 @@ class AIAnalysisService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call the AI API (using a placeholder for now, or direct Http call).
|
* Call the AI API.
|
||||||
*/
|
*/
|
||||||
protected function callAI(Candidate $candidate, string $cvText, ?string $letterText, ?string $provider = null)
|
protected function callAI(Candidate $candidate, string $cvText, ?string $letterText, ?string $provider = null)
|
||||||
{
|
{
|
||||||
@@ -83,30 +83,49 @@ class AIAnalysisService
|
|||||||
$jobDesc = $candidate->jobPosition->description;
|
$jobDesc = $candidate->jobPosition->description;
|
||||||
$requirements = implode(", ", $candidate->jobPosition->requirements ?? []);
|
$requirements = implode(", ", $candidate->jobPosition->requirements ?? []);
|
||||||
|
|
||||||
$prompt = "Tu es un expert en recrutement technique. Analyse le CV (et la lettre de motivation si présente) d'un candidat pour le poste de '{$jobTitle}' attache une grande importance aux compétences techniques et à l'expérience du candidat, mais aussi à sa capacité à s'intégrer dans une équipe et à sa motivation.
|
$prompt = "Tu es un expert en recrutement technique. Ton rôle est d'analyser le profil d'un candidat pour le poste de '{$jobTitle}'.";
|
||||||
|
|
||||||
DESCRIPTION DU POSTE:
|
if (!$candidate->jobPosition->ai_prompt) {
|
||||||
{$jobDesc}
|
$prompt .= " Attache une grande importance aux compétences techniques et à l'expérience, mais aussi à la capacité d'intégration et à la motivation.
|
||||||
|
|
||||||
|
DESCRIPTION DU POSTE:
|
||||||
|
{$jobDesc}
|
||||||
|
|
||||||
|
COMPÉTENCES REQUISES:
|
||||||
|
{$requirements}
|
||||||
|
|
||||||
|
CONTENU DU CV:
|
||||||
|
{$cvText}
|
||||||
|
CONTENU DE LA LETTRE DE MOTIVATION:
|
||||||
|
" . ($letterText ?? "Non fournie") . "
|
||||||
|
|
||||||
|
Fournis une analyse structurée en JSON avec les clés suivantes:
|
||||||
|
- match_score: note de 0 à 100
|
||||||
|
- summary: résumé de 3-4 phrases sur le profil
|
||||||
|
- strengths: liste des points forts par rapport au poste
|
||||||
|
- gaps: liste des compétences manquantes ou points de vigilance
|
||||||
|
- verdict: une conclusion (Favorable, Très Favorable, Réservé, Défavorable)";
|
||||||
|
} else {
|
||||||
|
// Context injection for the custom prompt
|
||||||
|
$prompt .= "
|
||||||
|
|
||||||
|
CONTEXTE DU POSTE:
|
||||||
|
{$jobDesc}
|
||||||
|
|
||||||
|
COMPÉTENCES REQUISES:
|
||||||
|
{$requirements}
|
||||||
|
|
||||||
|
CONTENU DU CV DU CANDIDAT:
|
||||||
|
{$cvText}
|
||||||
|
|
||||||
|
CONTENU DE LA LETTRE DE MOTIVATION:
|
||||||
|
" . ($letterText ?? "Non fournie") . "
|
||||||
|
|
||||||
|
CONSIGNES D'ANALYSE SPÉCIFIQUES:
|
||||||
|
" . $candidate->jobPosition->ai_prompt;
|
||||||
|
}
|
||||||
|
|
||||||
COMPÉTENCES REQUISES:
|
$prompt .= "\n\nRéponds UNIQUEMENT en JSON pur, sans texte avant ou après. Assure-toi que le JSON est valide.";
|
||||||
{$requirements}
|
|
||||||
|
|
||||||
CONTENU DU CV:
|
|
||||||
{$cvText}
|
|
||||||
CONTENU DE LA LETTRE DE MOTIVATION:
|
|
||||||
" . ($letterText ?? "Non fournie") . "
|
|
||||||
|
|
||||||
CONTEXTE ADDITIONNEL & INSTRUCTIONS PARTICULIÈRES:
|
|
||||||
" . ($candidate->jobPosition->ai_prompt ?? "Aucune instruction spécifique.") . "
|
|
||||||
|
|
||||||
Fournis une analyse structurée en JSON avec les clés suivantes:
|
|
||||||
- match_score: note de 0 à 100
|
|
||||||
- summary: résumé de 3-4 phrases sur le profil et la ville d'origine du candidat
|
|
||||||
- strengths: liste des points forts par rapport au poste
|
|
||||||
- gaps: liste des compétences manquantes ou points de vigilance
|
|
||||||
- verdict: une conclusion (Favorable, Très Favorable, Réservé, Défavorable)
|
|
||||||
|
|
||||||
Réponds UNIQUEMENT en JSON pur.";
|
|
||||||
|
|
||||||
$analysis = match ($provider) {
|
$analysis = match ($provider) {
|
||||||
'openai' => $this->callOpenAI($prompt),
|
'openai' => $this->callOpenAI($prompt),
|
||||||
@@ -115,11 +134,53 @@ class AIAnalysisService
|
|||||||
default => $this->callOllama($prompt),
|
default => $this->callOllama($prompt),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Inject metadata for display and tracking
|
// Normalize keys for frontend compatibility
|
||||||
$analysis['provider'] = $provider;
|
$normalized = $this->normalizeAnalysis($analysis);
|
||||||
$analysis['analyzed_at'] = now()->toIso8601String();
|
|
||||||
|
|
||||||
return $analysis;
|
// Inject metadata
|
||||||
|
$normalized['provider'] = $provider;
|
||||||
|
$normalized['analyzed_at'] = now()->toIso8601String();
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize the AI response keys to ensure frontend compatibility.
|
||||||
|
*/
|
||||||
|
protected function normalizeAnalysis(array $data): array
|
||||||
|
{
|
||||||
|
$normalized = $data;
|
||||||
|
|
||||||
|
// Map custom keys to standard keys if they exist
|
||||||
|
if (isset($data['score_global']) && !isset($data['match_score'])) {
|
||||||
|
$normalized['match_score'] = $data['score_global'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['recommandation']) && !isset($data['verdict'])) {
|
||||||
|
$normalized['verdict'] = $data['recommandation'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['synthese']) && !isset($data['summary'])) {
|
||||||
|
$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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure default keys exist even if empty
|
||||||
|
$normalized['match_score'] = $normalized['match_score'] ?? 0;
|
||||||
|
$normalized['summary'] = $normalized['summary'] ?? "Pas de résumé généré.";
|
||||||
|
$normalized['verdict'] = $normalized['verdict'] ?? "Indéterminé";
|
||||||
|
$normalized['strengths'] = $normalized['strengths'] ?? [];
|
||||||
|
$normalized['gaps'] = $normalized['gaps'] ?? [];
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function callOllama(string $prompt)
|
protected function callOllama(string $prompt)
|
||||||
@@ -181,7 +242,7 @@ class AIAnalysisService
|
|||||||
'content-type' => 'application/json'
|
'content-type' => 'application/json'
|
||||||
])->timeout(60)->post('https://api.anthropic.com/v1/messages', [
|
])->timeout(60)->post('https://api.anthropic.com/v1/messages', [
|
||||||
'model' => 'claude-3-5-sonnet-20240620',
|
'model' => 'claude-3-5-sonnet-20240620',
|
||||||
'max_tokens' => 1024,
|
'max_tokens' => 2048,
|
||||||
'messages' => [['role' => 'user', 'content' => $prompt]]
|
'messages' => [['role' => 'user', 'content' => $prompt]]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -229,7 +290,7 @@ class AIAnalysisService
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'match_score' => 75,
|
'match_score' => 75,
|
||||||
'summary' => "Analyse simulée (IA non connectée ou erreur API). Le candidat semble avoir une solide expérience mais certains points techniques doivent être vérifiés.",
|
'summary' => "Analyse simulée (IA non connectée ou erreur API). Le candidat peut avoir un profil intéressant mais une vérification manuelle est nécessaire.",
|
||||||
'strengths' => ["Expérience pertinente", "Bonne présentation"],
|
'strengths' => ["Expérience pertinente", "Bonne présentation"],
|
||||||
'gaps' => ["Compétences spécifiques à confirmer"],
|
'gaps' => ["Compétences spécifiques à confirmer"],
|
||||||
'verdict' => "Favorable"
|
'verdict' => "Favorable"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import AdminLayout from '@/Layouts/AdminLayout.vue';
|
import AdminLayout from '@/Layouts/AdminLayout.vue';
|
||||||
import { Head, useForm, Link, usePage, router } from '@inertiajs/vue3';
|
import { Head, useForm, Link, usePage, router } from '@inertiajs/vue3';
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
const flashSuccess = computed(() => page.props.flash?.success);
|
const flashSuccess = computed(() => page.props.flash?.success);
|
||||||
@@ -56,8 +57,8 @@ const openPreview = (doc) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Sorting Logic
|
// Sorting Logic
|
||||||
const sortKey = ref('user.name');
|
const sortKey = ref('ai_analysis.match_score');
|
||||||
const sortOrder = ref(1); // 1 = asc, -1 = desc
|
const sortOrder = ref(-1); // 1 = asc, -1 = desc
|
||||||
|
|
||||||
const sortBy = (key) => {
|
const sortBy = (key) => {
|
||||||
if (sortKey.value === key) {
|
if (sortKey.value === key) {
|
||||||
@@ -106,6 +107,61 @@ const sortedCandidates = computed(() => {
|
|||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectedIds = ref([]);
|
||||||
|
const isBatchAnalyzing = ref(false);
|
||||||
|
const analysisProgress = ref({ current: 0, total: 0 });
|
||||||
|
|
||||||
|
const toggleSelectAll = (e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
selectedIds.value = sortedCandidates.value.map(c => c.id);
|
||||||
|
} else {
|
||||||
|
selectedIds.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const batchAnalyze = async () => {
|
||||||
|
if (selectedIds.value.length === 0) return;
|
||||||
|
|
||||||
|
if (!confirm(`Voulez-vous lancer l'analyse IA pour les ${selectedIds.value.length} candidats sélectionnés ?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isBatchAnalyzing.value = true;
|
||||||
|
analysisProgress.value = { current: 0, total: selectedIds.value.length };
|
||||||
|
|
||||||
|
const results = { success: 0, errors: 0, details: [] };
|
||||||
|
|
||||||
|
// Copy the IDs to avoid issues if selection changes during process
|
||||||
|
const idsToProcess = [...selectedIds.value];
|
||||||
|
|
||||||
|
for (const id of idsToProcess) {
|
||||||
|
analysisProgress.value.current++;
|
||||||
|
try {
|
||||||
|
await axios.post(route('admin.candidates.analyze', id));
|
||||||
|
results.success++;
|
||||||
|
} catch (error) {
|
||||||
|
results.errors++;
|
||||||
|
const candidate = props.candidates.find(c => c.id === id);
|
||||||
|
results.details.push({
|
||||||
|
candidate: candidate?.user?.name || `ID #${id}`,
|
||||||
|
error: error.response?.data?.error || error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finished processing all
|
||||||
|
router.reload({
|
||||||
|
onSuccess: () => {
|
||||||
|
isBatchAnalyzing.value = false;
|
||||||
|
selectedIds.value = [];
|
||||||
|
alert(`Analyse terminée : ${results.success} succès, ${results.errors} erreurs.`);
|
||||||
|
if (results.details.length > 0) {
|
||||||
|
console.table(results.details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -141,9 +197,29 @@ const sortedCandidates = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<PrimaryButton @click="isModalOpen = true">
|
<div class="flex items-center gap-4">
|
||||||
Ajouter un Candidat
|
<div v-if="selectedIds.length > 0" class="flex items-center gap-3 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||||
</PrimaryButton>
|
<span class="text-sm font-bold text-slate-500">{{ selectedIds.length }} sélectionné(s)</span>
|
||||||
|
<PrimaryButton
|
||||||
|
@click="batchAnalyze"
|
||||||
|
:disabled="isBatchAnalyzing"
|
||||||
|
class="!bg-purple-600 hover:!bg-purple-500 !border-none flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg v-if="isBatchAnalyzing" class="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9l-.707.707M12 21v-1m4.243-4.243l-.707-.707m2.828-9.9l-.707.707" />
|
||||||
|
</svg>
|
||||||
|
{{ isBatchAnalyzing ? `Analyse ${analysisProgress.current}/${analysisProgress.total}...` : 'Analyse IA groupée' }}
|
||||||
|
</PrimaryButton>
|
||||||
|
<div class="h-8 w-px bg-slate-200 dark:bg-slate-700 mx-2"></div>
|
||||||
|
</div>
|
||||||
|
<PrimaryButton @click="isModalOpen = true">
|
||||||
|
Ajouter un Candidat
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Flash Messages -->
|
<!-- Flash Messages -->
|
||||||
@@ -164,6 +240,14 @@ const sortedCandidates = computed(() => {
|
|||||||
<table class="w-full text-left">
|
<table class="w-full text-left">
|
||||||
<thead class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
|
<thead class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="w-12 px-6 py-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="selectedIds.length === sortedCandidates.length && sortedCandidates.length > 0"
|
||||||
|
@change="toggleSelectAll"
|
||||||
|
class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500/20 cursor-pointer"
|
||||||
|
>
|
||||||
|
</th>
|
||||||
<th class="w-12 px-6 py-4"></th>
|
<th class="w-12 px-6 py-4"></th>
|
||||||
<th @click="sortBy('user.name')" class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-700/50 transition-colors">
|
<th @click="sortBy('user.name')" class="px-6 py-4 font-semibold text-slate-700 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-700/50 transition-colors">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -226,7 +310,15 @@ const sortedCandidates = computed(() => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||||
<tr v-for="candidate in sortedCandidates" :key="candidate.id" class="hover:bg-slate-50/50 dark:hover:bg-slate-700/30 transition-colors">
|
<tr v-for="candidate in sortedCandidates" :key="candidate.id" class="hover:bg-slate-50/50 dark:hover:bg-slate-700/30 transition-colors" :class="{ 'bg-indigo-50/30 dark:bg-indigo-900/10': selectedIds.includes(candidate.id) }">
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:value="candidate.id"
|
||||||
|
v-model="selectedIds"
|
||||||
|
class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500/20 cursor-pointer"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<button @click="toggleSelection(candidate.id)" class="text-amber-400 hover:text-amber-500 hover:scale-110 transition-transform focus:outline-none" :title="candidate.is_selected ? 'Retirer des retenus' : 'Marquer comme retenu'">
|
<button @click="toggleSelection(candidate.id)" class="text-amber-400 hover:text-amber-500 hover:scale-110 transition-transform focus:outline-none" :title="candidate.is_selected ? 'Retirer des retenus' : 'Marquer comme retenu'">
|
||||||
<svg v-if="candidate.is_selected" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
|
<svg v-if="candidate.is_selected" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
|||||||
@@ -621,85 +621,91 @@ const runAI = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- AI Analysis Section -->
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8 overflow-hidden relative">
|
</div>
|
||||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
|
|
||||||
|
<!-- AI Analysis Section (Full Width) -->
|
||||||
|
<div class="xl:col-span-3 space-y-8">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-3xl shadow-lg border border-slate-200 dark:border-slate-700 p-10 overflow-hidden relative">
|
||||||
|
<div class="flex flex-col xl:flex-row xl:items-center justify-between gap-8 mb-10 border-b border-slate-100 dark:border-slate-700 pb-8">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-bold text-lg mb-4 flex items-center gap-3 flex-wrap">
|
<h3 class="font-black text-3xl mb-4 flex items-center gap-4 flex-wrap">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<div class="p-3 bg-indigo-500 text-white rounded-2xl shadow-indigo-200 shadow-lg">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9l-.707.707M12 21v-1m4.243-4.243l-.707-.707m2.828-9.9l-.707.707" />
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
</svg>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9l-.707.707M12 21v-1m4.243-4.243l-.707-.707m2.828-9.9l-.707.707" />
|
||||||
Analyse IA complète
|
</svg>
|
||||||
<div class="flex items-center gap-2">
|
</div>
|
||||||
<span v-if="aiAnalysis?.provider" class="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-500 uppercase font-bold border border-slate-200">
|
<span>Analyse IA de la Candidature</span>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span v-if="aiAnalysis?.provider" class="text-sm px-4 py-1 rounded-full bg-slate-100 dark:bg-slate-900 text-slate-500 uppercase font-black border border-slate-200 dark:border-slate-700">
|
||||||
{{ aiAnalysis.provider }}
|
{{ aiAnalysis.provider }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="aiAnalysis?.analyzed_at" class="text-[10px] text-slate-400 italic font-normal">
|
<span v-if="aiAnalysis?.analyzed_at" class="text-xs text-slate-400 italic font-medium">
|
||||||
Effectuée le {{ new Date(aiAnalysis.analyzed_at).toLocaleDateString('fr-FR') }}
|
Effectuée le {{ new Date(aiAnalysis.analyzed_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-xs text-slate-400 mt-1 uppercase font-bold tracking-widest">Choisir l'IA pour l'analyse du matching</p>
|
<p class="text-sm text-slate-400 mt-2 uppercase font-black tracking-[0.2em]">Moteur décisionnel assisté par Intelligence Artificielle</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-4">
|
<div class="flex flex-wrap items-center gap-6">
|
||||||
<!-- Provider Selector -->
|
<!-- Provider Selector -->
|
||||||
<div v-if="props.ai_config?.enabled_providers" class="flex items-center bg-slate-100 dark:bg-slate-900/50 p-1.5 rounded-2xl border border-slate-200 dark:border-slate-800">
|
<div v-if="props.ai_config?.enabled_providers" class="flex items-center bg-slate-100 dark:bg-slate-900/80 p-2 rounded-2xl border border-slate-200 dark:border-slate-800">
|
||||||
<button
|
<button
|
||||||
v-for="provider in Object.keys(props.ai_config.enabled_providers)"
|
v-for="provider in Object.keys(props.ai_config.enabled_providers)"
|
||||||
:key="provider"
|
:key="provider"
|
||||||
@click="selectedProvider = provider"
|
@click="selectedProvider = provider"
|
||||||
class="px-4 py-2 text-[10px] font-black uppercase tracking-widest rounded-xl transition-all"
|
class="px-6 py-2.5 text-xs font-black uppercase tracking-widest rounded-xl transition-all"
|
||||||
:class="selectedProvider === provider ? 'bg-white dark:bg-slate-800 shadow-sm text-indigo-600' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'"
|
:class="selectedProvider === provider ? 'bg-white dark:bg-slate-800 shadow-xl text-indigo-600' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'"
|
||||||
>
|
>
|
||||||
{{ provider }}
|
{{ provider }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Force option for Super Admin -->
|
<!-- Force option for Super Admin -->
|
||||||
<div v-if="$page.props.auth.user.role === 'super_admin'" class="flex items-center gap-2 px-4 py-2 bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-900/50 rounded-xl">
|
<div v-if="$page.props.auth.user.role === 'super_admin'" class="flex items-center gap-3 px-5 py-3 bg-red-50 dark:bg-red-900/10 border border-red-100 dark:border-red-900/30 rounded-2xl">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="forceAnalysis"
|
id="forceAnalysis"
|
||||||
v-model="forceAnalysis"
|
v-model="forceAnalysis"
|
||||||
class="rounded border-red-300 text-red-600 focus:ring-red-500/20 w-4 h-4 cursor-pointer"
|
class="rounded-lg border-red-300 text-red-600 focus:ring-red-500/20 w-5 h-5 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<label for="forceAnalysis" class="text-[10px] font-black uppercase tracking-widest text-red-600 cursor-pointer select-none">
|
<label for="forceAnalysis" class="text-xs font-black uppercase tracking-widest text-red-600 cursor-pointer select-none">
|
||||||
Forcer (Bypass 7 jours)
|
Forcer (Bypass 7j)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
@click="runAI"
|
@click="runAI"
|
||||||
:disabled="isAnalyzing"
|
:disabled="isAnalyzing"
|
||||||
class="!bg-indigo-600 hover:!bg-indigo-500 !border-none !rounded-xl group"
|
class="!bg-indigo-600 hover:!bg-indigo-500 !border-none !rounded-2xl !py-4 !px-8 group shadow-xl shadow-indigo-200 dark:shadow-none"
|
||||||
>
|
>
|
||||||
<span v-if="isAnalyzing" class="flex items-center gap-2">
|
<span v-if="isAnalyzing" class="flex items-center gap-3">
|
||||||
<svg class="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg class="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Analyse en cours...
|
<span class="font-black uppercase tracking-widest text-xs">Analyse en cours...</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="flex items-center gap-2">
|
<span v-else class="flex items-center gap-3">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 group-hover:scale-110 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 group-hover:scale-110 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
Lancer l'analyse intelligente
|
<span class="font-black uppercase tracking-widest text-xs">Lancer l'analyse complète</span>
|
||||||
</span>
|
</span>
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- AI Results -->
|
<!-- AI Results -->
|
||||||
<div v-if="aiAnalysis" class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
<div v-if="aiAnalysis" class="space-y-12 animate-in fade-in slide-in-from-bottom-6 duration-1000">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||||
<div class="bg-slate-50 dark:bg-slate-900/50 p-6 rounded-3xl border border-slate-100 dark:border-slate-800 text-center flex flex-col items-center justify-center">
|
<div class="lg:col-span-1 bg-slate-50 dark:bg-slate-900/50 p-8 rounded-[2.5rem] border border-slate-100 dark:border-slate-800 text-center flex flex-col items-center justify-center shadow-inner">
|
||||||
<div class="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-4">Score d'Adéquation</div>
|
<div class="text-xs font-black uppercase tracking-[0.2em] text-slate-400 mb-6">Score Global</div>
|
||||||
<div class="text-5xl font-black text-indigo-600 mb-2">{{ aiAnalysis.match_score }}%</div>
|
<div class="text-7xl font-black text-indigo-600 mb-6 tracking-tighter">{{ aiAnalysis.match_score }}%</div>
|
||||||
<div
|
<div
|
||||||
class="px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest"
|
class="px-6 py-2 rounded-2xl text-xs font-black uppercase tracking-widest shadow-sm"
|
||||||
:class="[
|
:class="[
|
||||||
aiAnalysis.match_score >= 80 ? 'bg-emerald-100 text-emerald-700' :
|
aiAnalysis.match_score >= 80 ? 'bg-emerald-100 text-emerald-700' :
|
||||||
aiAnalysis.match_score >= 60 ? 'bg-amber-100 text-amber-700' :
|
aiAnalysis.match_score >= 60 ? 'bg-amber-100 text-amber-700' :
|
||||||
@@ -709,55 +715,127 @@ const runAI = async () => {
|
|||||||
{{ aiAnalysis.verdict }}
|
{{ aiAnalysis.verdict }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="md:col-span-2 bg-slate-50 dark:bg-slate-900/50 p-6 rounded-3xl border border-slate-100 dark:border-slate-800">
|
<div class="lg:col-span-3 bg-indigo-50/30 dark:bg-indigo-900/10 p-10 rounded-[2.5rem] border border-indigo-100/50 dark:border-indigo-900/30">
|
||||||
<div class="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-4">Synthèse du Profil</div>
|
<div class="text-xs font-black uppercase tracking-[0.2em] text-indigo-400 mb-6">Synthèse du Profil & Potentiel</div>
|
||||||
<p class="text-sm leading-relaxed text-slate-600 dark:text-slate-400 italic">
|
<p class="text-2xl leading-relaxed text-slate-700 dark:text-slate-300 font-medium italic">
|
||||||
" {{ aiAnalysis.summary }} "
|
" {{ aiAnalysis.summary }} "
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-10">
|
||||||
<div class="space-y-4">
|
<!-- Strengths -->
|
||||||
<h5 class="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-emerald-500">
|
<div class="space-y-6">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<h5 class="flex items-center gap-3 text-sm font-black uppercase tracking-widest text-emerald-500">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<div class="w-8 h-8 rounded-xl bg-emerald-100 dark:bg-emerald-900/20 flex items-center justify-center">
|
||||||
</svg>
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
Points Forts Identifiés
|
Points Forts Identifiés
|
||||||
</h5>
|
</h5>
|
||||||
<ul class="space-y-3">
|
<div class="grid grid-cols-1 gap-4">
|
||||||
<li v-for="(strength, idx) in aiAnalysis.strengths" :key="idx" class="flex items-start gap-3 p-3 bg-emerald-50/50 dark:bg-emerald-900/10 rounded-2xl border border-emerald-100 dark:border-emerald-800/50 text-[13px]">
|
<div v-for="(strength, idx) in aiAnalysis.strengths" :key="idx" class="flex items-start gap-4 p-5 bg-emerald-50/30 dark:bg-emerald-900/10 rounded-3xl border border-emerald-100/50 dark:border-emerald-800/30 transition-hover hover:border-emerald-300">
|
||||||
<span class="text-emerald-500 font-bold mt-0.5">•</span>
|
<div class="mt-1 flex-shrink-0 w-5 h-5 bg-emerald-500 rounded-full flex items-center justify-center text-white text-[10px] font-bold">✓</div>
|
||||||
<span class="text-emerald-800 dark:text-emerald-400">{{ strength }}</span>
|
<span class="text-lg text-slate-700 dark:text-slate-300">{{ strength }}</span>
|
||||||
</li>
|
</div>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
|
||||||
<h5 class="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-amber-500">
|
<!-- Gaps -->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<div class="space-y-6">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
<h5 class="flex items-center gap-3 text-sm font-black uppercase tracking-widest text-amber-500">
|
||||||
</svg>
|
<div class="w-8 h-8 rounded-xl bg-amber-100 dark:bg-amber-900/20 flex items-center justify-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
Points de Vigilance / Gaps
|
Points de Vigilance / Gaps
|
||||||
</h5>
|
</h5>
|
||||||
<ul class="space-y-3">
|
<div class="grid grid-cols-1 gap-4">
|
||||||
<li v-for="(gap, idx) in aiAnalysis.gaps" :key="idx" class="flex items-start gap-3 p-3 bg-amber-50/50 dark:bg-amber-900/10 rounded-2xl border border-amber-100 dark:border-amber-800/50 text-[13px]">
|
<div v-for="(gap, idx) in aiAnalysis.gaps" :key="idx" class="flex items-start gap-4 p-5 bg-amber-50/30 dark:bg-amber-900/10 rounded-3xl border border-amber-100/50 dark:border-amber-800/30 hover:border-amber-300 transition-colors">
|
||||||
<span class="text-amber-500 font-bold mt-0.5">•</span>
|
<div class="mt-1 flex-shrink-0 w-5 h-5 bg-amber-500 rounded-full flex items-center justify-center text-white text-[10px] font-bold">!</div>
|
||||||
<span class="text-amber-800 dark:text-amber-400">{{ gap }}</span>
|
<span class="text-lg text-slate-700 dark:text-slate-300">{{ gap }}</span>
|
||||||
</li>
|
</div>
|
||||||
</ul>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detailed Scores (if available) -->
|
||||||
|
<div v-if="aiAnalysis.scores_detailles" class="space-y-8">
|
||||||
|
<h5 class="text-sm font-black uppercase tracking-widest text-indigo-500 border-l-4 border-indigo-500 pl-4">Détail des scores par dimension</h5>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||||
|
<div v-for="(details, key) in aiAnalysis.scores_detailles" :key="key" class="bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 p-8 rounded-[2rem] shadow-sm hover:shadow-xl transition-all duration-300 group">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<span class="text-xs font-black uppercase tracking-[0.1em] text-slate-400 group-hover:text-indigo-500 transition-colors">
|
||||||
|
{{ key.replace(/_/g, ' ') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-2xl font-black text-indigo-600 bg-indigo-50 dark:bg-indigo-900/30 px-4 py-1 rounded-xl">{{ details.score }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-3 bg-slate-100 dark:bg-slate-800 rounded-full mb-6 overflow-hidden">
|
||||||
|
<div class="h-full bg-gradient-to-r from-indigo-400 to-indigo-600 rounded-full transition-all duration-1000 group-hover:from-indigo-500 group-hover:to-purple-600" :style="{ width: details.score + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400 leading-relaxed font-medium">{{ details.justification }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Blocking Elements -->
|
||||||
|
<div v-if="aiAnalysis.elements_bloquants?.length > 0 && aiAnalysis.elements_bloquants[0] !== ''" class="p-10 bg-red-50/50 dark:bg-red-900/10 border-2 border-dashed border-red-200 dark:border-red-900/30 rounded-[3rem]">
|
||||||
|
<h5 class="flex items-center gap-4 text-sm font-black uppercase tracking-widest text-red-600 mb-8">
|
||||||
|
<div class="w-10 h-10 rounded-2xl bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Signaux Critiques / Éléments Bloquants
|
||||||
|
</h5>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div v-for="(item, idx) in aiAnalysis.elements_bloquants" :key="idx" class="flex items-center gap-4 p-6 bg-white dark:bg-slate-900 rounded-[2rem] border border-red-100 dark:border-red-900/20 text-lg text-red-700 dark:text-red-400 font-black shadow-sm">
|
||||||
|
<span class="w-3 h-3 bg-red-500 rounded-full animate-pulse"></span>
|
||||||
|
{{ item }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Interview Questions -->
|
||||||
|
<div v-if="aiAnalysis.questions_entretien_suggerees?.length > 0" class="space-y-8">
|
||||||
|
<h5 class="text-sm font-black uppercase tracking-widest text-indigo-500 border-l-4 border-indigo-500 pl-4">Préparation d'entretien : Questions suggérées</h5>
|
||||||
|
<div class="grid grid-cols-1 gap-4">
|
||||||
|
<div v-for="(q, idx) in aiAnalysis.questions_entretien_suggerees" :key="idx" class="flex items-center gap-6 p-6 bg-slate-50 dark:bg-slate-900/30 border border-slate-100 dark:border-slate-800 rounded-3xl group hover:bg-white dark:hover:bg-slate-800 hover:shadow-xl hover:border-indigo-300 transition-all duration-300">
|
||||||
|
<div class="w-12 h-12 shrink-0 rounded-[1.25rem] bg-white dark:bg-slate-800 flex items-center justify-center text-lg font-black text-indigo-500 shadow-md group-hover:bg-indigo-600 group-hover:text-white transition-all">
|
||||||
|
{{ idx + 1 }}
|
||||||
|
</div>
|
||||||
|
<p class="text-xl font-bold text-slate-700 dark:text-slate-200 leading-snug">{{ q }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!isAnalyzing" class="py-12 border-2 border-dashed border-slate-100 dark:border-slate-800 rounded-3xl text-center">
|
<div v-else-if="!isAnalyzing" class="py-24 border-4 border-dashed border-slate-100 dark:border-slate-800 rounded-[3rem] text-center bg-slate-50/50 dark:bg-slate-900/20">
|
||||||
<p class="text-slate-400 text-sm font-medium">Aucune analyse effectuée pour le moment.</p>
|
<div class="w-20 h-20 bg-white dark:bg-slate-800 rounded-3xl flex items-center justify-center mx-auto mb-6 shadow-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-xl text-slate-400 font-bold uppercase tracking-widest">En attente d'analyse IA</p>
|
||||||
|
<p class="text-slate-400 mt-2">Cliquez sur le bouton ci-dessus pour lancer le moteur décisionnel.</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isAnalyzing" class="absolute inset-0 bg-white/60 dark:bg-slate-800/60 backdrop-blur-[2px] z-10 flex flex-col items-center justify-center gap-4">
|
|
||||||
<div class="flex gap-1 animate-pulse"><div class="w-2 h-2 bg-indigo-500 rounded-full"></div><div class="w-2 h-2 bg-indigo-500 rounded-full"></div><div class="w-2 h-2 bg-indigo-500 rounded-full"></div></div>
|
<div v-if="isAnalyzing" class="absolute inset-0 bg-white/80 dark:bg-slate-800/80 backdrop-blur-md z-20 flex flex-col items-center justify-center gap-6">
|
||||||
<p class="text-sm font-black text-indigo-600 uppercase tracking-widest">Analyse en cours...</p>
|
<div class="flex gap-2 animate-bounce">
|
||||||
|
<div class="w-4 h-4 bg-indigo-500 rounded-full"></div>
|
||||||
|
<div class="w-4 h-4 bg-purple-500 rounded-full"></div>
|
||||||
|
<div class="w-4 h-4 bg-indigo-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-2xl font-black text-indigo-600 uppercase tracking-[0.2em] mb-2">Traitement IA en cours</p>
|
||||||
|
<p class="text-slate-500 font-medium">Analyse sémantique et pondération des scores...</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Notes Section -->
|
<!-- Notes Section (Full Width) -->
|
||||||
|
<div class="xl:col-span-3 space-y-8">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8">
|
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h4 class="text-xl font-bold flex items-center gap-2">
|
<h4 class="text-xl font-bold flex items-center gap-2">
|
||||||
@@ -836,6 +914,7 @@ const runAI = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Historique des Tests (Full Width) -->
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8">
|
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8">
|
||||||
<h3 class="text-xl font-bold mb-8 flex items-center justify-between">
|
<h3 class="text-xl font-bold mb-8 flex items-center justify-between">
|
||||||
Historique des Tests
|
Historique des Tests
|
||||||
|
|||||||
Reference in New Issue
Block a user