Files
btpxpress-frontend/components/phases/wizard/PreviewGenerationStep.tsx
2025-10-13 05:29:32 +02:00

744 lines
38 KiB
TypeScript

'use client';
/**
* Étape 3 : Prévisualisation et génération avec planning intégré
* Interface finale de validation et génération des phases
*/
import React, { useState, useEffect } from 'react';
import { Card } from 'primereact/card';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Timeline } from 'primereact/timeline';
import { TabView, TabPanel } from 'primereact/tabview';
import { Tag } from 'primereact/tag';
import { Badge } from 'primereact/badge';
import { Divider } from 'primereact/divider';
import { Message } from 'primereact/message';
import { Panel } from 'primereact/panel';
import { ProgressBar } from 'primereact/progressbar';
import { Calendar } from 'primereact/calendar';
import { Accordion, AccordionTab } from 'primereact/accordion';
import { Chart } from 'primereact/chart';
import { WizardConfiguration } from '../PhaseGenerationWizard';
import { Chantier } from '../../../types/btp';
import budgetCoherenceService from '../../../services/budgetCoherenceService';
interface PreviewGenerationStepProps {
configuration: WizardConfiguration;
onConfigurationChange: (config: WizardConfiguration) => void;
chantier: Chantier;
}
interface PhasePreview {
id: string;
nom: string;
ordre: number;
dateDebut: Date;
dateFin: Date;
duree: number;
budget: number;
categorie: string;
sousPhases: SousPhasePreview[];
prerequis: string[];
competences: string[];
status: 'pending' | 'ready' | 'blocked';
}
interface SousPhasePreview {
id: string;
nom: string;
dateDebut: Date;
dateFin: Date;
duree: number;
budget: number;
}
const PreviewGenerationStep: React.FC<PreviewGenerationStepProps> = ({
configuration,
onConfigurationChange,
chantier
}) => {
const [activeTabIndex, setActiveTabIndex] = useState(0);
const [phasesPreview, setPhasesPreview] = useState<PhasePreview[]>([]);
const [chartData, setChartData] = useState<any>({});
const [validationBudget, setValidationBudget] = useState<any>(null);
const [chargementValidation, setChargementValidation] = useState(false);
// Calculer la prévisualisation des phases avec dates
useEffect(() => {
if (configuration.phasesSelectionnees.length > 0) {
calculatePhasesPreview();
}
}, [configuration.phasesSelectionnees, configuration.dateDebutSouhaitee]);
// Validation budgétaire séparée quand la prévisualisation change
useEffect(() => {
if (phasesPreview.length > 0) {
validerBudgetPhases();
}
}, [phasesPreview]);
const calculatePhasesPreview = () => {
const dateDebut = configuration.dateDebutSouhaitee || new Date();
let currentDate = new Date(dateDebut);
const previews: PhasePreview[] = configuration.phasesSelectionnees
.sort((a, b) => a.ordre - b.ordre)
.map((phase) => {
const dateDebutPhase = new Date(currentDate);
const dateFinPhase = new Date(currentDate);
const dureePhaseDays = phase.dureeEstimee || 1; // Défaut 1 jour si non défini
dateFinPhase.setDate(dateFinPhase.getDate() + dureePhaseDays);
// Calculer les sous-phases
let currentSousPhaseDate = new Date(dateDebutPhase);
const sousPhases: SousPhasePreview[] = phase.sousPhases.map((sp) => {
const debutSp = new Date(currentSousPhaseDate);
const finSp = new Date(currentSousPhaseDate);
const dureeSousPhasedays = sp.dureeEstimee || 1; // Défaut 1 jour si non défini
finSp.setDate(finSp.getDate() + dureeSousPhasedays);
currentSousPhaseDate = finSp;
return {
id: sp.id,
nom: sp.nom,
dateDebut: debutSp,
dateFin: finSp,
duree: sp.dureeEstimee || 0,
budget: sp.budgetEstime || 0
};
});
// Déterminer le statut
let status: 'pending' | 'ready' | 'blocked' = 'ready';
if (phase.prerequis.length > 0) {
// Vérifier si tous les prérequis sont satisfaits
const prerequisSatisfaits = phase.prerequis.every(prereq =>
configuration.phasesSelectionnees.some(p =>
p.nom.includes(prereq) && p.ordre < phase.ordre
)
);
status = prerequisSatisfaits ? 'ready' : 'blocked';
}
const preview: PhasePreview = {
id: phase.id,
nom: phase.nom,
ordre: phase.ordre,
dateDebut: dateDebutPhase,
dateFin: dateFinPhase,
duree: phase.dureeEstimee || 0,
budget: phase.budgetEstime || 0,
categorie: phase.categorieMetier,
sousPhases,
prerequis: phase.prerequis,
competences: phase.competencesRequises,
status
};
// Passer à la phase suivante
currentDate = new Date(dateFinPhase);
currentDate.setDate(currentDate.getDate() + 1); // 1 jour de battement
return preview;
});
setPhasesPreview(previews);
prepareChartData(previews);
};
const prepareChartData = (previews: PhasePreview[]) => {
// Données pour le graphique de répartition budgétaire
const budgetData = {
labels: previews.map(p => p.nom.substring(0, 15) + '...'),
datasets: [{
data: previews.map(p => p.budget),
backgroundColor: [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0',
'#9966FF', '#FF9F40', '#FF6384', '#C9CBCF'
],
borderWidth: 0
}]
};
// Données pour le planning (Gantt simplifié)
const planningData = {
labels: previews.map(p => p.nom.substring(0, 10)),
datasets: [{
label: 'Durée (jours)',
data: previews.map(p => p.duree),
backgroundColor: 'rgba(54, 162, 235, 0.5)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1
}]
};
setChartData({ budget: budgetData, planning: planningData });
};
// Validation budgétaire
const validerBudgetPhases = async () => {
if (phasesPreview.length === 0 || chargementValidation) return;
setChargementValidation(true);
try {
const budgetTotal = phasesPreview.reduce((sum, p) => sum + (p.budget || 0), 0);
if (budgetTotal > 0) {
const validation = await budgetCoherenceService.validerBudgetPhases(
chantier.id.toString(),
budgetTotal
);
setValidationBudget(validation);
}
} catch (error) {
console.warn('Erreur lors de la validation budgétaire:', error);
setValidationBudget(null);
} finally {
setChargementValidation(false);
}
};
// Templates pour les tableaux
const statusTemplate = (rowData: PhasePreview) => {
const severityMap = {
'ready': 'success',
'pending': 'warning',
'blocked': 'danger'
} as const;
const labelMap = {
'ready': 'Prête',
'pending': 'En attente',
'blocked': 'Bloquée'
};
return (
<Tag
value={labelMap[rowData.status]}
severity={severityMap[rowData.status]}
icon={`pi ${rowData.status === 'ready' ? 'pi-check' : rowData.status === 'blocked' ? 'pi-times' : 'pi-clock'}`}
/>
);
};
const dateTemplate = (rowData: PhasePreview, field: 'dateDebut' | 'dateFin') => (
<span>{rowData[field].toLocaleDateString('fr-FR')}</span>
);
const budgetTemplate = (rowData: PhasePreview) => (
<span className="font-semibold">
{new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0
}).format(rowData.budget)}
</span>
);
const sousPhaseTemplate = (rowData: PhasePreview) => (
<Badge
value={rowData.sousPhases.length}
severity="info"
className="mr-2"
/>
);
const prerequisTemplate = (rowData: PhasePreview) => (
<div className="flex flex-wrap gap-1">
{rowData.prerequis.slice(0, 2).map((prereq, index) => (
<span
key={index}
className="inline-block bg-orange-50 text-orange-700 px-2 py-1 border-round text-xs"
>
{prereq}
</span>
))}
{rowData.prerequis.length > 2 && (
<span className="inline-block bg-gray-100 text-gray-600 px-2 py-1 border-round text-xs">
+{rowData.prerequis.length - 2}
</span>
)}
</div>
);
// Timeline des phases
const timelineEvents = phasesPreview.map((phase, index) => ({
status: phase.status === 'ready' ? 'success' : phase.status === 'blocked' ? 'danger' : 'warning',
date: phase.dateDebut.toLocaleDateString('fr-FR'),
icon: phase.status === 'ready' ? 'pi-check' : phase.status === 'blocked' ? 'pi-times' : 'pi-clock',
color: phase.status === 'ready' ? '#22c55e' : phase.status === 'blocked' ? '#ef4444' : '#f59e0b',
title: phase.nom,
subtitle: `${phase.duree} jours • ${new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', minimumFractionDigits: 0 }).format(phase.budget)}`
}));
// Statistiques récapitulatives
const stats = {
totalPhases: phasesPreview.length,
totalSousPhases: phasesPreview.reduce((sum, p) => sum + p.sousPhases.length, 0),
dureeTotal: phasesPreview.reduce((sum, p) => sum + p.duree, 0),
budgetTotal: phasesPreview.reduce((sum, p) => sum + p.budget, 0),
phasesReady: phasesPreview.filter(p => p.status === 'ready').length,
phasesBlocked: phasesPreview.filter(p => p.status === 'blocked').length,
dateDebut: phasesPreview.length > 0 ? phasesPreview[0].dateDebut : null,
dateFin: phasesPreview.length > 0 ? phasesPreview[phasesPreview.length - 1].dateFin : null
};
const budgetAvecMarges = configuration.optionsAvancees.appliquerMarges ?
stats.budgetTotal * (1 + configuration.optionsAvancees.taux.margeCommerciale / 100) *
(1 + configuration.optionsAvancees.taux.alea / 100) *
(1 + configuration.optionsAvancees.taux.tva / 100) :
stats.budgetTotal;
// Panel de récapitulatif exécutif
const recapitulatifExecutif = () => (
<Card title="Récapitulatif Exécutif" className="mb-4">
<div className="grid">
<div className="col-12 md:col-3">
<div className="text-center p-3 surface-100 border-round">
<i className="pi pi-building text-primary text-3xl mb-3"></i>
<h6 className="text-color m-0 mb-2">Chantier</h6>
<div className="text-color font-bold">{chantier.nom}</div>
<small className="text-color-secondary">
{configuration.typeChantier?.nom || chantier.typeChantier}
</small>
{chantier.montantPrevu && (
<div className="text-xs text-color-secondary mt-1">
Budget: {chantier.montantPrevu.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })}
</div>
)}
</div>
</div>
<div className="col-12 md:col-3">
<div className="text-center p-3 surface-100 border-round">
<i className="pi pi-sitemap text-primary text-3xl mb-3"></i>
<h6 className="text-color m-0 mb-2">Phases</h6>
<div className="text-color font-bold text-xl">{stats.totalPhases}</div>
<small className="text-color-secondary">+ {stats.totalSousPhases} sous-phases</small>
</div>
</div>
<div className="col-12 md:col-3">
<div className="text-center p-3 surface-100 border-round">
<i className="pi pi-calendar text-primary text-3xl mb-3"></i>
<h6 className="text-color m-0 mb-2">Planning</h6>
<div className="text-color font-bold text-xl">{stats.dureeTotal}</div>
<small className="text-color-secondary">jours ouvrés</small>
{stats.dateDebut && stats.dateFin && (
<div className="mt-2 text-xs text-color-secondary">
{stats.dateDebut.toLocaleDateString('fr-FR')} {stats.dateFin.toLocaleDateString('fr-FR')}
</div>
)}
</div>
</div>
<div className="col-12 md:col-3">
<div className="text-center p-3 surface-100 border-round">
<i className="pi pi-euro text-primary text-3xl mb-3"></i>
<h6 className="text-color m-0 mb-2">Budget</h6>
<div className="text-color font-bold text-lg">
{new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0
}).format(budgetAvecMarges)}
</div>
<small className="text-color-secondary">
{configuration.optionsAvancees.appliquerMarges ? 'avec marges' : 'hors marges'}
</small>
</div>
</div>
</div>
{/* Alertes et validations */}
<Divider />
<div className="grid">
<div className="col-12 md:col-4">
{stats.phasesBlocked > 0 ? (
<Message
severity="warn"
text={`${stats.phasesBlocked} phase(s) bloquée(s) par des prérequis manquants`}
/>
) : (
<Message
severity="success"
text="Toutes les phases sont prêtes à être générées"
/>
)}
</div>
<div className="col-12 md:col-4">
{chargementValidation ? (
<Message
severity="info"
text="Validation budgétaire en cours..."
/>
) : validationBudget ? (
<Message
severity={validationBudget.valide ? "success" : "warn"}
text={validationBudget.message}
/>
) : (
<Message
severity="info"
text="Validation budgétaire indisponible"
/>
)}
</div>
<div className="col-12 md:col-4">
{configuration.optionsAvancees.calculerBudgetAuto ? (
<Message
severity="info"
text="Budgets calculés automatiquement avec coefficients"
/>
) : (
<Message
severity="info"
text="Budgets basés sur la saisie utilisateur"
/>
)}
</div>
</div>
{/* Recommandation budgétaire si nécessaire */}
{validationBudget && !validationBudget.valide && validationBudget.recommandation && (
<div className="mt-3">
<Message
severity="warn"
text={
validationBudget.recommandation === 'METTRE_A_JOUR_CHANTIER'
? `💡 Recommandation : Mettre à jour le budget du chantier à ${validationBudget.nouveauBudgetSuggere ? new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(validationBudget.nouveauBudgetSuggere) : 'calculer'}`
: validationBudget.recommandation === 'AJUSTER_PHASES'
? '💡 Recommandation : Ajuster les budgets des phases pour correspondre au budget du chantier'
: 'Vérification budgétaire recommandée'
}
/>
</div>
)}
</Card>
);
return (
<div>
<div className="flex align-items-center gap-3 mb-4">
<i className="pi pi-eye text-primary text-2xl"></i>
<div>
<h4 className="m-0">Prévisualisation & Génération</h4>
<p className="m-0 text-color-secondary">
Vérifiez la configuration finale avant génération
</p>
</div>
</div>
{recapitulatifExecutif()}
<TabView activeIndex={activeTabIndex} onTabChange={(e) => setActiveTabIndex(e.index)}>
<TabPanel header="Planning Détaillé" leftIcon="pi pi-calendar">
<div className="grid">
<div className="col-12 lg:col-8">
<Card title="Tableau des Phases">
<DataTable
value={phasesPreview}
paginator={false}
emptyMessage="Aucune phase à afficher"
className="p-datatable-sm"
>
<Column
field="ordre"
header="#"
style={{ width: '60px' }}
body={(rowData) => <Badge value={rowData.ordre} />}
/>
<Column
field="nom"
header="Phase"
style={{ minWidth: '200px' }}
/>
<Column
field="dateDebut"
header="Début"
body={(rowData) => dateTemplate(rowData, 'dateDebut')}
style={{ width: '100px' }}
/>
<Column
field="dateFin"
header="Fin"
body={(rowData) => dateTemplate(rowData, 'dateFin')}
style={{ width: '100px' }}
/>
<Column
field="duree"
header="Durée"
body={(rowData) => `${rowData.duree}j`}
style={{ width: '80px' }}
/>
<Column
field="budget"
header="Budget"
body={budgetTemplate}
style={{ width: '120px' }}
/>
<Column
field="sousPhases"
header="S.-phases"
body={sousPhaseTemplate}
style={{ width: '90px' }}
/>
<Column
field="status"
header="Statut"
body={statusTemplate}
style={{ width: '100px' }}
/>
</DataTable>
</Card>
</div>
<div className="col-12 lg:col-4">
<Card title="Timeline">
<Timeline
value={timelineEvents}
content={(item) => (
<div>
<div className="font-semibold text-sm">{item.title}</div>
<small className="text-color-secondary">{item.subtitle}</small>
</div>
)}
className="w-full"
/>
</Card>
</div>
</div>
</TabPanel>
<TabPanel header="Analyse Budgétaire" leftIcon="pi pi-chart-pie">
<div className="grid">
<div className="col-12 lg:col-6">
<Card title="Répartition Budgétaire par Phase">
{chartData.budget && (
<Chart
type="doughnut"
data={chartData.budget}
style={{ height: '300px' }}
/>
)}
</Card>
</div>
<div className="col-12 lg:col-6">
<Card title="Durée par Phase">
{chartData.planning && (
<Chart
type="bar"
data={chartData.planning}
style={{ height: '300px' }}
/>
)}
</Card>
</div>
</div>
{configuration.optionsAvancees.appliquerMarges && (
<Card title="Détail des Marges Appliquées" className="mt-4">
<div className="grid">
<div className="col-12 md:col-6">
<div className="flex justify-content-between mb-2">
<span>Budget base (phases):</span>
<span className="font-semibold">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(stats.budgetTotal)}
</span>
</div>
<div className="flex justify-content-between mb-2">
<span>+ Marge commerciale ({configuration.optionsAvancees.taux.margeCommerciale}%):</span>
<span className="text-blue-600">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(
stats.budgetTotal * configuration.optionsAvancees.taux.margeCommerciale / 100
)}
</span>
</div>
<div className="flex justify-content-between mb-2">
<span>+ Aléa/Imprévus ({configuration.optionsAvancees.taux.alea}%):</span>
<span className="text-orange-600">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(
stats.budgetTotal * (1 + configuration.optionsAvancees.taux.margeCommerciale / 100) *
configuration.optionsAvancees.taux.alea / 100
)}
</span>
</div>
<div className="flex justify-content-between mb-2">
<span>+ TVA ({configuration.optionsAvancees.taux.tva}%):</span>
<span className="text-purple-600">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(
stats.budgetTotal * (1 + configuration.optionsAvancees.taux.margeCommerciale / 100) *
(1 + configuration.optionsAvancees.taux.alea / 100) *
configuration.optionsAvancees.taux.tva / 100
)}
</span>
</div>
<Divider />
<div className="flex justify-content-between">
<span className="font-bold">Total TTC:</span>
<span className="font-bold text-green-600 text-lg">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(budgetAvecMarges)}
</span>
</div>
</div>
<div className="col-12 md:col-6">
<ProgressBar
value={85}
showValue={false}
className="mb-2"
style={{ height: '20px' }}
/>
<small className="text-color-secondary">
Marge totale appliquée: {(((budgetAvecMarges - stats.budgetTotal) / stats.budgetTotal) * 100).toFixed(1)}%
</small>
</div>
</div>
</Card>
)}
</TabPanel>
<TabPanel header="Cohérence Budgétaire" leftIcon="pi pi-calculator">
<Card title="Analyse Budgétaire Détaillée">
{chargementValidation ? (
<div className="text-center p-4">
<ProgressBar mode="indeterminate" style={{ height: '6px' }} />
<p className="mt-3">Vérification de la cohérence budgétaire...</p>
</div>
) : validationBudget ? (
<div>
<div className="grid">
<div className="col-12 md:col-6">
<h6>Résumé de la validation</h6>
<div className="p-3 border-round" style={{
backgroundColor: validationBudget.valide ? '#f0f9ff' : '#fefce8',
border: `1px solid ${validationBudget.valide ? '#0ea5e9' : '#eab308'}`
}}>
<div className="flex align-items-center gap-2 mb-2">
<i className={`pi ${validationBudget.valide ? 'pi-check-circle text-green-600' : 'pi-exclamation-triangle text-yellow-600'} text-lg`}></i>
<span className="font-semibold">
{validationBudget.valide ? 'Budget cohérent' : 'Attention budgétaire'}
</span>
</div>
<p className="m-0 text-sm">{validationBudget.message}</p>
{validationBudget.recommandation && (
<div className="mt-3 p-2 bg-white border-round">
<strong>Recommandation :</strong>
<br />
{validationBudget.recommandation === 'METTRE_A_JOUR_CHANTIER' &&
`Mettre à jour le budget du chantier à ${validationBudget.nouveauBudgetSuggere ? new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(validationBudget.nouveauBudgetSuggere) : 'calculer'}`
}
{validationBudget.recommandation === 'AJUSTER_PHASES' &&
'Ajuster les budgets des phases pour correspondre au budget du chantier'
}
</div>
)}
</div>
</div>
<div className="col-12 md:col-6">
<h6>Répartition budgétaire</h6>
<div className="text-sm">
<div className="flex justify-content-between mb-2">
<span>Budget total des phases :</span>
<span className="font-semibold">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(stats.budgetTotal)}
</span>
</div>
<div className="flex justify-content-between mb-2">
<span>Nombre de phases :</span>
<span className="font-semibold">{stats.totalPhases}</span>
</div>
<div className="flex justify-content-between mb-2">
<span>Budget moyen par phase :</span>
<span className="font-semibold">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(stats.totalPhases > 0 ? stats.budgetTotal / stats.totalPhases : 0)}
</span>
</div>
</div>
</div>
</div>
</div>
) : (
<Message severity="warn" text="Validation budgétaire non disponible" />
)}
</Card>
</TabPanel>
<TabPanel header="Détails Techniques" leftIcon="pi pi-cog">
<Accordion>
{phasesPreview.map((phase, index) => (
<AccordionTab
key={phase.id}
header={
<div className="flex align-items-center gap-3 w-full">
<Badge value={phase.ordre} />
<span className="font-semibold">{phase.nom}</span>
{statusTemplate(phase)}
<span className="ml-auto text-color-secondary">
{phase.duree}j {new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(phase.budget)}
</span>
</div>
}
>
<div className="grid">
<div className="col-12 md:col-6">
<h6>Informations générales</h6>
<div className="flex flex-column gap-2">
<div>
<span className="font-semibold">Catégorie:</span>
<Tag value={phase.categorie} severity="info" className="ml-2" />
</div>
<div>
<span className="font-semibold">Période:</span>
<span className="ml-2">
{phase.dateDebut.toLocaleDateString('fr-FR')} {phase.dateFin.toLocaleDateString('fr-FR')}
</span>
</div>
{phase.prerequis.length > 0 && (
<div>
<span className="font-semibold">Prérequis:</span>
<div className="mt-1">
{prerequisTemplate(phase)}
</div>
</div>
)}
</div>
</div>
<div className="col-12 md:col-6">
<h6>Compétences requises</h6>
<div className="flex flex-wrap gap-1">
{phase.competences.map((comp, idx) => (
<Badge key={idx} value={comp} severity="info" />
))}
</div>
{phase.sousPhases.length > 0 && (
<>
<h6 className="mt-4">Sous-phases ({phase.sousPhases.length})</h6>
<div className="flex flex-column gap-2">
{phase.sousPhases.map((sp, idx) => (
<div key={sp.id} className="p-2 bg-gray-50 border-round">
<div className="flex justify-content-between">
<span className="font-semibold text-sm">{sp.nom}</span>
<span className="text-sm text-color-secondary">
{sp.duree}j {new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(sp.budget)}
</span>
</div>
<small className="text-color-secondary">
{sp.dateDebut.toLocaleDateString('fr-FR')} {sp.dateFin.toLocaleDateString('fr-FR')}
</small>
</div>
))}
</div>
</>
)}
</div>
</div>
</AccordionTab>
))}
</Accordion>
</TabPanel>
</TabView>
</div>
);
};
export default PreviewGenerationStep;