Initial commit
This commit is contained in:
834
app/(main)/dashboard/page.tsx
Normal file
834
app/(main)/dashboard/page.tsx
Normal file
@@ -0,0 +1,834 @@
|
||||
'use client';
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
// Réinitialiser authProcessed si on a un nouveau code d'autorisation
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
// Fonction pour nettoyer l'URL des paramètres d'authentification
|
||||
const cleanAuthParams = useCallback(() => {
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.has('code') || url.searchParams.has('state')) {
|
||||
url.search = '';
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Hooks pour les données et actions du dashboard
|
||||
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="small"
|
||||
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="small" 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]);
|
||||
|
||||
// Nettoyer les paramètres d'authentification au montage
|
||||
useEffect(() => {
|
||||
cleanAuthParams();
|
||||
}, [cleanAuthParams]);
|
||||
|
||||
// Traiter l'authentification Keycloak si nécessaire
|
||||
useEffect(() => {
|
||||
// Si l'authentification est déjà terminée, ne rien faire
|
||||
if (authProcessed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const processAuth = async () => {
|
||||
// Protection absolue contre les boucles
|
||||
if (authInProgress || authProcessingRef.current) {
|
||||
console.log('🛑 Dashboard: Processus d\'authentification déjà en cours, arrêt');
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier si on a déjà des tokens valides
|
||||
const hasTokens = localStorage.getItem('accessToken');
|
||||
if (hasTokens) {
|
||||
console.log('✅ Tokens déjà présents, arrêt du processus d\'authentification');
|
||||
setAuthProcessed(true);
|
||||
return;
|
||||
}
|
||||
|
||||
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 l'authentification comme en cours pour éviter les appels multiples
|
||||
authProcessingRef.current = true;
|
||||
processedCodeRef.current = code;
|
||||
setAuthInProgress(true);
|
||||
|
||||
// Nettoyer les anciens tokens avant l'échange
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('idToken');
|
||||
|
||||
console.log('📡 Appel API /api/auth/token...');
|
||||
|
||||
|
||||
const response = await fetch('/api/auth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ code, state }),
|
||||
});
|
||||
|
||||
console.log('📡 Réponse API /api/auth/token:', {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
statusText: response.statusText
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('❌ Échec de l\'échange de token:', {
|
||||
status: response.status,
|
||||
error: errorText
|
||||
});
|
||||
|
||||
// Gestion spécifique des codes expirés, invalides ou code verifier manquant
|
||||
if (errorText.includes('invalid_grant') ||
|
||||
errorText.includes('Code not valid') ||
|
||||
errorText.includes('Code verifier manquant')) {
|
||||
|
||||
console.log('🔄 Problème d\'authentification détecté:', errorText);
|
||||
|
||||
// Vérifier si on n'est pas déjà en boucle
|
||||
const retryCount = parseInt(localStorage.getItem('auth_retry_count') || '0');
|
||||
if (retryCount >= 3) {
|
||||
console.error('🚫 Trop de tentatives d\'authentification. Arrêt pour éviter la boucle infinie.');
|
||||
localStorage.removeItem('auth_retry_count');
|
||||
setAuthError('Erreur d\'authentification persistante. Veuillez rafraîchir la page.');
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('auth_retry_count', (retryCount + 1).toString());
|
||||
console.log(`🔄 Tentative ${retryCount + 1}/3 - Redirection vers nouvelle authentification...`);
|
||||
|
||||
// Nettoyer l'URL et rediriger vers une nouvelle authentification
|
||||
const url = new URL(window.location.href);
|
||||
url.search = '';
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
|
||||
// Attendre un peu pour éviter les boucles infinies
|
||||
setTimeout(() => {
|
||||
window.location.href = '/api/auth/login';
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Échec de l'échange de token: ${errorText}`);
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
console.log('✅ Tokens reçus dans le dashboard:', {
|
||||
hasAccessToken: !!tokens.access_token,
|
||||
hasRefreshToken: !!tokens.refresh_token,
|
||||
hasIdToken: !!tokens.id_token
|
||||
});
|
||||
|
||||
// Réinitialiser le compteur de tentatives d'authentification
|
||||
localStorage.removeItem('auth_retry_count');
|
||||
|
||||
// Stocker les tokens
|
||||
if (tokens.access_token) {
|
||||
localStorage.setItem('accessToken', tokens.access_token);
|
||||
localStorage.setItem('refreshToken', tokens.refresh_token);
|
||||
localStorage.setItem('idToken', tokens.id_token);
|
||||
|
||||
// Stocker aussi dans un cookie pour le middleware
|
||||
document.cookie = `keycloak-token=${tokens.access_token}; path=/; max-age=3600; SameSite=Lax`;
|
||||
|
||||
console.log('✅ Tokens stockés avec succès');
|
||||
}
|
||||
|
||||
setAuthProcessed(true);
|
||||
setAuthInProgress(false);
|
||||
authProcessingRef.current = false;
|
||||
|
||||
// Vérifier s'il y a une URL de retour sauvegardée
|
||||
const returnUrl = localStorage.getItem('returnUrl');
|
||||
if (returnUrl && returnUrl !== '/dashboard') {
|
||||
console.log('🔄 Dashboard: Redirection vers la page d\'origine:', returnUrl);
|
||||
localStorage.removeItem('returnUrl');
|
||||
window.location.href = returnUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
// Nettoyer l'URL IMMÉDIATEMENT et arrêter tout traitement futur
|
||||
console.log('🧹 Dashboard: Nettoyage de l\'URL...');
|
||||
window.history.replaceState({}, document.title, '/dashboard');
|
||||
|
||||
// Charger les données du dashboard
|
||||
console.log('🔄 Dashboard: Chargement des données...');
|
||||
refresh();
|
||||
|
||||
// Arrêter définitivement le processus d'authentification
|
||||
return;
|
||||
|
||||
} 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');
|
||||
|
||||
setAuthError(`Erreur lors de l'authentification: ${error.message}`);
|
||||
setAuthProcessed(true);
|
||||
setAuthInProgress(false);
|
||||
authProcessingRef.current = false;
|
||||
}
|
||||
} else {
|
||||
setAuthProcessed(true);
|
||||
setAuthInProgress(false);
|
||||
authProcessingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
processAuth();
|
||||
}, [currentCode, currentState, authProcessed, authInProgress, refresh]);
|
||||
|
||||
|
||||
|
||||
|
||||
const actionBodyTemplate = useCallback((rowData: ChantierActif) => {
|
||||
const actions: ActionButtonType[] = ['VIEW', 'PHASES', 'PLANNING', 'STATS', 'MENU'];
|
||||
|
||||
const handleActionClick = (action: ActionButtonType | string, chantier: ChantierActif) => {
|
||||
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:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-content-center">
|
||||
<ActionButtonGroup
|
||||
chantier={rowData}
|
||||
actions={actions}
|
||||
onAction={handleActionClick}
|
||||
size="sm"
|
||||
spacing="sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}, [handleQuickView, router, chantierActions]);
|
||||
|
||||
|
||||
|
||||
// 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="small" 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="small" 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="small" 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;
|
||||
Reference in New Issue
Block a user