const candidaturesService = require('../services/candidatures.service'); class DashboardController { async getStats(req, res, next) { try { const filters = { annee: req.query.annee, secteur: req.query.secteur, pays: req.query.pays }; const stats = await candidaturesService.getDashboardStats(filters); res.json({ success: true, data: stats }); } catch (error) { next(error); } } } module.exports = new DashboardController(); const axios = require('axios'); require('dotenv').config(); class CandidaturesService { constructor() { this.baseURL = process.env.WP_API_URL || 'https://succesendirect.com/wp-json/custom/v1'; this.token = process.env.WP_API_TOKEN; // ⚡ CORRECTION MAJEURE : Initialisation de l'instance Axios this.axiosInstance = axios.create({ baseURL: this.baseURL, headers: { 'X-API-TOKEN': this.token, 'Content-Type': 'application/json' }, timeout: 8000 // ⚡ Robustesse : échoue proprement après 8 secondes si WordPress est lent }); } // Récupérer toutes les candidatures avec filtres et pagination async getAll(filters = {}) { try { const params = new URLSearchParams(); if (filters.secteur) params.append('secteur', filters.secteur); if (filters.pays) params.append('pays', filters.pays); if (filters.status) params.append('status', filters.status); if (filters.search) params.append('search', filters.search); if (filters.page) params.append('page', filters.page); if (filters.limit) params.append('per_page', filters.limit); const response = await this.axiosInstance.get('/candidatures', { params }); // ⚡ Récupération du total depuis les headers WordPress (ou fallback) const total = parseInt( response.headers['x-wp-total'] || response.data.total || response.data.length || 0, 10 ); return { data: response.data.data || response.data, total: total }; } catch (error) { console.error('Erreur API WordPress (GET candidatures):', error.response?.data || error.message); if (error.code === 'ECONNABORTED') { throw new Error('Délai d\'attente de l\'API WordPress dépassé.'); } throw new Error('Impossible de récupérer les candidatures'); } } // Récupérer une candidature par ID async getById(id) { try { const response = await this.axiosInstance.get(`/candidatures/${id}`); return response.data.data || null; } catch (error) { if (error.response?.status === 404) return null; console.error('Erreur API WordPress (GET candidature by ID):', error.response?.data || error.message); throw new Error('Impossible de récupérer la candidature'); } } // Récupérer les statistiques async getStats() { try { const response = await this.axiosInstance.get('/candidatures/stats'); return response.data.data || response.data; } catch (error) { console.error('Erreur API WordPress (GET stats):', error.response?.data || error.message); throw new Error('Impossible de récupérer les statistiques'); } } // Mettre à jour le statut d'une candidature async updateStatus(id, status, motif_rejet = null) { try { const payload = { status }; if (motif_rejet) payload.motif_rejet = motif_rejet; const response = await this.axiosInstance.put(`/candidatures/${id}/status`, payload); return response.data; } catch (error) { console.error('Erreur API WordPress (PUT status):', error.response?.data || error.message); throw new Error('Impossible de mettre à jour le statut'); } } // Lancer le scoring en masse pour une année async runScoring(annee, force = false) { try { const response = await this.axiosInstance.post('/scoring/run', { annee, force }); return response.data; } catch (error) { console.error('Erreur scoring masse:', error.response?.data || error.message); throw new Error('Impossible de lancer le scoring'); } } // Récupérer la liste des scores avec filtres async getScoringList(filters = {}) { try { const params = new URLSearchParams(); if (filters.annee) params.append('annee', filters.annee); if (filters.secteur) params.append('secteur', filters.secteur); if (filters.pays) params.append('pays', filters.pays); if (filters.search) params.append('search', filters.search); if (filters.page) params.append('page', filters.page); if (filters.limit) params.append('limit', filters.limit); if (filters.orderby) params.append('orderby', filters.orderby); const response = await this.axiosInstance.get('/scoring/list', { params }); return response.data; } catch (error) { console.error('Erreur liste scoring:', error.response?.data || error.message); throw new Error('Impossible de récupérer la liste des scores'); } } // Récupérer le détail d'un score async getScoringDetail(id) { try { const response = await this.axiosInstance.get(`/scoring/${id}`); return response.data; } catch (error) { console.error('Erreur détail scoring:', error.response?.data || error.message); throw new Error('Impossible de récupérer le détail du score'); } } // Récupérer les statistiques du tableau de bord async getDashboardStats(filters = {}) { try { const params = new URLSearchParams(); if (filters.annee) params.append('annee', filters.annee); if (filters.secteur) params.append('secteur', filters.secteur); if (filters.pays) params.append('pays', filters.pays); const response = await this.axiosInstance.get('/dashboard/stats', { params }); return response.data.data; } catch (error) { console.error('Erreur dashboard stats:', error.response?.data || error.message); throw new Error('Impossible de récupérer les statistiques du tableau de bord'); } } } // Export d'une instance unique (Singleton) module.exports = new CandidaturesService(); const express = require('express'); const router = express.Router(); const dashboardController = require('../controllers/dashboard.controller'); const { authenticate } = require('../middlewares/auth.middleware'); router.get('/stats', authenticate, dashboardController.getStats); module.exports = router; app.use('/dashboard', dashboardRoutes); import api from './api'; export const dashboardService = { getStats: (filters = {}) => api.get('/dashboard/stats', { params: filters }), }; import React, { useState, useEffect, useCallback, useMemo, useRef, memo } from "react"; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell, } from "recharts"; import { dashboardService } from "../services/dashboard.service"; import Select, { type SingleValue } from "react-select"; import { Users, TrendingUp, Award, RefreshCw, PieChart as PieChartIcon, BarChart2, Target, CheckCircle, XCircle } from "lucide-react"; // ============================================ // TYPES & INTERFACES // ============================================ interface OptionType { value: string | number; label: string; } interface StatsData { stats: { total_candidats: number; avg_score: number; min_score: number; max_score: number; valides: number; en_attente: number; rejetes: number; total_analyses: number; }; histogramme: Array<{ tranche: string; count: number }>; secteurs: Array<{ secteur: string; count: number; avg_score: number }>; pays: Array<{ pays: string; count: number; avg_score: number }>; top5: Array<{ id: string; nom: string; nom_projet: string; secteur: string; pays: string; score_total: number; }>; dimensions: { A: number; B: number; C: number; D: number; E: number; F: number }; evolution: Array<{ mois: string; count: number; avg_score: number }>; verdicts: Array<{ verdict: string; count: number }>; } interface StatCardProps { title: string; value: number | string; icon: React.ElementType; color: string; subtitle?: string; } // ============================================ // CACHE GLOBAL POUR LE DASHBOARD // ============================================ const dashboardCache = { data: null as StatsData | null, timestamp: 0, params: null as any, CACHE_DURATION: 3 * 60 * 1000, // 3 minutes }; // État global pour éviter les appels simultanés let globalFetchPromise: Promise | null = null; let lastFetchTime = 0; // ============================================ // SOUS-COMPOSANT OPTIMISÉ AVEC MEMO // ============================================ const StatCard = memo(({ title, value, icon: Icon, color, subtitle }) => (

{title}

{value}

{subtitle &&

{subtitle}

}
)); StatCard.displayName = "StatCard"; // ============================================ // COMPOSANT DES GRAPHIQUES (Mémoïsé) // ============================================ const PieChartComponent = memo<{ data: any[]; title: string; icon: React.ElementType; colors: string[]; dataKey: string; nameKey: string; }>(({ data, title, icon: Icon, colors, dataKey, nameKey }) => { return (

