Initial commit
This commit is contained in:
472
app/(main)/phases-chantier/dashboard/page.tsx
Normal file
472
app/(main)/phases-chantier/dashboard/page.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Chart } from 'primereact/chart';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { DataView } from 'primereact/dataview';
|
||||
import { Knob } from 'primereact/knob';
|
||||
import { Timeline } from 'primereact/timeline';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Calendar } from 'primereact/calendar';
|
||||
import { Badge } from 'primereact/badge';
|
||||
import { Panel } from 'primereact/panel';
|
||||
import { Page } from '@/types';
|
||||
import phaseChantierService from '@/services/phaseChantierService';
|
||||
import { PhaseChantier } from '@/types/btp-extended';
|
||||
|
||||
const PhasesDashboardPage: Page = () => {
|
||||
const [phases, setPhases] = useState<PhaseChantier[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('semaine');
|
||||
const [chartData, setChartData] = useState<any>({});
|
||||
const [avancementData, setAvancementData] = useState<any>({});
|
||||
const [budgetData, setBudgetData] = useState<any>({});
|
||||
const [timelineData, setTimelineData] = useState<any[]>([]);
|
||||
|
||||
const toast = useRef<Toast>(null);
|
||||
|
||||
const periodOptions = [
|
||||
{ label: 'Cette semaine', value: 'semaine' },
|
||||
{ label: 'Ce mois', value: 'mois' },
|
||||
{ label: 'Ce trimestre', value: 'trimestre' }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData();
|
||||
}, [selectedPeriod]);
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Charger les phases actives (exemple avec chantierId = 1)
|
||||
const data = await phaseChantierService.getByChantier(1);
|
||||
setPhases(data || []);
|
||||
|
||||
// Préparer les données pour les graphiques
|
||||
prepareChartData(data || []);
|
||||
prepareTimelineData(data || []);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des données:', error);
|
||||
toast.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Erreur',
|
||||
detail: 'Impossible de charger les données du tableau de bord',
|
||||
life: 3000
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const prepareChartData = (data: PhaseChantier[]) => {
|
||||
const documentStyle = getComputedStyle(document.documentElement);
|
||||
|
||||
// Graphique de répartition par statut
|
||||
const statutCounts = data.reduce((acc, phase) => {
|
||||
acc[phase.statut] = (acc[phase.statut] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
const statutData = {
|
||||
labels: Object.keys(statutCounts).map(statut =>
|
||||
phaseChantierService.getStatutLabel(statut as any)
|
||||
),
|
||||
datasets: [{
|
||||
data: Object.values(statutCounts),
|
||||
backgroundColor: [
|
||||
documentStyle.getPropertyValue('--blue-500'),
|
||||
documentStyle.getPropertyValue('--green-500'),
|
||||
documentStyle.getPropertyValue('--yellow-500'),
|
||||
documentStyle.getPropertyValue('--orange-500'),
|
||||
documentStyle.getPropertyValue('--red-500'),
|
||||
documentStyle.getPropertyValue('--gray-500'),
|
||||
],
|
||||
hoverBackgroundColor: [
|
||||
documentStyle.getPropertyValue('--blue-400'),
|
||||
documentStyle.getPropertyValue('--green-400'),
|
||||
documentStyle.getPropertyValue('--yellow-400'),
|
||||
documentStyle.getPropertyValue('--orange-400'),
|
||||
documentStyle.getPropertyValue('--red-400'),
|
||||
documentStyle.getPropertyValue('--gray-400'),
|
||||
]
|
||||
}]
|
||||
};
|
||||
|
||||
// Graphique d'avancement moyen
|
||||
const avancementMoyen = data.length > 0
|
||||
? data.reduce((sum, phase) => sum + (phase.pourcentageAvancement || 0), 0) / data.length
|
||||
: 0;
|
||||
|
||||
// Graphique budget vs coût
|
||||
const budgetVsCout = {
|
||||
labels: data.map(phase => phase.nom?.substring(0, 15) + '...'),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Budget prévu',
|
||||
backgroundColor: documentStyle.getPropertyValue('--blue-500'),
|
||||
borderColor: documentStyle.getPropertyValue('--blue-500'),
|
||||
data: data.map(phase => phase.budgetPrevu || 0)
|
||||
},
|
||||
{
|
||||
label: 'Coût réel',
|
||||
backgroundColor: documentStyle.getPropertyValue('--red-500'),
|
||||
borderColor: documentStyle.getPropertyValue('--red-500'),
|
||||
data: data.map(phase => phase.coutReel || 0)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
setChartData(statutData);
|
||||
setAvancementData({ value: avancementMoyen });
|
||||
setBudgetData(budgetVsCout);
|
||||
};
|
||||
|
||||
const prepareTimelineData = (data: PhaseChantier[]) => {
|
||||
// Créer une timeline des prochaines échéances
|
||||
const prochaines = data
|
||||
.filter(phase => phase.dateFinPrevue && phase.statut !== 'TERMINEE')
|
||||
.sort((a, b) => new Date(a.dateFinPrevue!).getTime() - new Date(b.dateFinPrevue!).getTime())
|
||||
.slice(0, 5)
|
||||
.map(phase => {
|
||||
const dateEcheance = new Date(phase.dateFinPrevue!);
|
||||
const maintenant = new Date();
|
||||
const joursRestants = Math.ceil((dateEcheance.getTime() - maintenant.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return {
|
||||
phase: phase.nom,
|
||||
date: dateEcheance.toLocaleDateString('fr-FR'),
|
||||
joursRestants,
|
||||
statut: phase.statut,
|
||||
avancement: phase.pourcentageAvancement || 0,
|
||||
critique: phase.critique,
|
||||
color: joursRestants < 0 ? '#FF6B6B' : joursRestants < 7 ? '#FFA726' : '#66BB6A',
|
||||
icon: joursRestants < 0 ? 'pi pi-exclamation-triangle' :
|
||||
joursRestants < 7 ? 'pi pi-clock' : 'pi pi-calendar'
|
||||
};
|
||||
});
|
||||
|
||||
setTimelineData(prochaines);
|
||||
};
|
||||
|
||||
const calculateKPIs = () => {
|
||||
const stats = phaseChantierService.calculateStatistiques(phases);
|
||||
const phasesEnRetard = phases.filter(phase => phaseChantierService.isEnRetard(phase)).length;
|
||||
const phasesCritiques = phases.filter(phase => phase.critique).length;
|
||||
const budgetUtilise = (stats.coutTotal / stats.budgetTotal) * 100;
|
||||
|
||||
return {
|
||||
avancementGlobal: stats.avancementMoyen,
|
||||
respectDelais: ((phases.length - phasesEnRetard) / phases.length) * 100,
|
||||
budgetUtilise,
|
||||
phasesCritiques,
|
||||
stats
|
||||
};
|
||||
};
|
||||
|
||||
const kpis = calculateKPIs();
|
||||
|
||||
const phaseItemTemplate = (phase: PhaseChantier) => {
|
||||
const retard = phaseChantierService.isEnRetard(phase);
|
||||
const avancement = phase.pourcentageAvancement || 0;
|
||||
|
||||
return (
|
||||
<div className="col-12 md:col-6 lg:col-4">
|
||||
<Card className="h-full">
|
||||
<div className="flex flex-column h-full">
|
||||
<div className="flex justify-content-between align-items-start mb-3">
|
||||
<div className="flex-1">
|
||||
<h6 className="m-0 text-lg font-semibold">{phase.nom}</h6>
|
||||
<p className="text-color-secondary text-sm mt-1">
|
||||
{phase.chantier?.nom || 'Chantier non défini'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-column align-items-end gap-1">
|
||||
<Tag
|
||||
value={phaseChantierService.getStatutLabel(phase.statut)}
|
||||
style={{ backgroundColor: phaseChantierService.getStatutColor(phase.statut) }}
|
||||
className="text-white"
|
||||
/>
|
||||
{phase.critique && (
|
||||
<Badge value="Critique" severity="danger" />
|
||||
)}
|
||||
{retard && (
|
||||
<Badge value="Retard" severity="warning" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-column justify-content-between">
|
||||
<div>
|
||||
<div className="flex justify-content-between align-items-center mb-2">
|
||||
<span className="text-sm font-medium">Avancement</span>
|
||||
<span className="text-sm font-bold">{avancement.toFixed(1)}%</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={avancement}
|
||||
className="mb-3"
|
||||
style={{ height: '0.5rem' }}
|
||||
/>
|
||||
|
||||
<div className="grid text-sm">
|
||||
<div className="col-6">
|
||||
<div className="text-color-secondary">Début prévu</div>
|
||||
<div className="font-medium">
|
||||
{phase.dateDebutPrevue ?
|
||||
new Date(phase.dateDebutPrevue).toLocaleDateString('fr-FR') :
|
||||
'Non défini'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-6">
|
||||
<div className="text-color-secondary">Fin prévue</div>
|
||||
<div className="font-medium">
|
||||
{phase.dateFinPrevue ?
|
||||
new Date(phase.dateFinPrevue).toLocaleDateString('fr-FR') :
|
||||
'Non défini'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-content-between align-items-center mt-3 pt-3 border-top-1 surface-border">
|
||||
<div className="text-sm">
|
||||
<div className="text-color-secondary">Budget</div>
|
||||
<div className="font-bold">
|
||||
{new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
notation: 'compact'
|
||||
}).format(phase.budgetPrevu || 0)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
icon="pi pi-arrow-right"
|
||||
className="p-button-rounded p-button-outlined p-button-sm"
|
||||
onClick={() => window.location.href = `/chantiers`}
|
||||
tooltip="Voir le chantier"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const chartOptions = {
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom' as const,
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20
|
||||
}
|
||||
}
|
||||
},
|
||||
maintainAspectRatio: false
|
||||
};
|
||||
|
||||
const budgetChartOptions = {
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const
|
||||
}
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
maxRotation: 45
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: function(value: any) {
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
notation: 'compact'
|
||||
}).format(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<div className="col-12">
|
||||
<Toast ref={toast} />
|
||||
|
||||
{/* Header avec filtres */}
|
||||
<div className="flex justify-content-between align-items-center mb-4">
|
||||
<h2 className="text-3xl font-bold m-0">Tableau de Bord - Phases</h2>
|
||||
<div className="flex gap-2">
|
||||
<Dropdown
|
||||
value={selectedPeriod}
|
||||
options={periodOptions}
|
||||
onChange={(e) => setSelectedPeriod(e.value)}
|
||||
className="w-12rem"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
onClick={loadDashboardData}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPIs principaux */}
|
||||
<div className="grid mb-4">
|
||||
<div className="col-12 md:col-3">
|
||||
<Card className="text-center">
|
||||
<Knob
|
||||
value={kpis.avancementGlobal}
|
||||
size={80}
|
||||
strokeWidth={8}
|
||||
valueColor="#2196F3"
|
||||
rangeColor="#E3F2FD"
|
||||
/>
|
||||
<h6 className="mt-3 mb-0">Avancement Global</h6>
|
||||
<p className="text-color-secondary text-sm">
|
||||
{kpis.avancementGlobal.toFixed(1)}% terminé
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="col-12 md:col-3">
|
||||
<Card className="text-center">
|
||||
<Knob
|
||||
value={kpis.respectDelais}
|
||||
size={80}
|
||||
strokeWidth={8}
|
||||
valueColor="#4CAF50"
|
||||
rangeColor="#E8F5E8"
|
||||
/>
|
||||
<h6 className="mt-3 mb-0">Respect des Délais</h6>
|
||||
<p className="text-color-secondary text-sm">
|
||||
{kpis.respectDelais.toFixed(1)}% dans les temps
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="col-12 md:col-3">
|
||||
<Card className="text-center">
|
||||
<Knob
|
||||
value={Math.min(kpis.budgetUtilise, 100)}
|
||||
size={80}
|
||||
strokeWidth={8}
|
||||
valueColor={kpis.budgetUtilise > 100 ? "#F44336" : "#FF9800"}
|
||||
rangeColor="#FFF3E0"
|
||||
/>
|
||||
<h6 className="mt-3 mb-0">Budget Utilisé</h6>
|
||||
<p className="text-color-secondary text-sm">
|
||||
{kpis.budgetUtilise.toFixed(1)}% du budget
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="col-12 md:col-3">
|
||||
<Card className="text-center">
|
||||
<div className="text-4xl font-bold text-purple-500 mb-2">
|
||||
{kpis.phasesCritiques}
|
||||
</div>
|
||||
<h6 className="mt-3 mb-0">Phases Critiques</h6>
|
||||
<p className="text-color-secondary text-sm">
|
||||
Nécessitent une attention
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid">
|
||||
{/* Graphiques */}
|
||||
<div className="col-12 lg:col-8">
|
||||
<div className="grid">
|
||||
<div className="col-12 md:col-6">
|
||||
<Card title="Répartition par Statut">
|
||||
<Chart
|
||||
type="doughnut"
|
||||
data={chartData}
|
||||
options={chartOptions}
|
||||
style={{ height: '300px' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="col-12 md:col-6">
|
||||
<Card title="Budget vs Coût Réel">
|
||||
<Chart
|
||||
type="bar"
|
||||
data={budgetData}
|
||||
options={budgetChartOptions}
|
||||
style={{ height: '300px' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline des prochaines échéances */}
|
||||
<div className="col-12 lg:col-4">
|
||||
<Card title="Prochaines Échéances">
|
||||
<Timeline
|
||||
value={timelineData}
|
||||
align="left"
|
||||
className="customized-timeline"
|
||||
marker={(item) => (
|
||||
<span
|
||||
className="flex w-2rem h-2rem align-items-center justify-content-center text-white border-circle z-1 shadow-1"
|
||||
style={{ backgroundColor: item.color }}
|
||||
>
|
||||
<i className={item.icon}></i>
|
||||
</span>
|
||||
)}
|
||||
content={(item) => (
|
||||
<Card className="mt-3 mb-3">
|
||||
<div className="flex flex-column">
|
||||
<div className="font-bold mb-1">{item.phase}</div>
|
||||
<div className="text-sm text-color-secondary mb-2">
|
||||
{item.date} • {item.joursRestants >= 0 ?
|
||||
`${item.joursRestants} jours restants` :
|
||||
`${Math.abs(item.joursRestants)} jours de retard`
|
||||
}
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={item.avancement}
|
||||
style={{ height: '0.4rem' }}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex justify-content-between align-items-center">
|
||||
<span className="text-xs">{item.avancement.toFixed(1)}%</span>
|
||||
{item.critique && (
|
||||
<Badge value="Critique" severity="danger" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vue des phases actives */}
|
||||
<Card title="Phases Actives" className="mt-4">
|
||||
<DataView
|
||||
value={phases.filter(p => p.statut === 'EN_COURS')}
|
||||
layout="grid"
|
||||
itemTemplate={phaseItemTemplate}
|
||||
paginator
|
||||
rows={6}
|
||||
emptyMessage="Aucune phase active"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhasesDashboardPage;
|
||||
Reference in New Issue
Block a user