563 lines
23 KiB
TypeScript
563 lines
23 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Calendar, Users, Clock, CheckCircle, XCircle, AlertCircle,
|
|
Plus, LogOut, FileSpreadsheet, RefreshCw, Edit, ClipboardList, FileText
|
|
} from "lucide-react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { toast } from "sonner";
|
|
import { useSSE } from "@/hooks/useSSE";
|
|
import { PublicClientApplication } from "@azure/msal-browser";
|
|
|
|
// Configuration MSAL
|
|
const msalConfig = {
|
|
auth: {
|
|
clientId: "4bb4cc24-bac3-427c-b02c-5d14fc67b561",
|
|
authority: "https://login.microsoftonline.com/9840a2a0-6ae1-4688-b03d-d2ec291be0f9",
|
|
redirectUri: "https://mygta-rh.ensup-adm.net",
|
|
},
|
|
cache: {
|
|
cacheLocation: "sessionStorage",
|
|
storeAuthStateInCookie: false,
|
|
},
|
|
};
|
|
|
|
interface Stats {
|
|
enAttente: number;
|
|
valideeCeMois: number;
|
|
nombreEquipes: number;
|
|
}
|
|
|
|
interface Demande {
|
|
Id: number;
|
|
nomEmploye: string;
|
|
typesConge: string;
|
|
DateDebut: string;
|
|
Statut: string;
|
|
}
|
|
|
|
const Dashboard = () => {
|
|
const navigate = useNavigate();
|
|
const [stats, setStats] = useState<Stats>({ enAttente: 0, valideeCeMois: 0, nombreEquipes: 0 });
|
|
const [recentRequests, setRecentRequests] = useState<Demande[]>([]);
|
|
const [user, setUser] = useState<any>(null);
|
|
const [token, setToken] = useState<string>('');
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
// ✅ Chargement initial de l'utilisateur
|
|
useEffect(() => {
|
|
const userData = localStorage.getItem('user');
|
|
const userToken = localStorage.getItem('token');
|
|
|
|
if (!userData || !userToken) {
|
|
toast.error("Session expirée, reconnexion nécessaire");
|
|
navigate('/');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const parsedUser = JSON.parse(userData);
|
|
setUser(parsedUser);
|
|
setToken(userToken);
|
|
} catch (error) {
|
|
console.error('Erreur parsing user:', error);
|
|
toast.error("Erreur lors de la récupération des données utilisateur");
|
|
navigate('/');
|
|
}
|
|
}, [navigate]);
|
|
|
|
// ✅ Fonction de chargement des données
|
|
const chargerDonnees = async () => {
|
|
if (!token) {
|
|
console.log('⏳ Pas de token, attente...');
|
|
return;
|
|
}
|
|
|
|
console.log('📥 Chargement des données du dashboard...');
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
// Charger les stats
|
|
const respStats = await fetch('/api/stats', {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
|
|
if (!respStats.ok) {
|
|
throw new Error(`Erreur stats: ${respStats.status}`);
|
|
}
|
|
|
|
const dataStats = await respStats.json();
|
|
console.log('📊 Stats reçues:', dataStats);
|
|
setStats(dataStats);
|
|
|
|
// Charger les demandes récentes
|
|
const respDemandes = await fetch('/api/demandes?limit=5', {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
|
|
if (!respDemandes.ok) {
|
|
throw new Error(`Erreur demandes: ${respDemandes.status}`);
|
|
}
|
|
|
|
const dataDemandes = await respDemandes.json();
|
|
console.log('📋 Demandes reçues:', dataDemandes.length);
|
|
setRecentRequests(dataDemandes);
|
|
|
|
} catch (error: any) {
|
|
console.error("❌ Erreur chargement données:", error);
|
|
toast.error("Erreur lors du chargement des données: " + error.message);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// ✅ Charger les données quand le token est disponible
|
|
useEffect(() => {
|
|
if (token) {
|
|
chargerDonnees();
|
|
}
|
|
}, [token]);
|
|
|
|
// 🔔 CONNEXION SSE
|
|
useSSE((event) => {
|
|
console.log('🔔 Événement SSE reçu dans Dashboard:', event.type);
|
|
|
|
switch (event.type) {
|
|
case 'connected':
|
|
console.log('✅ Dashboard connecté au serveur temps réel');
|
|
toast.success("Connexion temps réel établie", { duration: 2000 });
|
|
break;
|
|
|
|
case 'demande-created':
|
|
console.log('🆕 Nouvelle demande créée');
|
|
toast.info("Nouvelle demande de congé", { duration: 3000 });
|
|
if (token) chargerDonnees();
|
|
break;
|
|
|
|
case 'demande-updated':
|
|
console.log('🔄 Demande mise à jour');
|
|
if (token) chargerDonnees();
|
|
break;
|
|
|
|
case 'demande-validated':
|
|
console.log('✅ Demande validée/refusée');
|
|
toast.info("Une demande a été traitée", { duration: 3000 });
|
|
if (token) chargerDonnees();
|
|
break;
|
|
|
|
// 🆕 GESTION DES ANNULATIONS
|
|
case 'demande-cancelled':
|
|
console.log('🚫 Demande annulée');
|
|
toast.warning("Une demande a été annulée", {
|
|
description: "La liste a été mise à jour",
|
|
duration: 4000
|
|
});
|
|
if (token) chargerDonnees();
|
|
break;
|
|
|
|
case 'demande-deleted':
|
|
console.log('🗑️ Demande supprimée');
|
|
if (token) chargerDonnees();
|
|
break;
|
|
|
|
case 'demande-list-updated':
|
|
console.log('📋 Liste des demandes mise à jour');
|
|
if (token) chargerDonnees();
|
|
break;
|
|
|
|
case 'heartbeat':
|
|
// Heartbeat pour garder la connexion
|
|
break;
|
|
|
|
default:
|
|
console.log('❓ Type d\'événement inconnu:', event.type);
|
|
}
|
|
});
|
|
|
|
const handleLogout = async () => {
|
|
try {
|
|
const msalInstance = new PublicClientApplication(msalConfig);
|
|
await msalInstance.initialize();
|
|
|
|
const accounts = msalInstance.getAllAccounts();
|
|
|
|
if (accounts.length > 0) {
|
|
// Déconnexion popup au lieu de redirect
|
|
await msalInstance.logoutPopup({
|
|
account: accounts[0],
|
|
mainWindowRedirectUri: "https://mygta-rh.ensup-adm.net"
|
|
});
|
|
}
|
|
|
|
// Nettoyer tout le stockage
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('user');
|
|
sessionStorage.clear();
|
|
|
|
toast.success("Déconnexion réussie");
|
|
navigate("/");
|
|
} catch (error) {
|
|
console.error("Erreur déconnexion:", error);
|
|
// Forcer la déconnexion locale même en cas d'erreur
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('user');
|
|
sessionStorage.clear();
|
|
toast.success("Déconnexion réussie");
|
|
navigate("/");
|
|
}
|
|
};
|
|
|
|
// ✅ FONCTION MISE À JOUR AVEC LE STATUT "ANNULÉE"
|
|
const getStatusBadge = (status: string) => {
|
|
switch (status) {
|
|
case "En attente":
|
|
return <Badge variant="outline" className="bg-warning/10 text-warning border-warning/20">
|
|
<Clock className="w-3 h-3 mr-1" />
|
|
En attente
|
|
</Badge>;
|
|
case "Validée":
|
|
return <Badge variant="outline" className="bg-success/10 text-success border-success/20">
|
|
<CheckCircle className="w-3 h-3 mr-1" />
|
|
Validée
|
|
</Badge>;
|
|
case "Refusée":
|
|
return <Badge variant="outline" className="bg-destructive/10 text-destructive border-destructive/20">
|
|
<XCircle className="w-3 h-3 mr-1" />
|
|
Refusée
|
|
</Badge>;
|
|
case "Annulée":
|
|
return <Badge variant="outline" className="bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-800 dark:text-gray-300">
|
|
<AlertCircle className="w-3 h-3 mr-1" />
|
|
Annulée
|
|
</Badge>;
|
|
default:
|
|
return <Badge variant="outline" className="bg-gray-100 text-gray-700">
|
|
{status}
|
|
</Badge>;
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateString: string) => {
|
|
if (!dateString) return '';
|
|
try {
|
|
return new Date(dateString).toLocaleDateString('fr-FR', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric'
|
|
});
|
|
} catch (error) {
|
|
console.error('Erreur formatage date:', error);
|
|
return dateString;
|
|
}
|
|
};
|
|
|
|
const statsCards = [
|
|
{
|
|
title: "Demandes en attente",
|
|
value: stats.enAttente,
|
|
icon: Clock,
|
|
color: "text-warning",
|
|
bg: "bg-warning/10",
|
|
action: () => navigate("/validation")
|
|
},
|
|
{
|
|
title: "Validées ce mois",
|
|
value: stats.valideeCeMois,
|
|
icon: CheckCircle,
|
|
color: "text-success",
|
|
bg: "bg-success/10",
|
|
action: () => navigate("/validation")
|
|
},
|
|
{
|
|
title: "Total équipes",
|
|
value: stats.nombreEquipes,
|
|
icon: Users,
|
|
color: "text-primary",
|
|
bg: "bg-primary/10",
|
|
action: () => navigate("/teams")
|
|
},
|
|
];
|
|
|
|
const actionCards = [
|
|
{
|
|
title: "Saisie manuelle",
|
|
description: "Enregistrer une demande",
|
|
icon: Plus,
|
|
color: "bg-[#7e5aa2]",
|
|
action: () => navigate("/saisie-manuelle")
|
|
},
|
|
{
|
|
title: "Validation",
|
|
description: "Valider les demandes",
|
|
icon: CheckCircle,
|
|
color: "bg-[#7e5aa2]", // Mauve GTARH comme les autres
|
|
action: () => navigate("/validation")
|
|
},
|
|
|
|
{
|
|
title: "Récupération",
|
|
description: "Gérer les samedis travaillés",
|
|
icon: Clock,
|
|
color: "bg-purple-600",
|
|
action: () => navigate("/gestion-recuperation")
|
|
},
|
|
{
|
|
title: "Export paie",
|
|
description: "Générer un rapport",
|
|
icon: FileSpreadsheet,
|
|
color: "bg-blue-600",
|
|
action: () => navigate("/export-paie")
|
|
},
|
|
{
|
|
title: "Compteurs",
|
|
description: "Gérer les soldes",
|
|
icon: RefreshCw,
|
|
color: "bg-purple-600",
|
|
action: () => navigate("/compteurs")
|
|
},
|
|
{
|
|
title: "Équipes",
|
|
description: "Voir toutes les équipes",
|
|
icon: Users,
|
|
color: "bg-indigo-600",
|
|
action: () => navigate("/teams")
|
|
},
|
|
{
|
|
title: "Historique",
|
|
description: "Consulter l'historique",
|
|
icon: ClipboardList,
|
|
color: "bg-orange-600",
|
|
action: () => navigate("/historique")
|
|
},
|
|
{
|
|
title: "Comptes-Rendus RH",
|
|
description: "Gérer les forfaits jour",
|
|
icon: FileText,
|
|
color: "bg-cyan-600",
|
|
action: () => navigate("/compte-rendu-rh")
|
|
}
|
|
];
|
|
|
|
const handleDeleteRequest = async (id: number) => {
|
|
if (!confirm("Voulez-vous vraiment supprimer cette demande ?")) return;
|
|
|
|
if (!token) {
|
|
toast.error("Utilisateur non authentifié");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
console.log('🗑️ Suppression demande:', id);
|
|
|
|
const resp = await fetch(`/api/demandes/${id}`, {
|
|
method: "DELETE",
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
const data = await resp.json();
|
|
|
|
if (resp.ok) {
|
|
toast.success("Demande supprimée avec succès");
|
|
// Le SSE rechargera automatiquement, mais on peut forcer un rechargement immédiat
|
|
await chargerDonnees();
|
|
} else {
|
|
toast.error(data.error || "Erreur lors de la suppression");
|
|
}
|
|
} catch (error: any) {
|
|
console.error("❌ Erreur suppression:", error);
|
|
toast.error("Erreur lors de la suppression: " + error.message);
|
|
}
|
|
};
|
|
|
|
// ✅ Affichage du loader pendant le chargement initial
|
|
if (isLoading && !user) {
|
|
return (
|
|
<div className="min-h-screen bg-gradient-subtle flex items-center justify-center">
|
|
<div className="text-center">
|
|
<RefreshCw className="w-12 h-12 animate-spin text-[#7e5aa2] mx-auto mb-4" />
|
|
<p className="text-gray-600">Chargement du tableau de bord...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-subtle">
|
|
<header className="bg-card border-b border-border shadow-card">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-3">
|
|
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ backgroundColor: "#7e5aa2" }}>
|
|
<Calendar className="w-5 h-5 text-white" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-foreground">GTARH</h1>
|
|
{user && (
|
|
<p className="text-sm text-muted-foreground">
|
|
{user.prenom} {user.nom} - {user.role}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleLogout}
|
|
className="transition-smooth"
|
|
>
|
|
<LogOut className="w-4 h-4 mr-2" />
|
|
Déconnexion
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
|
{statsCards.map((stat, index) => (
|
|
<Card
|
|
key={index}
|
|
className="shadow-card border-0 bg-gradient-card transition-smooth hover:shadow-elegant cursor-pointer"
|
|
onClick={stat.action}
|
|
>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground mb-1">{stat.title}</p>
|
|
<p className="text-3xl font-bold text-foreground">{stat.value}</p>
|
|
</div>
|
|
<div className={`w-12 h-12 rounded-xl ${stat.bg} flex items-center justify-center`}>
|
|
<stat.icon className={`w-6 h-6 ${stat.color}`} />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{/* Action Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
{actionCards.map((action, index) => (
|
|
<Card
|
|
key={index}
|
|
className="shadow-card border-0 hover:shadow-elegant transition-smooth cursor-pointer group"
|
|
onClick={action.action}
|
|
>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-start gap-4">
|
|
<div className={`w-12 h-12 rounded-xl ${action.color} flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform`}>
|
|
<action.icon className="w-6 h-6 text-white" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3 className="font-semibold text-lg mb-1">{action.title}</h3>
|
|
<p className="text-sm text-muted-foreground">{action.description}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{/* Recent Requests */}
|
|
<Card className="shadow-card border-0">
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle>Demandes récentes</CardTitle>
|
|
<CardDescription>Les dernières demandes soumises</CardDescription>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => chargerDonnees()}
|
|
disabled={isLoading}
|
|
>
|
|
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
|
Actualiser
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => navigate("/validation")}
|
|
>
|
|
Voir tout
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isLoading && recentRequests.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<RefreshCw className="w-12 h-12 animate-spin text-[#7e5aa2] mx-auto mb-4" />
|
|
<p className="text-muted-foreground">Chargement des demandes...</p>
|
|
</div>
|
|
) : recentRequests.length > 0 ? (
|
|
<div className="space-y-4">
|
|
{recentRequests.map((request) => (
|
|
<div
|
|
key={request.Id}
|
|
className="flex items-center justify-between p-4 rounded-lg border border-border bg-gradient-card hover:shadow-card transition-smooth cursor-pointer group"
|
|
onClick={() => navigate(`/modification-demande/${request.Id}`)}
|
|
>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-3 mb-1">
|
|
<p className="font-medium text-foreground">{request.nomEmploye}</p>
|
|
<Badge variant="secondary" className="text-xs">{request.typesConge}</Badge>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
À partir du {formatDate(request.DateDebut)}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{getStatusBadge(request.Statut)}
|
|
|
|
{/* Bouton Edit */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
navigate(`/modification-demande/${request.Id}`);
|
|
}}
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
</Button>
|
|
|
|
{/* Bouton Supprimer */}
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteRequest(request.Id);
|
|
}}
|
|
>
|
|
<XCircle className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-12">
|
|
<AlertCircle className="w-16 h-16 text-muted-foreground mx-auto mb-4 opacity-50" />
|
|
<p className="text-muted-foreground text-lg">Aucune demande récente</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</main>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Dashboard; |