570 lines
21 KiB
TypeScript
570 lines
21 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* Assistant de génération automatique de phases pour chantiers BTP
|
|
* Wizard en 3 étapes: Sélection template -> Personnalisation -> Prévisualisation & Génération
|
|
*/
|
|
|
|
import React, { useState, useRef, useEffect } from 'react';
|
|
import { Dialog } from 'primereact/dialog';
|
|
import { Steps } from 'primereact/steps';
|
|
import { Button } from 'primereact/button';
|
|
import { Card } from 'primereact/card';
|
|
import { Toast } from 'primereact/toast';
|
|
import { ProgressBar } from 'primereact/progressbar';
|
|
import { Divider } from 'primereact/divider';
|
|
import { Badge } from 'primereact/badge';
|
|
import { Tag } from 'primereact/tag';
|
|
import TemplateSelectionStep from './wizard/TemplateSelectionStep';
|
|
import CustomizationStep from './wizard/CustomizationStep';
|
|
import PreviewGenerationStep from './wizard/PreviewGenerationStep';
|
|
import typeChantierService from '../../services/typeChantierService';
|
|
import phaseService from '../../services/phaseService';
|
|
|
|
export interface PhaseTemplate {
|
|
id: string;
|
|
nom: string;
|
|
description: string;
|
|
ordre: number;
|
|
dureeEstimee: number;
|
|
budgetEstime: number;
|
|
competencesRequises: string[];
|
|
prerequis: string[];
|
|
sousPhases: SousPhaseTemplate[];
|
|
categorieMetier: 'GROS_OEUVRE' | 'SECOND_OEUVRE' | 'FINITIONS' | 'EQUIPEMENTS' | 'AMENAGEMENTS';
|
|
obligatoire: boolean;
|
|
personnalisable: boolean;
|
|
}
|
|
|
|
export interface SousPhaseTemplate {
|
|
id: string;
|
|
nom: string;
|
|
description: string;
|
|
ordre: number;
|
|
dureeEstimee: number;
|
|
budgetEstime: number;
|
|
competencesRequises: string[];
|
|
obligatoire: boolean;
|
|
}
|
|
|
|
export interface TypeChantierTemplate {
|
|
id: string;
|
|
nom: string;
|
|
description: string;
|
|
categorie: string;
|
|
phases: PhaseTemplate[];
|
|
dureeGlobaleEstimee: number;
|
|
budgetGlobalEstime: number;
|
|
nombreTotalPhases: number;
|
|
complexiteMetier: 'SIMPLE' | 'MOYENNE' | 'COMPLEXE' | 'EXPERT';
|
|
tags: string[];
|
|
}
|
|
|
|
export interface WizardConfiguration {
|
|
typeChantier: TypeChantierTemplate | null;
|
|
phasesSelectionnees: PhaseTemplate[];
|
|
configurationsPersonnalisees: Record<string, any>;
|
|
budgetGlobal: number;
|
|
dureeGlobale: number;
|
|
dateDebutSouhaitee: Date | null;
|
|
optionsAvancees: {
|
|
integrerPlanning: boolean;
|
|
calculerBudgetAuto: boolean;
|
|
appliquerMarges: boolean;
|
|
taux: {
|
|
margeCommerciale: number;
|
|
alea: number;
|
|
tva: number;
|
|
};
|
|
};
|
|
}
|
|
|
|
interface PhaseGenerationWizardProps {
|
|
visible: boolean;
|
|
onHide: () => void;
|
|
chantier: Chantier;
|
|
onGenerated: (phases: any[]) => void;
|
|
}
|
|
|
|
const PhaseGenerationWizard: React.FC<PhaseGenerationWizardProps> = ({
|
|
visible,
|
|
onHide,
|
|
chantier,
|
|
onGenerated
|
|
}) => {
|
|
const toast = useRef<Toast>(null);
|
|
|
|
// États du wizard
|
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
const [isGenerating, setIsGenerating] = useState(false);
|
|
const [generationProgress, setGenerationProgress] = useState(0);
|
|
|
|
// Configuration du wizard initialisée avec les données du chantier
|
|
const [configuration, setConfiguration] = useState<WizardConfiguration>(() => ({
|
|
typeChantier: null,
|
|
phasesSelectionnees: [],
|
|
configurationsPersonnalisees: {},
|
|
budgetGlobal: chantier?.montantPrevu || 0,
|
|
dureeGlobale: chantier?.dateDebut && chantier?.dateFinPrevue
|
|
? Math.ceil((new Date(chantier.dateFinPrevue).getTime() - new Date(chantier.dateDebut).getTime()) / (1000 * 60 * 60 * 24))
|
|
: 0,
|
|
dateDebutSouhaitee: chantier?.dateDebut ? new Date(chantier.dateDebut) : null,
|
|
optionsAvancees: {
|
|
integrerPlanning: true,
|
|
calculerBudgetAuto: true,
|
|
appliquerMarges: true,
|
|
taux: {
|
|
margeCommerciale: 15,
|
|
alea: 10,
|
|
tva: 20
|
|
}
|
|
}
|
|
}));
|
|
|
|
// Données chargées
|
|
const [templatesTypes, setTemplatesTypes] = useState<TypeChantierTemplate[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// Étapes du wizard
|
|
const wizardSteps = [
|
|
{
|
|
label: 'Sélection Template',
|
|
icon: 'pi pi-th-large',
|
|
description: 'Choisir le type de chantier et template de phases'
|
|
},
|
|
{
|
|
label: 'Personnalisation',
|
|
icon: 'pi pi-cog',
|
|
description: 'Adapter les phases et paramètres à vos besoins'
|
|
},
|
|
{
|
|
label: 'Génération',
|
|
icon: 'pi pi-check-circle',
|
|
description: 'Prévisualiser et générer les phases'
|
|
}
|
|
];
|
|
|
|
// Charger les templates au montage
|
|
useEffect(() => {
|
|
if (visible) {
|
|
loadTemplatesTypes();
|
|
}
|
|
}, [visible]);
|
|
|
|
// Mettre à jour la configuration quand les données du chantier changent
|
|
useEffect(() => {
|
|
if (chantier && visible) {
|
|
setConfiguration(prev => ({
|
|
...prev,
|
|
budgetGlobal: chantier.montantPrevu || 0,
|
|
dureeGlobale: chantier.dateDebut && chantier.dateFinPrevue
|
|
? Math.ceil((new Date(chantier.dateFinPrevue).getTime() - new Date(chantier.dateDebut).getTime()) / (1000 * 60 * 60 * 24))
|
|
: 0,
|
|
dateDebutSouhaitee: chantier.dateDebut ? new Date(chantier.dateDebut) : null
|
|
}));
|
|
}
|
|
}, [chantier, visible]);
|
|
|
|
const loadTemplatesTypes = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const templates = await typeChantierService.getAllTemplates();
|
|
setTemplatesTypes(templates);
|
|
} catch (error) {
|
|
console.error('Erreur lors du chargement des templates:', error);
|
|
toast.current?.show({
|
|
severity: 'error',
|
|
summary: 'Erreur',
|
|
detail: 'Impossible de charger les templates de chantiers',
|
|
life: 3000
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Navigation du wizard
|
|
const nextStep = () => {
|
|
if (activeIndex < wizardSteps.length - 1) {
|
|
setActiveIndex(activeIndex + 1);
|
|
}
|
|
};
|
|
|
|
const prevStep = () => {
|
|
if (activeIndex > 0) {
|
|
setActiveIndex(activeIndex - 1);
|
|
}
|
|
};
|
|
|
|
const canProceedToNext = (): boolean => {
|
|
switch (activeIndex) {
|
|
case 0:
|
|
return configuration.typeChantier !== null;
|
|
case 1:
|
|
return configuration.phasesSelectionnees.length > 0;
|
|
case 2:
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// Génération des phases
|
|
const generatePhases = async () => {
|
|
if (!configuration.typeChantier) {
|
|
toast.current?.show({
|
|
severity: 'warn',
|
|
summary: 'Configuration incomplète',
|
|
detail: 'Veuillez sélectionner un type de chantier',
|
|
life: 3000
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsGenerating(true);
|
|
setGenerationProgress(0);
|
|
|
|
// Simulation du processus de génération avec étapes
|
|
const etapes = [
|
|
{ label: 'Validation de la configuration', delay: 500 },
|
|
{ label: 'Génération des phases principales', delay: 800 },
|
|
{ label: 'Création des sous-phases', delay: 600 },
|
|
{ label: 'Calcul des budgets automatiques', delay: 700 },
|
|
{ label: 'Intégration au planning', delay: 400 },
|
|
{ label: 'Sauvegarde en base', delay: 500 }
|
|
];
|
|
|
|
for (let i = 0; i < etapes.length; i++) {
|
|
const etape = etapes[i];
|
|
|
|
toast.current?.show({
|
|
severity: 'info',
|
|
summary: `Étape ${i + 1}/${etapes.length}`,
|
|
detail: etape.label,
|
|
life: 2000
|
|
});
|
|
|
|
await new Promise(resolve => setTimeout(resolve, etape.delay));
|
|
setGenerationProgress(((i + 1) / etapes.length) * 100);
|
|
}
|
|
|
|
// Appel au service pour générer les phases avec données du chantier
|
|
try {
|
|
const phasesGenerees = await phaseService.generateFromTemplate(
|
|
parseInt(chantier.id.toString()),
|
|
configuration.typeChantier.id,
|
|
{
|
|
phasesSelectionnees: configuration.phasesSelectionnees,
|
|
configurationsPersonnalisees: configuration.configurationsPersonnalisees,
|
|
optionsAvancees: configuration.optionsAvancees,
|
|
dateDebutSouhaitee: configuration.dateDebutSouhaitee || chantier.dateDebut,
|
|
dureeGlobale: configuration.dureeGlobale,
|
|
// Données du chantier pour cohérence
|
|
chantierData: {
|
|
budgetTotal: chantier.montantPrevu,
|
|
typeChantier: chantier.typeChantier,
|
|
dateDebut: chantier.dateDebut,
|
|
dateFinPrevue: chantier.dateFinPrevue,
|
|
surface: chantier.surface,
|
|
adresse: chantier.adresse
|
|
}
|
|
}
|
|
);
|
|
|
|
toast.current?.show({
|
|
severity: 'success',
|
|
summary: 'Génération réussie',
|
|
detail: `${phasesGenerees.length} phases ont été générées avec succès`,
|
|
life: 5000
|
|
});
|
|
|
|
onGenerated(phasesGenerees);
|
|
onHide();
|
|
|
|
} catch (serviceError) {
|
|
console.warn('Service non disponible, génération simulée:', serviceError);
|
|
|
|
// Génération simulée de phases avec données du chantier
|
|
const baseDate = chantier.dateDebut ? new Date(chantier.dateDebut) : new Date();
|
|
let currentDate = new Date(baseDate);
|
|
|
|
const phasesSimulees = configuration.phasesSelectionnees.map((phase, index) => {
|
|
const dateDebut = new Date(currentDate);
|
|
const dateFin = new Date(dateDebut.getTime() + phase.dureeEstimee * 24 * 60 * 60 * 1000);
|
|
currentDate = new Date(dateFin.getTime() + 24 * 60 * 60 * 1000); // Jour suivant pour la phase suivante
|
|
|
|
// Calculer budget proportionnel si budget total défini
|
|
let budgetProportionnel = phase.budgetEstime;
|
|
if (chantier.montantPrevu && configuration.budgetGlobal) {
|
|
const ratioPhase = phase.budgetEstime / configuration.budgetGlobal;
|
|
budgetProportionnel = chantier.montantPrevu * ratioPhase;
|
|
}
|
|
|
|
return {
|
|
id: `sim_${Date.now()}_${index}`,
|
|
nom: phase.nom,
|
|
description: phase.description,
|
|
chantierId: chantier.id.toString(),
|
|
dateDebutPrevue: dateDebut.toISOString(),
|
|
dateFinPrevue: dateFin.toISOString(),
|
|
dureeEstimeeHeures: phase.dureeEstimee * 8,
|
|
budgetPrevu: budgetProportionnel,
|
|
coutReel: 0,
|
|
statut: 'PLANIFIEE',
|
|
priorite: phase.categorieMetier === 'GROS_OEUVRE' ? 'CRITIQUE' : 'MOYENNE',
|
|
critique: phase.obligatoire,
|
|
ordreExecution: phase.ordre,
|
|
phaseParent: null,
|
|
prerequisPhases: [],
|
|
competencesRequises: phase.competencesRequises,
|
|
materielsNecessaires: [],
|
|
fournisseursRecommandes: [],
|
|
dateCreation: new Date().toISOString(),
|
|
dateModification: new Date().toISOString(),
|
|
creePar: 'wizard',
|
|
modifiePar: 'wizard'
|
|
};
|
|
});
|
|
|
|
toast.current?.show({
|
|
severity: 'success',
|
|
summary: 'Génération simulée réussie',
|
|
detail: `${phasesSimulees.length} phases ont été générées (mode simulation)`,
|
|
life: 5000
|
|
});
|
|
|
|
onGenerated(phasesSimulees);
|
|
onHide();
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Erreur lors de la génération:', error);
|
|
toast.current?.show({
|
|
severity: 'error',
|
|
summary: 'Erreur de génération',
|
|
detail: 'Impossible de générer les phases automatiquement',
|
|
life: 3000
|
|
});
|
|
} finally {
|
|
setIsGenerating(false);
|
|
setGenerationProgress(0);
|
|
}
|
|
};
|
|
|
|
// Reset du wizard avec données du chantier
|
|
const resetWizard = () => {
|
|
setActiveIndex(0);
|
|
setConfiguration({
|
|
typeChantier: null,
|
|
phasesSelectionnees: [],
|
|
configurationsPersonnalisees: {},
|
|
budgetGlobal: chantier?.montantPrevu || 0,
|
|
dureeGlobale: chantier?.dateDebut && chantier?.dateFinPrevue
|
|
? Math.ceil((new Date(chantier.dateFinPrevue).getTime() - new Date(chantier.dateDebut).getTime()) / (1000 * 60 * 60 * 24))
|
|
: 0,
|
|
dateDebutSouhaitee: chantier?.dateDebut ? new Date(chantier.dateDebut) : null,
|
|
optionsAvancees: {
|
|
integrerPlanning: true,
|
|
calculerBudgetAuto: true,
|
|
appliquerMarges: true,
|
|
taux: {
|
|
margeCommerciale: 15,
|
|
alea: 10,
|
|
tva: 20
|
|
}
|
|
}
|
|
});
|
|
setGenerationProgress(0);
|
|
setIsGenerating(false);
|
|
};
|
|
|
|
// Template du header
|
|
const headerTemplate = () => (
|
|
<div className="flex align-items-center gap-3">
|
|
<i className="pi pi-magic text-2xl text-primary"></i>
|
|
<div>
|
|
<h4 className="m-0">Assistant de Génération de Phases</h4>
|
|
<p className="m-0 text-color-secondary text-sm">
|
|
Chantier: <strong>{chantier?.nom}</strong>
|
|
{chantier?.typeChantier && (
|
|
<span className="ml-2 text-xs">({chantier.typeChantier})</span>
|
|
)}
|
|
</p>
|
|
{chantier && (
|
|
<p className="m-0 text-xs text-500">
|
|
Budget: {chantier.montantPrevu?.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' }) || 'Non défini'} |
|
|
Période: {chantier.dateDebut ? new Date(chantier.dateDebut).toLocaleDateString('fr-FR') : 'Non défini'} →
|
|
{chantier.dateFinPrevue ? new Date(chantier.dateFinPrevue).toLocaleDateString('fr-FR') : 'Non défini'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{configuration.typeChantier && (
|
|
<div className="ml-auto">
|
|
<Tag
|
|
value={configuration.typeChantier.nom}
|
|
severity="info"
|
|
icon="pi pi-building"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// Template du footer
|
|
const footerTemplate = () => (
|
|
<div className="flex justify-content-between align-items-center">
|
|
<div className="flex align-items-center gap-2">
|
|
<Button
|
|
label="Précédent"
|
|
icon="pi pi-arrow-left"
|
|
className="p-button-text p-button-rounded"
|
|
onClick={prevStep}
|
|
disabled={activeIndex === 0 || isGenerating}
|
|
/>
|
|
{activeIndex < wizardSteps.length - 1 ? (
|
|
<Button
|
|
label="Suivant"
|
|
icon="pi pi-arrow-right"
|
|
iconPos="right"
|
|
className="p-button-text p-button-rounded p-button-info"
|
|
onClick={nextStep}
|
|
disabled={!canProceedToNext() || isGenerating}
|
|
/>
|
|
) : (
|
|
<Button
|
|
label={isGenerating ? "Génération..." : "Générer les phases"}
|
|
icon={isGenerating ? "pi pi-spin pi-spinner" : "pi pi-check"}
|
|
className="p-button-text p-button-rounded p-button-success"
|
|
onClick={generatePhases}
|
|
disabled={!canProceedToNext() || isGenerating}
|
|
loading={isGenerating}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex align-items-center gap-2">
|
|
{configuration.phasesSelectionnees.length > 0 && (
|
|
<Badge
|
|
value={`${configuration.phasesSelectionnees.length} phases`}
|
|
severity="info"
|
|
/>
|
|
)}
|
|
<Button
|
|
label="Fermer"
|
|
icon="pi pi-times"
|
|
className="p-button-text p-button-rounded"
|
|
onClick={() => {
|
|
resetWizard();
|
|
onHide();
|
|
}}
|
|
disabled={isGenerating}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// Rendu conditionnel des étapes
|
|
const renderCurrentStep = () => {
|
|
switch (activeIndex) {
|
|
case 0:
|
|
return (
|
|
<TemplateSelectionStep
|
|
templates={templatesTypes}
|
|
loading={loading}
|
|
configuration={configuration}
|
|
onConfigurationChange={setConfiguration}
|
|
chantier={chantier}
|
|
/>
|
|
);
|
|
case 1:
|
|
return (
|
|
<CustomizationStep
|
|
configuration={configuration}
|
|
onConfigurationChange={setConfiguration}
|
|
/>
|
|
);
|
|
case 2:
|
|
return (
|
|
<PreviewGenerationStep
|
|
configuration={configuration}
|
|
onConfigurationChange={setConfiguration}
|
|
chantier={chantier}
|
|
/>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Toast ref={toast} />
|
|
|
|
<Dialog
|
|
visible={visible}
|
|
onHide={() => {
|
|
if (!isGenerating) {
|
|
resetWizard();
|
|
onHide();
|
|
}
|
|
}}
|
|
header={headerTemplate}
|
|
footer={footerTemplate}
|
|
style={{ width: '90vw', maxWidth: '1200px' }}
|
|
modal
|
|
closable={!isGenerating}
|
|
className="p-dialog-maximized-responsive"
|
|
>
|
|
<div className="grid">
|
|
{/* Barre de progression de génération */}
|
|
{isGenerating && (
|
|
<div className="col-12 mb-4">
|
|
<Card className="bg-blue-50 border-blue-200">
|
|
<div className="flex align-items-center gap-3">
|
|
<i className="pi pi-spin pi-cog text-blue-600 text-2xl"></i>
|
|
<div className="flex-1">
|
|
<h6 className="m-0 text-blue-800">Génération en cours...</h6>
|
|
<ProgressBar
|
|
value={generationProgress}
|
|
className="mt-2"
|
|
style={{ height: '8px' }}
|
|
/>
|
|
<small className="text-blue-600 mt-1 block">
|
|
{generationProgress.toFixed(0)}% terminé
|
|
</small>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Steps navigation */}
|
|
<div className="col-12 mb-4">
|
|
<Steps
|
|
model={wizardSteps}
|
|
activeIndex={activeIndex}
|
|
onSelect={(e) => {
|
|
if (!isGenerating && e.index <= activeIndex) {
|
|
setActiveIndex(e.index);
|
|
}
|
|
}}
|
|
readOnly={isGenerating}
|
|
/>
|
|
</div>
|
|
|
|
<Divider />
|
|
|
|
{/* Contenu de l'étape courante */}
|
|
<div className="col-12">
|
|
<div style={{ minHeight: '500px' }}>
|
|
{renderCurrentStep()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default PhaseGenerationWizard; |