Files
btpxpress-frontend/app/(main)/dashboard/page.tsx
dahoud e15d717a40 Fix: Correction critique de la boucle OAuth - Empêcher les échanges multiples du code
PROBLÈME RÉSOLU:
- Erreur "Code already used" répétée dans les logs Keycloak
- Boucle infinie de tentatives d'échange du code d'autorisation OAuth
- Utilisateurs bloqués à la connexion

CORRECTIONS APPLIQUÉES:
1. Ajout de useRef pour protéger contre les exécutions multiples
   - hasExchanged.current: Flag pour prévenir les réexécutions
   - isProcessing.current: Protection pendant le traitement

2. Modification des dépendances useEffect
   - AVANT: [searchParams, router] → exécution à chaque changement
   - APRÈS: [] → exécution unique au montage du composant

3. Amélioration du logging
   - Console logs pour debug OAuth flow
   - Messages emoji pour faciliter le suivi

4. Nettoyage de l'URL
   - window.history.replaceState() pour retirer les paramètres OAuth
   - Évite les re-renders causés par les paramètres dans l'URL

5. Gestion d'erreurs améliorée
   - Capture des erreurs JSON du serveur
   - Messages d'erreur plus explicites

FICHIERS AJOUTÉS:
- app/(main)/aide/* - 4 pages du module Aide (documentation, tutoriels, support)
- app/(main)/messages/* - 4 pages du module Messages (inbox, envoyés, archives)
- app/auth/callback/page.tsx.backup - Sauvegarde avant modification

IMPACT:
 Un seul échange de code par authentification
 Plus d'erreur "Code already used"
 Connexion fluide et sans boucle
 Logs propres et lisibles

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 23:45:33 +00:00

825 lines
38 KiB
TypeScript

'use client';
export const dynamic = 'force-dynamic';
import React, { useContext, useRef, useState, useEffect, useCallback, useMemo } from 'react';
import { Button } from 'primereact/button';
import { Column } from 'primereact/column';
import { DataTable } from 'primereact/datatable';
import { Avatar } from 'primereact/avatar';
import { Toast } from 'primereact/toast';
import { Dialog } from 'primereact/dialog';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { confirmDialog, ConfirmDialog } from 'primereact/confirmdialog';
import { useRouter, useSearchParams } from 'next/navigation';
import { ProgressSpinner } from 'primereact/progressspinner';
import { Message } from 'primereact/message';
import { LayoutContext } from '../../../layout/context/layoutcontext';
import { useDashboard, ChantierActif } from '../../../hooks/useDashboard';
import { useChantierActions } from '../../../hooks/useChantierActions';
import {
ChantierStatusBadge,
ChantierProgressBar,
ChantierUrgencyIndicator
} from '../../../components/chantiers';
import ActionButtonGroup from '../../../components/chantiers/ActionButtonGroup';
import { ActionButtonType } from '../../../components/chantiers/ActionButtonStyles';
import CFASymbol from '../../../components/ui/CFASymbol';
const Dashboard = () => {
console.log('🏗️ Dashboard: Composant chargé');
const { layoutConfig } = useContext(LayoutContext);
const toast = useRef<Toast>(null);
const router = useRouter();
const searchParams = useSearchParams();
const [selectedChantier, setSelectedChantier] = useState<ChantierActif | null>(null);
const [showQuickView, setShowQuickView] = useState(false);
const [authProcessed, setAuthProcessed] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
const [authInProgress, setAuthInProgress] = useState(false);
const [isHydrated, setIsHydrated] = useState(false);
// Flag global pour éviter les appels multiples (React 18 StrictMode)
const authProcessingRef = useRef(false);
// Mémoriser le code traité pour éviter les retraitements
const processedCodeRef = useRef<string | null>(null);
// Flag pour éviter les redirections multiples
const redirectingRef = useRef(false);
const currentCode = searchParams.get('code');
const currentState = searchParams.get('state');
console.log('🏗️ Dashboard: SearchParams:', {
code: currentCode?.substring(0, 20) + '...',
state: currentState,
authProcessed,
processedCode: processedCodeRef.current?.substring(0, 20) + '...',
authInProgress: authInProgress
});
// Gérer l'hydratation pour éviter les erreurs SSR/CSR
useEffect(() => {
setIsHydrated(true);
}, []);
// Réinitialiser authProcessed si on a un nouveau code d'autorisation
useEffect(() => {
if (!isHydrated) return; // Attendre l'hydratation
if (currentCode && authProcessed && !authInProgress && processedCodeRef.current !== currentCode) {
console.log('🔄 Dashboard: Nouveau code détecté, réinitialisation authProcessed');
setAuthProcessed(false);
processedCodeRef.current = null;
}
}, [currentCode, authProcessed, authInProgress, isHydrated]);
// Fonction pour nettoyer l'URL des paramètres d'authentification
const cleanAuthParams = useCallback(() => {
if (typeof window === 'undefined') return; // Protection SSR
const url = new URL(window.location.href);
if (url.searchParams.has('code') || url.searchParams.has('state')) {
url.search = '';
window.history.replaceState({}, '', url.toString());
// Nettoyer sessionStorage après succès if (typeof window !== 'undefined') { sessionStorage.removeItem('oauth_code_processed'); }
}
}, []);
// Hooks pour les données et actions du dashboard
// Charger les données après authentification ou si pas de code d'autorisation
const shouldLoadData = authProcessed || !currentCode;
const {
metrics,
chantiersActifs,
loading,
error,
refresh
} = useDashboard();
const chantierActions = useChantierActions({
toast,
onRefresh: refresh
});
// Optimisations avec useMemo pour les calculs coûteux
const formattedMetrics = useMemo(() => {
if (!metrics) return null;
return {
chantiersActifs: metrics.chantiersActifs || 0,
chiffreAffaires: new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
notation: 'compact',
maximumFractionDigits: 1
}).format(metrics.chiffreAffaires || 0),
chantiersEnRetard: metrics.chantiersEnRetard || 0,
tauxReussite: `${metrics.tauxReussite || 0}%`
};
}, [metrics]);
const chantiersActifsCount = useMemo(() => {
return chantiersActifs?.length || 0;
}, [chantiersActifs]);
// Templates DataTable optimisés avec useCallback
const chantierBodyTemplate = useCallback((rowData: ChantierActif) => {
return (
<div className="flex align-items-center">
<ChantierUrgencyIndicator chantier={rowData} className="mr-2" />
<div className="mr-2">
<i className="pi pi-building text-primary"></i>
</div>
<div>
<div className="font-medium">{rowData.nom}</div>
<div className="text-500 text-sm">ID: {rowData.id?.substring(0, 8)}</div>
</div>
</div>
);
}, []);
const clientBodyTemplate = useCallback((rowData: ChantierActif) => {
const clientName = typeof rowData.client === 'string' ? rowData.client : rowData.client?.nom || 'N/A';
return (
<div className="flex align-items-center">
<Avatar label={clientName.charAt(0)} size="normal" shape="circle" className="mr-2" />
<span>{clientName}</span>
</div>
);
}, []);
const statutBodyTemplate = useCallback((rowData: ChantierActif) => {
return <ChantierStatusBadge statut={rowData.statut} />;
}, []);
const avancementBodyTemplate = useCallback((rowData: ChantierActif) => {
return (
<div className="flex align-items-center justify-content-center" style={{ minWidth: '90px' }}>
<ChantierProgressBar
value={rowData.avancement || 0}
showCompletionIcon={false}
showPercentage={false}
showValue={false}
size="normal"
style={{ width: '75px' }}
/>
<span className="ml-2 text-xs font-semibold text-gray-600">
{rowData.avancement || 0}%
</span>
</div>
);
}, []);
const budgetBodyTemplate = useCallback((rowData: ChantierActif) => {
if (!rowData.budget) return <span className="text-500">-</span>;
return (
<span className="font-semibold flex align-items-center">
{new Intl.NumberFormat('fr-FR', {
style: 'decimal',
notation: 'compact',
maximumFractionDigits: 1
}).format(rowData.budget)}
<CFASymbol size="medium" className="ml-1" />
</span>
);
}, []);
const handleQuickView = useCallback((chantier: ChantierActif) => {
setSelectedChantier(chantier);
setShowQuickView(true);
// Le hook gère déjà les détails supplémentaires
chantierActions.handleQuickView(chantier);
}, [chantierActions]);
// Traiter l'authentification Keycloak si nécessaire
useEffect(() => {
// Attendre l'hydratation avant de traiter l'authentification
if (!isHydrated) {
return;
}
// Mode développement : ignorer l'authentification Keycloak
if (process.env.NEXT_PUBLIC_DEV_MODE === 'true' || process.env.NEXT_PUBLIC_SKIP_AUTH === 'true') {
console.log('🔧 Dashboard: Mode développement détecté, authentification ignorée');
setAuthProcessed(true);
return;
}
// Si l'authentification est déjà terminée, ne rien faire
if (authProcessed) {
return;
}
// Si on est en train de rediriger, ne rien faire
if (redirectingRef.current) {
return;
}
const processAuth = async () => {
try {
// Protection absolue contre les boucles
if (authInProgress || authProcessingRef.current) {
console.log('🛑 Dashboard: Processus d\'authentification déjà en cours, arrêt');
return;
}
// Les tokens sont maintenant stockés dans des cookies HttpOnly
// Le middleware les vérifiera automatiquement
// Pas besoin de vérifier localStorage
const code = currentCode;
const state = currentState;
const error = searchParams.get('error');
if (error) {
setAuthError(`Erreur d'authentification: ${error}`);
setAuthProcessed(true);
return;
}
// Vérifier si ce code a déjà été traité
if (code && !authProcessed && !authInProgress && !authProcessingRef.current && processedCodeRef.current !== code) {
try {
console.log('🔐 Traitement du code d\'autorisation Keycloak...', { code: code.substring(0, 20) + '...', state });
// Marquer IMMÉDIATEMENT dans sessionStorage pour empêcher double traitement
if (typeof window !== 'undefined') {
sessionStorage.setItem('oauth_code_processed', code);
}
// Marquer l'authentification comme en cours pour éviter les appels multiples
authProcessingRef.current = true;
processedCodeRef.current = code;
setAuthInProgress(true);
console.log('📡 Appel de /api/auth/token...');
// Utiliser fetch au lieu d'un formulaire pour éviter la boucle de redirection
const response = await fetch('/api/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, state: state || '' })
});
if (response.ok) {
console.log('✅ Authentification réussie, tokens stockés dans les cookies');
// Nettoyer l'URL en enlevant les paramètres OAuth
window.history.replaceState({}, '', '/dashboard');
// Nettoyer sessionStorage après succès if (typeof window !== 'undefined') { sessionStorage.removeItem('oauth_code_processed'); }
// Marquer l'authentification comme terminée
setAuthProcessed(true);
setAuthInProgress(false);
authProcessingRef.current = false;
} else {
console.error("❌ Erreur lors de l'authentification");
const errorData = await response.json();
setAuthError(`Erreur lors de l'authentification: ${errorData.error || 'Erreur inconnue'}`);
setAuthProcessed(true);
setAuthInProgress(false);
authProcessingRef.current = false;
}
} catch (error) {
console.error('❌ Erreur lors du traitement de l\'authentification:', error);
// ARRÊTER LA BOUCLE : Ne pas rediriger automatiquement, juste marquer comme traité
console.log('🛑 Dashboard: Erreur d\'authentification, arrêt du processus pour éviter la boucle');
const errorMessage = error instanceof Error ? error.message : 'Erreur inconnue lors de l\'authentification';
setAuthError(`Erreur lors de l'authentification: ${errorMessage}`);
setAuthProcessed(true);
setAuthInProgress(false);
authProcessingRef.current = false;
}
} else {
setAuthProcessed(true);
setAuthInProgress(false);
authProcessingRef.current = false;
}
} catch (error) {
console.error('❌ Erreur générale lors du traitement de l\'authentification:', error);
const errorMessage = error instanceof Error ? error.message : 'Erreur inconnue lors de l\'authentification';
setAuthError(`Erreur lors de l'authentification: ${errorMessage}`);
setAuthProcessed(true);
setAuthInProgress(false);
authProcessingRef.current = false;
}
};
processAuth();
}, [currentCode, currentState, authProcessed, authInProgress, isHydrated]);
const actionBodyTemplate = useCallback((rowData: ChantierActif) => {
const actions: ActionButtonType[] = ['VIEW', 'PHASES', 'PLANNING', 'STATS', 'MENU'];
const handleActionClick = (action: ActionButtonType | string, chantier: ChantierActif) => {
try {
switch (action) {
case 'VIEW':
handleQuickView(chantier);
break;
case 'PHASES':
router.push(`/chantiers/${chantier.id}/phases`);
break;
case 'PLANNING':
router.push(`/planning?chantier=${chantier.id}`);
break;
case 'STATS':
chantierActions.handleViewStats(chantier);
break;
case 'MENU':
// Le menu sera géré directement par ChantierMenuActions
break;
// Actions du menu "Plus d'actions"
case 'suspend':
chantierActions.handleSuspendChantier(chantier);
break;
case 'close':
chantierActions.handleCloseChantier(chantier);
break;
case 'notify-client':
chantierActions.handleNotifyClient(chantier);
break;
case 'generate-report':
chantierActions.handleGenerateReport(chantier);
break;
case 'generate-invoice':
chantierActions.handleGenerateInvoice(chantier);
break;
case 'create-amendment':
chantierActions.handleCreateAmendment(chantier);
break;
default:
console.warn('Action non reconnue:', action);
break;
}
} catch (error) {
console.error('Erreur lors de l\'exécution de l\'action:', action, error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Une erreur est survenue lors de l\'exécution de l\'action',
life: 3000
});
}
};
return (
<div className="flex justify-content-center">
<ActionButtonGroup
chantier={rowData}
actions={actions}
onAction={handleActionClick}
size="sm"
spacing="sm"
/>
</div>
);
}, [handleQuickView, router, chantierActions]);
// Attendre l'hydratation pour éviter les erreurs SSR/CSR
if (!isHydrated) {
return (
<div className="grid">
<div className="col-12">
<div className="card">
<div className="text-center">
<ProgressSpinner style={{ width: '50px', height: '50px' }} />
<h5 className="mt-3">Chargement...</h5>
<p className="text-600">Initialisation de l'application</p>
</div>
</div>
</div>
</div>
);
}
// Afficher le chargement pendant le traitement de l'authentification
if (!authProcessed) {
return (
<div className="grid">
<div className="col-12">
<div className="card">
<div className="text-center">
<ProgressSpinner style={{ width: '50px', height: '50px' }} />
<h5 className="mt-3">Authentification en cours...</h5>
<p className="text-600">Traitement des informations de connexion</p>
</div>
</div>
</div>
</div>
);
}
// Afficher l'erreur d'authentification
if (authError) {
return (
<div className="grid">
<div className="col-12">
<div className="card">
<div className="text-center">
<i className="pi pi-exclamation-triangle text-red-500" style={{ fontSize: '4rem' }}></i>
<h5 className="text-red-500">Erreur d'authentification</h5>
<p className="text-600 mb-4">{authError}</p>
<Button
label="Retour à la connexion"
icon="pi pi-sign-in"
onClick={() => window.location.href = '/api/auth/login'}
className="p-button-outlined"
/>
</div>
</div>
</div>
</div>
);
}
if (loading) {
return (
<div className="grid">
<div className="col-12">
<div className="card">
<div className="text-center">
<ProgressSpinner style={{ width: '50px', height: '50px' }} />
<h5 className="mt-3">Chargement des données...</h5>
<p className="text-600">Récupération des informations du dashboard</p>
</div>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="grid">
<div className="col-12">
<div className="card">
<div className="text-center">
<i className="pi pi-exclamation-triangle text-orange-500" style={{ fontSize: '4rem' }}></i>
<h5>Erreur de chargement</h5>
<p>{error}</p>
<Button
label="Réessayer"
icon="pi pi-refresh"
onClick={refresh}
className="p-button-text p-button-rounded"
/>
</div>
</div>
</div>
</div>
);
}
return (
<div className="grid">
<Toast ref={toast} />
<ConfirmDialog />
{/* En-tête du dashboard */}
<div className="col-12">
<div className="card">
<div className="flex justify-content-between align-items-center">
<h5>Dashboard BTPXpress</h5>
<Button
label="Actualiser"
icon="pi pi-refresh"
onClick={refresh}
className="p-button-outlined p-button-sm"
/>
</div>
</div>
</div>
{/* Métriques KPI - Style Atlantis */}
<div className="col-12 lg:col-3 md:col-6">
<div className="card mb-0">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Chantiers Actifs</span>
<div className="text-900 font-medium text-xl">
{loading ? '...' : (formattedMetrics?.chantiersActifs || 0)}
</div>
</div>
<div className="flex align-items-center justify-content-center bg-blue-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
<i className="pi pi-building text-blue-500 text-xl"></i>
</div>
</div>
</div>
</div>
<div className="col-12 lg:col-3 md:col-6">
<div className="card mb-0">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">CA Réalisé</span>
<div className="text-900 font-medium text-xl">
<div className="flex align-items-center">
{loading ? '...' : (formattedMetrics?.chiffreAffaires || '0')}
{!loading && <CFASymbol size="medium" className="ml-1" />}
</div>
</div>
</div>
<div className="flex align-items-center justify-content-center bg-green-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
<i className="pi pi-euro text-green-500 text-xl"></i>
</div>
</div>
</div>
</div>
<div className="col-12 lg:col-3 md:col-6">
<div className="card mb-0">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">En Retard</span>
<div className="text-900 font-medium text-xl">
{loading ? '...' : (formattedMetrics?.chantiersEnRetard || 0)}
</div>
</div>
<div className="flex align-items-center justify-content-center bg-orange-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
<i className="pi pi-exclamation-triangle text-orange-500 text-xl"></i>
</div>
</div>
</div>
</div>
<div className="col-12 lg:col-3 md:col-6">
<div className="card mb-0">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Taux Réussite</span>
<div className="text-900 font-medium text-xl">
{loading ? '...' : (formattedMetrics?.tauxReussite || '0%')}
</div>
</div>
<div className="flex align-items-center justify-content-center bg-cyan-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
<i className="pi pi-chart-line text-cyan-500 text-xl"></i>
</div>
</div>
</div>
</div>
{/* Tableau des chantiers actifs */}
<div className="col-12">
<div className="card">
<div className="flex justify-content-between align-items-center mb-5">
<h5>Chantiers Actifs ({chantiersActifsCount})</h5>
<Button
icon="pi pi-refresh"
className="p-button-text p-button-rounded"
onClick={refresh}
loading={loading}
/>
</div>
<DataTable
value={chantiersActifs || []}
paginator
rows={10}
className="p-datatable-customers"
emptyMessage="Aucun chantier actif trouvé"
loading={loading}
responsiveLayout="scroll"
>
<Column
field="nom"
header="Chantier"
body={chantierBodyTemplate}
sortable
/>
<Column
field="client"
header="Client"
body={clientBodyTemplate}
sortable
/>
<Column
field="statut"
header="Statut"
body={statutBodyTemplate}
sortable
/>
<Column
field="avancement"
header="Avancement"
body={avancementBodyTemplate}
style={{ width: '9rem', textAlign: 'center' }}
sortable
/>
<Column
field="budget"
header="Budget"
body={budgetBodyTemplate}
sortable
/>
<Column
header="Actions"
body={actionBodyTemplate}
style={{ width: '10rem', maxWidth: '10rem', textAlign: 'center' }}
frozen
alignFrozen="right"
/>
</DataTable>
</div>
</div>
{/* Dialog Vue Rapide */}
<Dialog
header={`Vue rapide : ${selectedChantier?.nom || ''}`}
visible={showQuickView}
onHide={() => setShowQuickView(false)}
style={{ width: '60vw' }}
breakpoints={{ '960px': '75vw', '641px': '100vw' }}
>
{selectedChantier && (() => {
// Calculs statistiques depuis les données existantes
const dateDebut = selectedChantier.dateDebut ? new Date(selectedChantier.dateDebut) : null;
const dateFinPrevue = selectedChantier.dateFinPrevue ? new Date(selectedChantier.dateFinPrevue) : null;
const aujourd_hui = new Date();
const joursEcoules = dateDebut ?
Math.floor((aujourd_hui.getTime() - dateDebut.getTime()) / (1000 * 60 * 60 * 24)) : 0;
const joursRestants = dateFinPrevue ?
Math.max(0, Math.floor((dateFinPrevue.getTime() - aujourd_hui.getTime()) / (1000 * 60 * 60 * 24))) : 0;
const dureeTotal = dateDebut && dateFinPrevue ?
Math.floor((dateFinPrevue.getTime() - dateDebut.getTime()) / (1000 * 60 * 60 * 24)) : 0;
const tauxDepense = selectedChantier.budget > 0 ?
Math.round((selectedChantier.coutReel / selectedChantier.budget) * 100) : 0;
const ecartBudget = selectedChantier.budget - selectedChantier.coutReel;
const retard = dateFinPrevue && aujourd_hui > dateFinPrevue && selectedChantier.statut !== 'TERMINE';
const joursRetard = retard ?
Math.floor((aujourd_hui.getTime() - dateFinPrevue.getTime()) / (1000 * 60 * 60 * 24)) : 0;
return (
<div className="grid">
{/* Informations principales */}
<div className="col-12 md:col-6">
<div className="field">
<label className="font-bold">Client</label>
<p>{typeof selectedChantier.client === 'string' ? selectedChantier.client : selectedChantier.client?.nom}</p>
</div>
<div className="field">
<label className="font-bold">Statut</label>
<div className="flex align-items-center gap-2">
<Tag value={selectedChantier.statut} severity={selectedChantier.statut === 'EN_COURS' ? 'success' : 'info'} />
{retard && (
<Tag value={`${joursRetard} jours de retard`} severity="danger" icon="pi pi-exclamation-triangle" />
)}
</div>
</div>
<div className="field">
<label className="font-bold">Avancement</label>
<div className="flex align-items-center gap-2">
<ProgressBar
value={selectedChantier.avancement || 0}
showValue
style={{ flex: 1 }}
/>
{selectedChantier.avancement === 100 && (
<i className="pi pi-check-circle text-green-500 text-xl" />
)}
</div>
</div>
<div className="field">
<label className="font-bold">Dates</label>
<div className="text-sm">
<div className="mb-1">
<i className="pi pi-calendar-plus text-green-500 mr-2"></i>
Début: {dateDebut ? dateDebut.toLocaleDateString('fr-FR') : 'Non définie'}
</div>
<div>
<i className="pi pi-calendar-times text-orange-500 mr-2"></i>
Fin prévue: {dateFinPrevue ? dateFinPrevue.toLocaleDateString('fr-FR') : 'Non définie'}
</div>
</div>
</div>
</div>
{/* Statistiques financières */}
<div className="col-12 md:col-6">
<div className="field">
<label className="font-bold">Budget</label>
<div className="text-sm">
<div className="mb-1">
<span className="font-semibold">Prévu: </span>
{selectedChantier.budget ? (
<span className="flex align-items-center">
{new Intl.NumberFormat('fr-FR', { style: 'decimal' }).format(selectedChantier.budget)}
<CFASymbol size="medium" className="ml-1" />
</span>
) : 'Non défini'}
</div>
<div className="mb-1">
<span className="font-semibold">Dépensé: </span>
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(selectedChantier.coutReel)}
<span className="ml-2 text-sm">({tauxDepense}%)</span>
</div>
<div>
<span className="font-semibold">Reste: </span>
<span className={`flex align-items-center ${ecartBudget >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{new Intl.NumberFormat('fr-FR', { style: 'decimal' }).format(Math.abs(ecartBudget))}
<CFASymbol size="medium" className="ml-1" />
</span>
</div>
</div>
</div>
<div className="field">
<label className="font-bold">Durée</label>
<div className="text-sm">
<div className="mb-1">
<i className="pi pi-clock mr-2"></i>
Durée totale: {dureeTotal} jours
</div>
<div className="mb-1">
<i className="pi pi-history mr-2 text-blue-500"></i>
Jours écoulés: {joursEcoules} jours
</div>
<div>
<i className="pi pi-hourglass mr-2 text-orange-500"></i>
Jours restants: {joursRestants > 0 ? `${joursRestants} jours` : 'Terminé'}
</div>
</div>
</div>
{/* Indicateurs de performance */}
<div className="field">
<label className="font-bold">Performance</label>
<div className="flex gap-3">
<div className="text-center">
<div className="text-2xl font-bold text-blue-500">
{selectedChantier.avancement || 0}%
</div>
<div className="text-xs text-500">Avancement</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-500">
{tauxDepense}%
</div>
<div className="text-xs text-500">Budget utilisé</div>
</div>
{joursEcoules > 0 && (
<div className="text-center">
<div className="text-2xl font-bold text-purple-500">
{Math.round((selectedChantier.avancement || 0) / joursEcoules * 10) / 10}%
</div>
<div className="text-xs text-500">% par jour</div>
</div>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="col-12">
<div className="flex justify-content-end gap-2">
<Button
label="Voir détails"
icon="pi pi-external-link"
className="p-button-text p-button-rounded"
onClick={() => {
setShowQuickView(false);
router.push(`/chantiers/${selectedChantier.id}`);
}}
/>
<Button
label="Gérer les phases"
icon="pi pi-sitemap"
className="p-button-text p-button-rounded"
style={{ color: '#10B981' }}
onClick={() => {
setShowQuickView(false);
router.push(`/chantiers/${selectedChantier.id}/phases`);
}}
/>
</div>
</div>
</div>
);
})()}
</Dialog>
</div>
);
};
export default Dashboard;