- 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>
547 lines
23 KiB
TypeScript
547 lines
23 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 { DataTable } from 'primereact/datatable';
|
|
import { Column } from 'primereact/column';
|
|
import { Tag } from 'primereact/tag';
|
|
import { ProgressBar } from 'primereact/progressbar';
|
|
import { Button } from 'primereact/button';
|
|
import { Toast } from 'primereact/toast';
|
|
import { Dropdown } from 'primereact/dropdown';
|
|
import { Calendar } from 'primereact/calendar';
|
|
import { Badge } from 'primereact/badge';
|
|
import { Divider } from 'primereact/divider';
|
|
import { useRouter } from 'next/navigation';
|
|
|
|
interface ChantierDashboard {
|
|
id: string;
|
|
nom: string;
|
|
client: string;
|
|
statut: string;
|
|
avancement: number;
|
|
budget: number;
|
|
coutReel: number;
|
|
dateDebut: Date;
|
|
dateFinPrevue: Date;
|
|
nombreEmployes: number;
|
|
nombrePhases: number;
|
|
phasesTerminees: number;
|
|
retard: number;
|
|
priorite: string;
|
|
localisation: string;
|
|
}
|
|
|
|
const DashboardChantiers = () => {
|
|
const toast = useRef<Toast>(null);
|
|
const router = useRouter();
|
|
|
|
const [chantiers, setChantiers] = useState<ChantierDashboard[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedPeriod, setSelectedPeriod] = useState('30');
|
|
const [selectedStatut, setSelectedStatut] = useState('');
|
|
const [dateRange, setDateRange] = useState<Date[]>([]);
|
|
|
|
const [chartData, setChartData] = useState({});
|
|
const [chartOptions, setChartOptions] = useState({});
|
|
|
|
const periodOptions = [
|
|
{ label: '7 derniers jours', value: '7' },
|
|
{ label: '30 derniers jours', value: '30' },
|
|
{ label: '3 derniers mois', value: '90' },
|
|
{ label: '6 derniers mois', value: '180' },
|
|
{ label: 'Cette année', value: '365' }
|
|
];
|
|
|
|
const statutOptions = [
|
|
{ label: 'Tous les statuts', value: '' },
|
|
{ label: 'Planifié', value: 'PLANIFIE' },
|
|
{ label: 'En cours', value: 'EN_COURS' },
|
|
{ label: 'En pause', value: 'EN_PAUSE' },
|
|
{ label: 'Terminé', value: 'TERMINE' },
|
|
{ label: 'Annulé', value: 'ANNULE' }
|
|
];
|
|
|
|
useEffect(() => {
|
|
loadChantiers();
|
|
initCharts();
|
|
}, [selectedPeriod, selectedStatut, dateRange]);
|
|
|
|
const loadChantiers = async () => {
|
|
try {
|
|
setLoading(true);
|
|
|
|
// TODO: Remplacer par un vrai appel API
|
|
// const response = await chantierService.getDashboardData({
|
|
// period: selectedPeriod,
|
|
// statut: selectedStatut,
|
|
// dateRange
|
|
// });
|
|
|
|
// Données simulées pour la démonstration
|
|
const mockChantiers: ChantierDashboard[] = [
|
|
{
|
|
id: '1',
|
|
nom: 'Résidence Les Jardins',
|
|
client: 'Immobilière Moderne',
|
|
statut: 'EN_COURS',
|
|
avancement: 75,
|
|
budget: 850000,
|
|
coutReel: 620000,
|
|
dateDebut: new Date('2024-03-15'),
|
|
dateFinPrevue: new Date('2024-12-20'),
|
|
nombreEmployes: 12,
|
|
nombrePhases: 8,
|
|
phasesTerminees: 6,
|
|
retard: 0,
|
|
priorite: 'HAUTE',
|
|
localisation: 'Paris 15ème'
|
|
},
|
|
{
|
|
id: '2',
|
|
nom: 'Centre Commercial Atlantis',
|
|
client: 'Groupe Retail Plus',
|
|
statut: 'EN_COURS',
|
|
avancement: 45,
|
|
budget: 2500000,
|
|
coutReel: 1200000,
|
|
dateDebut: new Date('2024-01-10'),
|
|
dateFinPrevue: new Date('2025-06-30'),
|
|
nombreEmployes: 25,
|
|
nombrePhases: 12,
|
|
phasesTerminees: 5,
|
|
retard: 15,
|
|
priorite: 'CRITIQUE',
|
|
localisation: 'Nanterre'
|
|
},
|
|
{
|
|
id: '3',
|
|
nom: 'Rénovation Hôtel Luxe',
|
|
client: 'Hôtels Prestige',
|
|
statut: 'EN_PAUSE',
|
|
avancement: 30,
|
|
budget: 1200000,
|
|
coutReel: 380000,
|
|
dateDebut: new Date('2024-02-01'),
|
|
dateFinPrevue: new Date('2024-11-15'),
|
|
nombreEmployes: 8,
|
|
nombrePhases: 10,
|
|
phasesTerminees: 3,
|
|
retard: 45,
|
|
priorite: 'MOYENNE',
|
|
localisation: 'Lyon'
|
|
},
|
|
{
|
|
id: '4',
|
|
nom: 'Usine Pharmaceutique',
|
|
client: 'PharmaFrance',
|
|
statut: 'PLANIFIE',
|
|
avancement: 0,
|
|
budget: 3200000,
|
|
coutReel: 0,
|
|
dateDebut: new Date('2025-02-01'),
|
|
dateFinPrevue: new Date('2026-08-30'),
|
|
nombreEmployes: 0,
|
|
nombrePhases: 15,
|
|
phasesTerminees: 0,
|
|
retard: 0,
|
|
priorite: 'HAUTE',
|
|
localisation: 'Marseille'
|
|
},
|
|
{
|
|
id: '5',
|
|
nom: 'Pont Autoroutier A86',
|
|
client: 'Ministère des Transports',
|
|
statut: 'TERMINE',
|
|
avancement: 100,
|
|
budget: 4500000,
|
|
coutReel: 4350000,
|
|
dateDebut: new Date('2023-06-01'),
|
|
dateFinPrevue: new Date('2024-10-31'),
|
|
nombreEmployes: 0,
|
|
nombrePhases: 20,
|
|
phasesTerminees: 20,
|
|
retard: -10,
|
|
priorite: 'CRITIQUE',
|
|
localisation: 'Île-de-France'
|
|
}
|
|
];
|
|
|
|
// Filtrer selon les critères sélectionnés
|
|
let filteredChantiers = mockChantiers;
|
|
|
|
if (selectedStatut) {
|
|
filteredChantiers = filteredChantiers.filter(c => c.statut === selectedStatut);
|
|
}
|
|
|
|
setChantiers(filteredChantiers);
|
|
|
|
} catch (error) {
|
|
console.error('Erreur lors du chargement des chantiers:', error);
|
|
toast.current?.show({
|
|
severity: 'error',
|
|
summary: 'Erreur',
|
|
detail: 'Impossible de charger les données des chantiers'
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const initCharts = () => {
|
|
const documentStyle = getComputedStyle(document.documentElement);
|
|
const textColor = documentStyle.getPropertyValue('--text-color');
|
|
const textColorSecondary = documentStyle.getPropertyValue('--text-color-secondary');
|
|
const surfaceBorder = documentStyle.getPropertyValue('--surface-border');
|
|
|
|
// Graphique de répartition par statut
|
|
const statutData = {
|
|
labels: ['En cours', 'Planifié', 'En pause', 'Terminé', 'Annulé'],
|
|
datasets: [
|
|
{
|
|
data: [
|
|
chantiers.filter(c => c.statut === 'EN_COURS').length,
|
|
chantiers.filter(c => c.statut === 'PLANIFIE').length,
|
|
chantiers.filter(c => c.statut === 'EN_PAUSE').length,
|
|
chantiers.filter(c => c.statut === 'TERMINE').length,
|
|
chantiers.filter(c => c.statut === 'ANNULE').length
|
|
],
|
|
backgroundColor: [
|
|
documentStyle.getPropertyValue('--green-500'),
|
|
documentStyle.getPropertyValue('--blue-500'),
|
|
documentStyle.getPropertyValue('--orange-500'),
|
|
documentStyle.getPropertyValue('--cyan-500'),
|
|
documentStyle.getPropertyValue('--red-500')
|
|
],
|
|
hoverBackgroundColor: [
|
|
documentStyle.getPropertyValue('--green-400'),
|
|
documentStyle.getPropertyValue('--blue-400'),
|
|
documentStyle.getPropertyValue('--orange-400'),
|
|
documentStyle.getPropertyValue('--cyan-400'),
|
|
documentStyle.getPropertyValue('--red-400')
|
|
]
|
|
}
|
|
]
|
|
};
|
|
|
|
const options = {
|
|
plugins: {
|
|
legend: {
|
|
labels: {
|
|
usePointStyle: true,
|
|
color: textColor
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
setChartData(statutData);
|
|
setChartOptions(options);
|
|
};
|
|
|
|
const getStatutSeverity = (statut: string) => {
|
|
switch (statut) {
|
|
case 'EN_COURS': return 'success';
|
|
case 'PLANIFIE': return 'info';
|
|
case 'EN_PAUSE': return 'warning';
|
|
case 'TERMINE': return 'success';
|
|
case 'ANNULE': return 'danger';
|
|
default: return 'info';
|
|
}
|
|
};
|
|
|
|
const getPrioriteSeverity = (priorite: string) => {
|
|
switch (priorite) {
|
|
case 'CRITIQUE': return 'danger';
|
|
case 'HAUTE': return 'warning';
|
|
case 'MOYENNE': return 'info';
|
|
case 'BASSE': return 'success';
|
|
default: return 'info';
|
|
}
|
|
};
|
|
|
|
const statutBodyTemplate = (rowData: ChantierDashboard) => (
|
|
<Tag value={rowData.statut} severity={getStatutSeverity(rowData.statut)} />
|
|
);
|
|
|
|
const prioriteBodyTemplate = (rowData: ChantierDashboard) => (
|
|
<Tag value={rowData.priorite} severity={getPrioriteSeverity(rowData.priorite)} />
|
|
);
|
|
|
|
const avancementBodyTemplate = (rowData: ChantierDashboard) => (
|
|
<div className="flex align-items-center">
|
|
<ProgressBar
|
|
value={rowData.avancement}
|
|
style={{ width: '100px', marginRight: '8px' }}
|
|
showValue={false}
|
|
/>
|
|
<span className="text-sm font-semibold">{rowData.avancement}%</span>
|
|
</div>
|
|
);
|
|
|
|
const budgetBodyTemplate = (rowData: ChantierDashboard) => {
|
|
const pourcentageUtilise = (rowData.coutReel / rowData.budget) * 100;
|
|
const couleur = pourcentageUtilise > 90 ? 'text-red-500' : pourcentageUtilise > 75 ? 'text-orange-500' : 'text-green-500';
|
|
|
|
return (
|
|
<div>
|
|
<div className="font-semibold">
|
|
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', notation: 'compact' }).format(rowData.budget)}
|
|
</div>
|
|
<div className={`text-sm ${couleur}`}>
|
|
{pourcentageUtilise.toFixed(1)}% utilisé
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const retardBodyTemplate = (rowData: ChantierDashboard) => {
|
|
if (rowData.retard === 0) {
|
|
return <Tag value="À l'heure" severity="success" />;
|
|
} else if (rowData.retard > 0) {
|
|
return <Tag value={`+${rowData.retard}j`} severity="danger" />;
|
|
} else {
|
|
return <Tag value={`${rowData.retard}j`} severity="info" />;
|
|
}
|
|
};
|
|
|
|
const actionBodyTemplate = (rowData: ChantierDashboard) => (
|
|
<div className="flex gap-2">
|
|
<Button
|
|
icon="pi pi-eye"
|
|
className="p-button-text p-button-sm"
|
|
tooltip="Voir détails"
|
|
onClick={() => router.push(`/chantiers/${rowData.id}`)}
|
|
/>
|
|
<Button
|
|
icon="pi pi-sitemap"
|
|
className="p-button-text p-button-sm"
|
|
tooltip="Gérer phases"
|
|
onClick={() => router.push(`/chantiers/${rowData.id}/phases`)}
|
|
/>
|
|
<Button
|
|
icon="pi pi-calendar"
|
|
className="p-button-text p-button-sm"
|
|
tooltip="Planning"
|
|
onClick={() => router.push(`/planning?chantier=${rowData.id}`)}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
// Calculs des métriques
|
|
const totalBudget = chantiers.reduce((sum, c) => sum + c.budget, 0);
|
|
const totalCoutReel = chantiers.reduce((sum, c) => sum + c.coutReel, 0);
|
|
const chantiersEnRetard = chantiers.filter(c => c.retard > 0).length;
|
|
const avancementMoyen = chantiers.length > 0 ?
|
|
chantiers.reduce((sum, c) => sum + c.avancement, 0) / chantiers.length : 0;
|
|
|
|
return (
|
|
<div className="grid">
|
|
<Toast ref={toast} />
|
|
|
|
{/* En-tête avec filtres */}
|
|
<div className="col-12">
|
|
<Card>
|
|
<div className="flex justify-content-between align-items-center mb-4">
|
|
<h2 className="text-2xl font-bold m-0">Dashboard Chantiers</h2>
|
|
<Button
|
|
label="Nouveau chantier"
|
|
icon="pi pi-plus"
|
|
onClick={() => router.push('/chantiers/nouveau')}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-3 align-items-center">
|
|
<div className="field">
|
|
<label htmlFor="period" className="font-semibold">Période</label>
|
|
<Dropdown
|
|
id="period"
|
|
value={selectedPeriod}
|
|
options={periodOptions}
|
|
onChange={(e) => setSelectedPeriod(e.value)}
|
|
className="w-full md:w-14rem"
|
|
/>
|
|
</div>
|
|
|
|
<div className="field">
|
|
<label htmlFor="statut" className="font-semibold">Statut</label>
|
|
<Dropdown
|
|
id="statut"
|
|
value={selectedStatut}
|
|
options={statutOptions}
|
|
onChange={(e) => setSelectedStatut(e.value)}
|
|
className="w-full md:w-14rem"
|
|
/>
|
|
</div>
|
|
|
|
<div className="field">
|
|
<label htmlFor="dateRange" className="font-semibold">Plage de dates</label>
|
|
<Calendar
|
|
id="dateRange"
|
|
value={dateRange}
|
|
onChange={(e) => setDateRange(e.value as Date[])}
|
|
selectionMode="range"
|
|
readOnlyInput
|
|
className="w-full md:w-14rem"
|
|
placeholder="Sélectionner une période"
|
|
/>
|
|
</div>
|
|
|
|
<Button
|
|
icon="pi pi-refresh"
|
|
className="p-button-outlined"
|
|
onClick={loadChantiers}
|
|
loading={loading}
|
|
tooltip="Actualiser"
|
|
/>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Métriques principales */}
|
|
<div className="col-12 lg:col-3 md:col-6">
|
|
<Card className="h-full">
|
|
<div className="flex justify-content-between mb-3">
|
|
<div>
|
|
<span className="block text-500 font-medium mb-3">Total Chantiers</span>
|
|
<div className="text-900 font-medium text-xl">{chantiers.length}</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>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="col-12 lg:col-3 md:col-6">
|
|
<Card className="h-full">
|
|
<div className="flex justify-content-between mb-3">
|
|
<div>
|
|
<span className="block text-500 font-medium mb-3">Budget Total</span>
|
|
<div className="text-900 font-medium text-xl">
|
|
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', notation: 'compact' }).format(totalBudget)}
|
|
</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>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="col-12 lg:col-3 md:col-6">
|
|
<Card className="h-full">
|
|
<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">{chantiersEnRetard}</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>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="col-12 lg:col-3 md:col-6">
|
|
<Card className="h-full">
|
|
<div className="flex justify-content-between mb-3">
|
|
<div>
|
|
<span className="block text-500 font-medium mb-3">Avancement Moyen</span>
|
|
<div className="text-900 font-medium text-xl">{avancementMoyen.toFixed(1)}%</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>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Graphique de répartition */}
|
|
<div className="col-12 lg:col-6">
|
|
<Card>
|
|
<h6>Répartition par Statut</h6>
|
|
<Chart type="doughnut" data={chartData} options={chartOptions} className="w-full md:w-30rem" />
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Indicateurs de performance */}
|
|
<div className="col-12 lg:col-6">
|
|
<Card>
|
|
<h6>Indicateurs de Performance</h6>
|
|
<div className="grid">
|
|
<div className="col-6">
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-green-500">
|
|
{((totalBudget - totalCoutReel) / totalBudget * 100).toFixed(1)}%
|
|
</div>
|
|
<div className="text-sm text-500">Économies réalisées</div>
|
|
</div>
|
|
</div>
|
|
<div className="col-6">
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-blue-500">
|
|
{chantiers.filter(c => c.statut === 'EN_COURS').length}
|
|
</div>
|
|
<div className="text-sm text-500">Chantiers actifs</div>
|
|
</div>
|
|
</div>
|
|
<div className="col-6">
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-purple-500">
|
|
{chantiers.reduce((sum, c) => sum + c.nombreEmployes, 0)}
|
|
</div>
|
|
<div className="text-sm text-500">Employés mobilisés</div>
|
|
</div>
|
|
</div>
|
|
<div className="col-6">
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-orange-500">
|
|
{(chantiersEnRetard / chantiers.length * 100).toFixed(1)}%
|
|
</div>
|
|
<div className="text-sm text-500">Taux de retard</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Tableau des chantiers */}
|
|
<div className="col-12">
|
|
<Card>
|
|
<div className="flex justify-content-between align-items-center mb-4">
|
|
<h6>Liste des Chantiers ({chantiers.length})</h6>
|
|
<Badge value={`${chantiers.filter(c => c.statut === 'EN_COURS').length} actifs`} severity="success" />
|
|
</div>
|
|
|
|
<DataTable
|
|
value={chantiers}
|
|
loading={loading}
|
|
responsiveLayout="scroll"
|
|
paginator
|
|
rows={10}
|
|
rowsPerPageOptions={[5, 10, 25]}
|
|
emptyMessage="Aucun chantier trouvé"
|
|
sortMode="multiple"
|
|
>
|
|
<Column field="nom" header="Chantier" sortable />
|
|
<Column field="client" header="Client" sortable />
|
|
<Column field="statut" header="Statut" body={statutBodyTemplate} sortable />
|
|
<Column field="priorite" header="Priorité" body={prioriteBodyTemplate} sortable />
|
|
<Column field="avancement" header="Avancement" body={avancementBodyTemplate} sortable />
|
|
<Column field="budget" header="Budget" body={budgetBodyTemplate} sortable />
|
|
<Column field="retard" header="Retard" body={retardBodyTemplate} sortable />
|
|
<Column field="localisation" header="Localisation" sortable />
|
|
<Column header="Actions" body={actionBodyTemplate} style={{ width: '120px' }} />
|
|
</DataTable>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default DashboardChantiers;
|