{title}

{data.length > 0 ? ( {data.map((_, index) => ( ))} ) : (
Aucune donnée
)}
); }); PieChartComponent.displayName = "PieChartComponent"; // ============================================ // COMPOSANT PRINCIPAL // ============================================ const Dashboard: React.FC = () => { const [loading, setLoading] = useState(false); const [stats, setStats] = useState(() => dashboardCache.data); const [annee, setAnnee] = useState(new Date().getFullYear()); // Référence pour annuler les requêtes en cours const abortControllerRef = useRef(null); const isMounted = useRef(true); const debounceTimeoutRef = useRef | null>(null); // Options de filtres const anneeOptions: OptionType[] = useMemo(() => [ { value: 2024, label: "2024" }, { value: 2025, label: "2025" }, { value: 2026, label: "2026" }, { value: 2027, label: "2027" }, ], []); // Fonction de fetch optimisée avec cache const fetchStats = useCallback(async (forceRefresh = false) => { // Vérifier le cache const now = Date.now(); const currentParams = { annee }; const cacheKey = JSON.stringify(currentParams); const cacheValid = dashboardCache.data && dashboardCache.params === cacheKey && (now - dashboardCache.timestamp) < dashboardCache.CACHE_DURATION; if (!forceRefresh && cacheValid) { console.log("📦 Utilisation du cache du dashboard"); setStats(dashboardCache.data); return; } // Éviter les appels simultanés if (globalFetchPromise) { console.log("⏳ Attente de la requête en cours..."); await globalFetchPromise; return; } // Rate limiting if (!forceRefresh && (now - lastFetchTime) < 1500) { console.log("⏳ Appel trop fréquent, attente..."); return; } // Annuler la requête précédente if (abortControllerRef.current) { abortControllerRef.current.abort(); } const controller = new AbortController(); abortControllerRef.current = controller; lastFetchTime = now; setLoading(true); const params = { annee, }; const paramsKey = JSON.stringify(params); globalFetchPromise = dashboardService .getStats(params) .then((res) => { const data = res.data?.data || null; if (isMounted.current) { // Mettre à jour le cache dashboardCache.data = data; dashboardCache.timestamp = now; dashboardCache.params = paramsKey; setStats(data); } return data; }) .catch((error: any) => { if (error.name === "AbortError" || error.name === "CanceledError") { console.log("⚠️ Requête annulée"); return; } console.error("Erreur chargement dashboard:", error); if (isMounted.current) { // Garder les données du cache si disponibles if (!dashboardCache.data) { console.error("Aucune donnée en cache disponible."); } } throw error; }) .finally(() => { globalFetchPromise = null; if (isMounted.current) { setLoading(false); } }); await globalFetchPromise; }, [annee]); // Chargement initial avec cache useEffect(() => { isMounted.current = true; fetchStats(); return () => { isMounted.current = false; if (abortControllerRef.current) { abortControllerRef.current.abort(); } if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current); } }; }, [fetchStats]); // Rafraîchir avec debounce quand les filtres changent useEffect(() => { if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current); } debounceTimeoutRef.current = setTimeout(() => { // Invalider le cache quand les filtres changent dashboardCache.timestamp = 0; fetchStats(); }, 300); // Debounce de 300ms return () => { if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current); } }; }, [annee, fetchStats]); // Fonction pour rafraîchir manuellement const handleRefresh = useCallback(() => { dashboardCache.timestamp = 0; fetchStats(true); }, [fetchStats]); // Gestionnaires de changement optimisés const handleAnneeChange = useCallback((option: SingleValue) => { if (option) setAnnee(Number(option.value)); }, []); // ============================================ // MÉMOÏSATION DES DONNÉES TRANSFORMÉES // ============================================ const secteurData = useMemo(() => { if (!Array.isArray(stats?.secteurs)) return []; return stats.secteurs.map((item) => ({ name: item.secteur || "Non renseigné", count: Number(item.count) || 0, avg_score: item.avg_score, })); }, [stats?.secteurs]); const paysDataFormatted = useMemo(() => { if (!Array.isArray(stats?.pays)) return []; return stats.pays.map((item) => ({ name: item.pays || "Non renseigné", count: Number(item.count) || 0, avg_score: item.avg_score, })); }, [stats?.pays]); // Palette de couleurs FNE const COLORS = useMemo( () => ["#DAA520", "#FFD700", "#0A0A0A", "#3A3A3A", "#F5F5F5", "#2A2A2A", "#1A1A1A", "#FFE55C"], [] ); // Affichage du chargement initial if (loading && !stats) { return (
); } // Fallback si aucune donnée if (!stats) { return (

