- Correction des erreurs TypeScript dans userService.ts et workflowTester.ts - Ajout des propriétés manquantes aux objets User mockés - Conversion des dates de string vers objets Date - Correction des appels asynchrones et des types incompatibles - Ajout de dynamic rendering pour résoudre les erreurs useSearchParams - Enveloppement de useSearchParams dans Suspense boundary - Configuration de force-dynamic au niveau du layout principal Build réussi: 126 pages générées avec succès 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
474 lines
21 KiB
TypeScript
474 lines
21 KiB
TypeScript
'use client';
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
|
|
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; |