386 lines
16 KiB
TypeScript
Executable File
386 lines
16 KiB
TypeScript
Executable File
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import { Card } from 'primereact/card';
|
|
import { Timeline } from 'primereact/timeline';
|
|
import { Badge } from 'primereact/badge';
|
|
import { Tag } from 'primereact/tag';
|
|
import { Button } from 'primereact/button';
|
|
import { Accordion, AccordionTab } from 'primereact/accordion';
|
|
import { ProgressBar } from 'primereact/progressbar';
|
|
import { Tooltip } from 'primereact/tooltip';
|
|
import chantierTemplateService from '../../services/chantierTemplateService';
|
|
import type { TypeChantier, PhaseTemplate } from '../../types/chantier-templates';
|
|
import type { ChantierPreview } from '../../types/chantier-form';
|
|
|
|
interface PhasesTimelinePreviewProps {
|
|
typeChantier: TypeChantier;
|
|
dateDebut: Date;
|
|
surface?: number;
|
|
nombreNiveaux?: number;
|
|
inclureSousPhases?: boolean;
|
|
ajusterDelais?: boolean;
|
|
margeSecurite?: number;
|
|
className?: string;
|
|
showDetails?: boolean;
|
|
compact?: boolean;
|
|
}
|
|
|
|
interface PhaseTimelineItem {
|
|
id: string;
|
|
nom: string;
|
|
description: string;
|
|
dateDebut: Date;
|
|
dateFin: Date;
|
|
duree: number;
|
|
ordreExecution: number;
|
|
critique: boolean;
|
|
prerequis: string[];
|
|
sousPhases?: PhaseTimelineItem[];
|
|
competences: string[];
|
|
status?: 'planned' | 'current' | 'completed' | 'late';
|
|
}
|
|
|
|
const PhasesTimelinePreview: React.FC<PhasesTimelinePreviewProps> = ({
|
|
typeChantier,
|
|
dateDebut,
|
|
surface,
|
|
nombreNiveaux,
|
|
inclureSousPhases = true,
|
|
ajusterDelais = true,
|
|
margeSecurite = 5,
|
|
className = '',
|
|
showDetails = true,
|
|
compact = false
|
|
}) => {
|
|
const [timelineItems, setTimelineItems] = useState<PhaseTimelineItem[]>([]);
|
|
const [preview, setPreview] = useState<ChantierPreview | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [expandedPhases, setExpandedPhases] = useState<string[]>([]);
|
|
|
|
useEffect(() => {
|
|
generateTimeline();
|
|
}, [typeChantier, dateDebut, surface, nombreNiveaux, inclureSousPhases, ajusterDelais, margeSecurite]);
|
|
|
|
const generateTimeline = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const template = chantierTemplateService.getTemplate(typeChantier);
|
|
const complexity = chantierTemplateService.analyzeComplexity(typeChantier);
|
|
const planning = chantierTemplateService.calculatePlanning(typeChantier, dateDebut);
|
|
|
|
// Générer la prévisualisation
|
|
const previewData: ChantierPreview = {
|
|
typeChantier,
|
|
nom: template.nom,
|
|
dureeEstimee: template.dureeMoyenneJours,
|
|
dateFinEstimee: planning.dateFin,
|
|
complexite: complexity,
|
|
phasesCount: template.phases.length,
|
|
sousePhasesCount: template.phases.reduce((total, phase) => total + (phase.sousPhases?.length || 0), 0),
|
|
specificites: template.specificites || [],
|
|
reglementations: template.reglementations || []
|
|
};
|
|
|
|
setPreview(previewData);
|
|
|
|
// Convertir les phases du template en éléments timeline
|
|
const items: PhaseTimelineItem[] = [];
|
|
let currentDate = new Date(dateDebut);
|
|
|
|
template.phases.forEach((phase, index) => {
|
|
const adjustedDuration = ajusterDelais ?
|
|
Math.ceil(phase.dureePrevueJours * (complexity.score / 100)) :
|
|
phase.dureePrevueJours;
|
|
|
|
const phaseItem: PhaseTimelineItem = {
|
|
id: phase.id,
|
|
nom: phase.nom,
|
|
description: phase.description,
|
|
dateDebut: new Date(currentDate),
|
|
dateFin: addDays(currentDate, adjustedDuration),
|
|
duree: adjustedDuration,
|
|
ordreExecution: phase.ordreExecution,
|
|
critique: phase.critique,
|
|
prerequis: phase.prerequis || [],
|
|
competences: phase.competencesRequises || [],
|
|
status: 'planned'
|
|
};
|
|
|
|
if (inclureSousPhases && phase.sousPhases) {
|
|
let sousPhaseDate = new Date(currentDate);
|
|
phaseItem.sousPhases = phase.sousPhases.map(sousPhase => {
|
|
const sousPhaseAdjustedDuration = ajusterDelais ?
|
|
Math.ceil(sousPhase.dureePrevueJours * (complexity.score / 100)) :
|
|
sousPhase.dureePrevueJours;
|
|
|
|
const item: PhaseTimelineItem = {
|
|
id: sousPhase.id,
|
|
nom: sousPhase.nom,
|
|
description: sousPhase.description,
|
|
dateDebut: new Date(sousPhaseDate),
|
|
dateFin: addDays(sousPhaseDate, sousPhaseAdjustedDuration),
|
|
duree: sousPhaseAdjustedDuration,
|
|
ordreExecution: sousPhase.ordreExecution,
|
|
critique: sousPhase.critique,
|
|
prerequis: sousPhase.prerequis || [],
|
|
competences: sousPhase.competencesRequises || [],
|
|
status: 'planned'
|
|
};
|
|
|
|
sousPhaseDate = addDays(sousPhaseDate, sousPhaseAdjustedDuration);
|
|
return item;
|
|
});
|
|
}
|
|
|
|
items.push(phaseItem);
|
|
currentDate = addDays(currentDate, adjustedDuration + (margeSecurite || 0));
|
|
});
|
|
|
|
setTimelineItems(items);
|
|
} catch (error) {
|
|
console.error('Erreur lors de la génération du timeline:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const addDays = (date: Date, days: number): Date => {
|
|
const result = new Date(date);
|
|
result.setDate(result.getDate() + days);
|
|
return result;
|
|
};
|
|
|
|
const formatDate = (date: Date): string => {
|
|
return date.toLocaleDateString('fr-FR', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric'
|
|
});
|
|
};
|
|
|
|
const getSeverityByStatus = (status: string) => {
|
|
switch (status) {
|
|
case 'completed': return 'success';
|
|
case 'current': return 'info';
|
|
case 'late': return 'danger';
|
|
default: return 'secondary';
|
|
}
|
|
};
|
|
|
|
const getIconByPhase = (phase: PhaseTimelineItem): string => {
|
|
if (phase.critique) return 'pi pi-exclamation-triangle';
|
|
if (phase.competences.includes('ELECTRICITE')) return 'pi pi-bolt';
|
|
if (phase.competences.includes('PLOMBERIE')) return 'pi pi-home';
|
|
if (phase.competences.includes('MACONNERIE')) return 'pi pi-building';
|
|
if (phase.competences.includes('CHARPENTE')) return 'pi pi-sitemap';
|
|
return 'pi pi-circle';
|
|
};
|
|
|
|
const renderPhaseCard = (phase: PhaseTimelineItem, isSubPhase = false) => (
|
|
<div
|
|
key={phase.id}
|
|
className={`${isSubPhase ? 'ml-4 surface-100' : 'surface-card'} p-3 border-1 surface-border border-round mb-2`}
|
|
>
|
|
<div className="flex justify-content-between align-items-start mb-2">
|
|
<div className="flex-1">
|
|
<div className="flex align-items-center gap-2 mb-1">
|
|
<i className={getIconByPhase(phase)} />
|
|
<span className={`font-semibold ${isSubPhase ? 'text-sm' : ''}`}>
|
|
{phase.nom}
|
|
</span>
|
|
{phase.critique && (
|
|
<Tag value="Critique" severity="danger" className="text-xs" />
|
|
)}
|
|
</div>
|
|
<p className="text-600 text-sm m-0 mb-2">{phase.description}</p>
|
|
|
|
{/* Compétences requises */}
|
|
{phase.competences.length > 0 && (
|
|
<div className="flex gap-1 mb-2">
|
|
{phase.competences.map(comp => (
|
|
<Badge
|
|
key={comp}
|
|
value={comp}
|
|
severity="info"
|
|
className="text-xs"
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="text-right">
|
|
<div className="text-sm font-semibold text-primary">
|
|
{phase.duree} jour{phase.duree > 1 ? 's' : ''}
|
|
</div>
|
|
<div className="text-xs text-600">
|
|
{formatDate(phase.dateDebut)} - {formatDate(phase.dateFin)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Prérequis */}
|
|
{showDetails && phase.prerequis.length > 0 && (
|
|
<div className="mt-2 p-2 surface-100 border-round">
|
|
<div className="text-xs font-semibold text-600 mb-1">Prérequis:</div>
|
|
<div className="text-xs text-700">
|
|
{phase.prerequis.join(', ')}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Sous-phases */}
|
|
{!compact && inclureSousPhases && phase.sousPhases && phase.sousPhases.length > 0 && (
|
|
<div className="mt-3">
|
|
<Button
|
|
label={`${expandedPhases.includes(phase.id) ? 'Masquer' : 'Voir'} les sous-phases (${phase.sousPhases.length})`}
|
|
icon={`pi pi-chevron-${expandedPhases.includes(phase.id) ? 'up' : 'down'}`}
|
|
className="p-button-text p-button-sm"
|
|
onClick={() => {
|
|
setExpandedPhases(prev =>
|
|
prev.includes(phase.id)
|
|
? prev.filter(id => id !== phase.id)
|
|
: [...prev, phase.id]
|
|
);
|
|
}}
|
|
/>
|
|
|
|
{expandedPhases.includes(phase.id) && (
|
|
<div className="mt-2">
|
|
{phase.sousPhases.map(sousPhase =>
|
|
renderPhaseCard(sousPhase, true)
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const renderCompactTimeline = () => {
|
|
const timelineData = timelineItems.map(phase => ({
|
|
status: phase.nom,
|
|
date: formatDate(phase.dateDebut),
|
|
icon: getIconByPhase(phase),
|
|
color: phase.critique ? '#ef4444' : '#3b82f6',
|
|
phase: phase
|
|
}));
|
|
|
|
return (
|
|
<Timeline
|
|
value={timelineData}
|
|
opposite={(item) => (
|
|
<div className="text-right">
|
|
<div className="font-semibold">{item.status}</div>
|
|
<div className="text-600 text-sm">{item.phase.duree} jour{item.phase.duree > 1 ? 's' : ''}</div>
|
|
</div>
|
|
)}
|
|
content={(item) => (
|
|
<div>
|
|
<div className="text-600 text-sm">{item.date}</div>
|
|
{item.phase.critique && (
|
|
<Tag value="Critique" severity="danger" className="text-xs mt-1" />
|
|
)}
|
|
</div>
|
|
)}
|
|
className="w-full"
|
|
/>
|
|
);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<Card className={className}>
|
|
<ProgressBar mode="indeterminate" style={{ height: '4px' }} />
|
|
<div className="text-center mt-3">
|
|
<span className="text-600">Génération du planning...</span>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card
|
|
title="Planning prévisionnel des phases"
|
|
className={className}
|
|
subTitle={preview ? `${preview.dureeEstimee} jours estimés • ${preview.phasesCount} phases • ${preview.sousePhasesCount} sous-phases` : undefined}
|
|
>
|
|
{/* Métriques rapides */}
|
|
{preview && (
|
|
<div className="grid mb-4">
|
|
<div className="col-3">
|
|
<div className="text-center p-2 border-1 surface-border border-round">
|
|
<div className="text-lg font-bold text-primary">{preview.phasesCount}</div>
|
|
<div className="text-600 text-sm">Phases</div>
|
|
</div>
|
|
</div>
|
|
<div className="col-3">
|
|
<div className="text-center p-2 border-1 surface-border border-round">
|
|
<div className="text-lg font-bold text-primary">{preview.sousePhasesCount}</div>
|
|
<div className="text-600 text-sm">Sous-phases</div>
|
|
</div>
|
|
</div>
|
|
<div className="col-3">
|
|
<div className="text-center p-2 border-1 surface-border border-round">
|
|
<div className="text-lg font-bold text-orange-500">{preview.dureeEstimee}</div>
|
|
<div className="text-600 text-sm">Jours</div>
|
|
</div>
|
|
</div>
|
|
<div className="col-3">
|
|
<div className="text-center p-2 border-1 surface-border border-round">
|
|
<Tag
|
|
value={preview.complexite.niveau}
|
|
severity={
|
|
preview.complexite.niveau === 'SIMPLE' ? 'success' :
|
|
preview.complexite.niveau === 'MOYEN' ? 'warning' :
|
|
'danger'
|
|
}
|
|
className="text-xs"
|
|
/>
|
|
<div className="text-600 text-sm mt-1">Complexité</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Affichage compact ou détaillé */}
|
|
{compact ? (
|
|
renderCompactTimeline()
|
|
) : (
|
|
<div className="max-h-30rem overflow-auto">
|
|
{timelineItems.map(phase => renderPhaseCard(phase))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Légende */}
|
|
{showDetails && (
|
|
<div className="mt-4 p-3 surface-100 border-round">
|
|
<div className="text-sm font-semibold text-600 mb-2">Légende:</div>
|
|
<div className="flex flex-wrap gap-3 text-xs">
|
|
<div className="flex align-items-center gap-1">
|
|
<i className="pi pi-exclamation-triangle text-red-500"></i>
|
|
<span>Phase critique</span>
|
|
</div>
|
|
<div className="flex align-items-center gap-1">
|
|
<i className="pi pi-bolt text-yellow-500"></i>
|
|
<span>Électricité</span>
|
|
</div>
|
|
<div className="flex align-items-center gap-1">
|
|
<i className="pi pi-home text-blue-500"></i>
|
|
<span>Plomberie</span>
|
|
</div>
|
|
<div className="flex align-items-center gap-1">
|
|
<i className="pi pi-building text-gray-600"></i>
|
|
<span>Maçonnerie</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<Tooltip target=".phase-tooltip" />
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default PhasesTimelinePreview; |