Aucune donnée disponible pour ces critères.

); } const { stats: generalStats, histogramme, top5 } = stats; return (
{/* En-tête */}

Tableau de bord

options={anneeOptions} value={anneeOptions.find((o) => o.value === annee) || null} onChange={handleAnneeChange} className="w-32" isClearable={false} styles={{ control: (p) => ({ ...p, borderColor: "#DAA520", "&:hover": { borderColor: "#FFD700" }, boxShadow: "none", }), }} />
{/* Cartes statistiques */}
{/* Graphiques Principaux */}
{/* Histogramme des scores */}

Répartition des scores globaux

{histogramme && histogramme.length > 0 ? ( ) : (
Aucune donnée
)}
{/* Top 5 des candidats */}

Top 5 des meilleures candidatures

{top5 && top5.length > 0 ? ( {["#", "Nom", "Projet", "Secteur", "Pays", "Score"].map((header) => ( ))} {top5.map((item, index) => ( ))}
{header}
{index + 1} {item.nom} {item.nom_projet} {item.secteur} {item.pays} {item.score_total} / 100
) : (

Aucun candidat classé pour le moment.

)}
); }; export default Dashboard; Succès En Direct - Premier TV Show d’Investissement Africain succesSite
SUCCÈS EN DIRECT
Succès En Direct

Premier TV show d'investissement
100% Africain

Connecter investisseurs et entrepreneurs en toute confiance.

Scroll

Qui sommes-nous ?

Succès En Direct est le premier TV show d'investissement en Afrique francophone.

30 minutes
pour convaincre
4 investisseurs
de renom
Jusqu'à 100M FCFA
levés
+4 zones
monétaires couvertes
Diffusé
sur un média panafricain
AFG Bank
Partenaire Financier
Air Côte d'Ivoire
Transporteur Officiel

À mi-chemin entre média d'influence et accélérateur économique, nous révélons les talents et connectons les entrepreneurs aux marchés régionaux, au mentorat et au capital.

"

Pitcher une idée. Donner vie à un rêve.

"

C'est ça, Succès En Direct.

Ils font notre crédibilité

Baobabs & Sages, unis pour la réussite africaine

Voir tous les investisseurs
🎙️

4 investisseurs permanents sur le plateau — Prêts à investir dans votre projet

Candidat

Votre idée mérite
d'être lancée

Rejoignez Succès En Direct et transformez votre vision en réalité.

Appuyez sur GO
Passez à l'action - Lancez votre projet avec Succès En Direct
GO!

Derniers articles

Découvrez nos derniers articles pour rester informé et inspiré au quotidien.

Tous les articles
Entrepreneur en Afrique
Success

Informel et formel

Lire
Réseaux professionnels en Afrique
Success

Tu as une idée

Lire
Succès et croissance économique
Success

Pitch ton idée. Change ta vie.

Lire