Initial commit

This commit is contained in:
dahoud
2025-10-01 01:39:07 +00:00
commit b430bf3b96
826 changed files with 255287 additions and 0 deletions

View File

@@ -0,0 +1,266 @@
'use client';
import React, { useState, useContext, useEffect } from 'react';
import { Panel } from 'primereact/panel';
import { InputSwitch } from 'primereact/inputswitch';
import { Slider } from 'primereact/slider';
import { Dropdown } from 'primereact/dropdown';
import { Button } from 'primereact/button';
import { Message } from 'primereact/message';
import { LayoutContext } from '../../layout/context/layoutcontext';
interface AtlantisAccessibilityControlsProps {
className?: string;
}
interface AccessibilitySettings {
fontSize: number;
highContrast: boolean;
reduceMotion: boolean;
screenReader: boolean;
keyboardNav: boolean;
}
const AtlantisAccessibilityControls: React.FC<AtlantisAccessibilityControlsProps> = ({
className = ''
}) => {
const { layoutConfig, setLayoutConfig } = useContext(LayoutContext);
const [settings, setSettings] = useState<AccessibilitySettings>({
fontSize: 14,
highContrast: false,
reduceMotion: false,
screenReader: false,
keyboardNav: true
});
// Options de thème pour l'accessibilité
const contrastThemes = [
{ label: 'Thème normal', value: 'magenta' },
{ label: 'Contraste élevé - Bleu', value: 'blue' },
{ label: 'Contraste élevé - Sombre', value: 'dark' }
];
// Appliquer les paramètres d'accessibilité
useEffect(() => {
// Appliquer la taille de police via scale Atlantis
setLayoutConfig(prev => ({
...prev,
scale: settings.fontSize
}));
// Appliquer le thème de contraste
if (settings.highContrast) {
setLayoutConfig(prev => ({
...prev,
theme: 'blue', // Thème avec meilleur contraste
colorScheme: 'dark'
}));
}
// Classes CSS pour les animations
const rootElement = document.documentElement;
if (settings.reduceMotion) {
rootElement.style.setProperty('--transition-duration', '0ms');
} else {
rootElement.style.removeProperty('--transition-duration');
}
// Annoncer les changements pour les lecteurs d'écran
if (settings.screenReader) {
announceChange('Paramètres d\'accessibilité mis à jour');
}
}, [settings, setLayoutConfig]);
// Fonction d'annonce pour lecteurs d'écran
const announceChange = (message: string) => {
const announcement = document.createElement('div');
announcement.setAttribute('aria-live', 'polite');
announcement.setAttribute('aria-atomic', 'true');
announcement.className = 'sr-only';
announcement.textContent = message;
document.body.appendChild(announcement);
setTimeout(() => document.body.removeChild(announcement), 1000);
};
const updateSetting = (key: keyof AccessibilitySettings, value: any) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const resetToDefaults = () => {
setSettings({
fontSize: 14,
highContrast: false,
reduceMotion: false,
screenReader: false,
keyboardNav: true
});
setLayoutConfig(prev => ({
...prev,
scale: 14,
theme: 'magenta',
colorScheme: 'dark'
}));
announceChange('Paramètres d\'accessibilité réinitialisés');
};
return (
<Panel
header="Paramètres d'accessibilité"
toggleable
collapsed
className={`card ${className}`}
pt={{
header: { className: 'surface-100' },
content: { className: 'surface-50' }
}}
>
<div className="grid">
{/* Taille de police */}
<div className="col-12 md:col-6">
<div className="field">
<label htmlFor="fontSize" className="font-semibold text-color">
Taille de police: {settings.fontSize}px
</label>
<Slider
id="fontSize"
value={settings.fontSize}
onChange={(e) => updateSetting('fontSize', e.value)}
min={12}
max={20}
step={1}
className="w-full mt-2"
/>
<div className="flex justify-content-between text-xs text-color-secondary mt-1">
<span>Petit</span>
<span>Normal</span>
<span>Grand</span>
</div>
</div>
</div>
{/* Contraste élevé */}
<div className="col-12 md:col-6">
<div className="field">
<label htmlFor="highContrast" className="font-semibold text-color">
Mode contraste élevé
</label>
<div className="flex align-items-center gap-2 mt-2">
<InputSwitch
id="highContrast"
checked={settings.highContrast}
onChange={(e) => updateSetting('highContrast', e.value)}
/>
<span className="text-sm text-color-secondary">
{settings.highContrast ? 'Activé' : 'Désactivé'}
</span>
</div>
</div>
</div>
{/* Réduction des animations */}
<div className="col-12 md:col-6">
<div className="field">
<label htmlFor="reduceMotion" className="font-semibold text-color">
Réduire les animations
</label>
<div className="flex align-items-center gap-2 mt-2">
<InputSwitch
id="reduceMotion"
checked={settings.reduceMotion}
onChange={(e) => updateSetting('reduceMotion', e.value)}
/>
<span className="text-sm text-color-secondary">
Pour les utilisateurs sensibles au mouvement
</span>
</div>
</div>
</div>
{/* Mode lecteur d'écran */}
<div className="col-12 md:col-6">
<div className="field">
<label htmlFor="screenReader" className="font-semibold text-color">
Mode lecteur d'écran
</label>
<div className="flex align-items-center gap-2 mt-2">
<InputSwitch
id="screenReader"
checked={settings.screenReader}
onChange={(e) => updateSetting('screenReader', e.value)}
/>
<span className="text-sm text-color-secondary">
Annonces vocales activées
</span>
</div>
</div>
</div>
</div>
{/* Informations et aide */}
<div className="mt-4">
<Message
severity="info"
text="Ces paramètres améliorent l'accessibilité selon vos besoins"
className="w-full"
/>
</div>
{/* Raccourcis clavier */}
<div className="card mt-3">
<h6 className="mt-0 mb-3 text-color">Raccourcis clavier disponibles</h6>
<div className="grid text-sm">
<div className="col-12 md:col-6">
<div className="flex align-items-center gap-2 mb-2">
<kbd className="bg-surface-200 text-color px-2 py-1 border-round text-xs">Tab</kbd>
<span className="text-color-secondary">Naviguer entre les éléments</span>
</div>
<div className="flex align-items-center gap-2 mb-2">
<kbd className="bg-surface-200 text-color px-2 py-1 border-round text-xs">Entrée</kbd>
<span className="text-color-secondary">Activer un élément</span>
</div>
</div>
<div className="col-12 md:col-6">
<div className="flex align-items-center gap-2 mb-2">
<kbd className="bg-surface-200 text-color px-2 py-1 border-round text-xs">Échap</kbd>
<span className="text-color-secondary">Fermer un dialogue</span>
</div>
<div className="flex align-items-center gap-2 mb-2">
<kbd className="bg-surface-200 text-color px-2 py-1 border-round text-xs">↑↓</kbd>
<span className="text-color-secondary">Naviguer dans les listes</span>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex justify-content-between align-items-center mt-4">
<Button
label="Réinitialiser"
icon="pi pi-refresh"
className="p-button-outlined"
onClick={resetToDefaults}
/>
<div className="flex align-items-center gap-2">
<i className="pi pi-info-circle text-primary" />
<span className="text-sm text-color-secondary">
Paramètres sauvegardés automatiquement
</span>
</div>
</div>
{/* Région pour les annonces lecteurs d'écran */}
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
id="accessibility-announcements"
/>
</Panel>
);
};
export default AtlantisAccessibilityControls;

View File

@@ -0,0 +1,371 @@
'use client';
import React, { useState, useContext } from 'react';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Tag } from 'primereact/tag';
import { Badge } from 'primereact/badge';
import { Button } from 'primereact/button';
import { Toolbar } from 'primereact/toolbar';
import { Dropdown } from 'primereact/dropdown';
import { InputSwitch } from 'primereact/inputswitch';
import { LayoutContext } from '../../layout/context/layoutcontext';
import type { PhaseChantier } from '../../types/btp';
import phaseValidationService from '../../services/phaseValidationService';
interface AtlantisResponsivePhasesTableProps {
phases: PhaseChantier[];
onPhaseSelect?: (phase: PhaseChantier) => void;
onPhaseStart?: (phaseId: string) => void;
onPhaseValidate?: (phase: PhaseChantier) => void;
className?: string;
}
const AtlantisResponsivePhasesTable: React.FC<AtlantisResponsivePhasesTableProps> = ({
phases,
onPhaseSelect,
onPhaseStart,
onPhaseValidate,
className = ''
}) => {
const [selectedPhase, setSelectedPhase] = useState<PhaseChantier | null>(null);
const [globalFilter, setGlobalFilter] = useState<string>('');
const [filters, setFilters] = useState<any>({});
const { layoutConfig, isDesktop } = useContext(LayoutContext);
// Options de filtrage responsive
const [compactView, setCompactView] = useState(!isDesktop());
const [showSubPhases, setShowSubPhases] = useState(true);
const handlePhaseSelect = (phase: PhaseChantier) => {
setSelectedPhase(phase);
onPhaseSelect?.(phase);
};
// Template pour le nom des phases avec hiérarchie Atlantis
const nameBodyTemplate = (rowData: PhaseChantier) => {
const isSubPhase = !!rowData.phaseParent;
return (
<div className={`flex align-items-center gap-2 ${isSubPhase ? 'ml-4' : ''}`}>
{isSubPhase && (
<i className="pi pi-arrow-right text-color-secondary text-sm" />
)}
<span className={`${isSubPhase ? 'text-color-secondary' : 'font-semibold text-color'}`}>
{rowData.nom}
</span>
{rowData.critique && (
<Tag
value="Critique"
severity="danger"
className="text-xs"
/>
)}
</div>
);
};
// Template pour le statut avec Tag PrimeReact
const statusBodyTemplate = (rowData: PhaseChantier) => {
const getSeverity = () => {
switch (rowData.statut) {
case 'TERMINEE': return 'success';
case 'EN_COURS': return 'info';
default: return 'secondary';
}
};
return (
<Tag
value={rowData.statut}
severity={getSeverity()}
icon={`pi pi-${rowData.statut === 'TERMINEE' ? 'check' : rowData.statut === 'EN_COURS' ? 'clock' : 'calendar'}`}
/>
);
};
// Template pour l'avancement avec style Atlantis
const progressBodyTemplate = (rowData: PhaseChantier) => {
const progress = rowData.pourcentageAvancement || 0;
return (
<div className="flex align-items-center gap-2">
<div className="w-full bg-surface-200 border-round" style={{ height: '8px' }}>
<div
className="bg-primary border-round h-full transition-all transition-duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-sm font-semibold text-color-secondary min-w-max">
{progress}%
</span>
</div>
);
};
// Template pour la validation avec couleurs Atlantis
const validationBodyTemplate = (rowData: PhaseChantier) => {
const validation = phaseValidationService.validatePhaseStart(rowData, phases);
const getValidationButton = () => {
if (validation.readyToStart) {
return (
<Button
icon="pi pi-check-circle"
className="p-button-success p-button-rounded p-button-text"
onClick={() => onPhaseValidate?.(rowData)}
tooltip="Prête à démarrer"
/>
);
} else if (validation.canStart) {
return (
<Button
icon="pi pi-exclamation-triangle"
className="p-button-warning p-button-rounded p-button-text"
onClick={() => onPhaseValidate?.(rowData)}
tooltip="Peut démarrer avec précautions"
/>
);
} else {
return (
<Button
icon="pi pi-times-circle"
className="p-button-danger p-button-rounded p-button-text"
onClick={() => onPhaseValidate?.(rowData)}
tooltip="Ne peut pas démarrer"
/>
);
}
};
return (
<div className="flex align-items-center gap-2">
{getValidationButton()}
{validation.errors.length > 0 && (
<Badge value={validation.errors.length} severity="danger" />
)}
{validation.warnings.length > 0 && (
<Badge value={validation.warnings.length} severity="warning" />
)}
</div>
);
};
// Template pour les actions
const actionsBodyTemplate = (rowData: PhaseChantier) => {
const validation = phaseValidationService.validatePhaseStart(rowData, phases);
return (
<div className="flex gap-1">
<Button
icon="pi pi-play"
className="p-button-success p-button-sm"
disabled={!validation.canStart || rowData.statut === 'TERMINEE'}
onClick={() => onPhaseStart?.(rowData.id!)}
tooltip="Démarrer"
/>
<Button
icon="pi pi-eye"
className="p-button-outlined p-button-sm"
onClick={() => onPhaseValidate?.(rowData)}
tooltip="Détails"
/>
</div>
);
};
// Template pour les dates avec style Atlantis
const dateBodyTemplate = (field: string) => (rowData: PhaseChantier) => {
const date = rowData[field as keyof PhaseChantier] as string;
if (!date) return <span className="text-color-secondary">-</span>;
const formattedDate = new Date(date).toLocaleDateString('fr-FR');
const isOverdue = field.includes('Fin') && rowData.statut !== 'TERMINEE' && new Date(date) < new Date();
return (
<span className={isOverdue ? 'text-red-500 font-semibold' : 'text-color'}>
{formattedDate}
{isOverdue && <i className="pi pi-exclamation-triangle ml-2 text-red-500" />}
</span>
);
};
// Barre d'outils responsive Atlantis
const toolbarStart = (
<div className="flex align-items-center gap-2">
<h5 className="m-0 text-color">Phases du chantier</h5>
{!isDesktop() && (
<Badge value={phases.length} className="ml-2" />
)}
</div>
);
const toolbarEnd = (
<div className="flex align-items-center gap-2">
<div className="field-checkbox">
<InputSwitch
inputId="compactView"
checked={compactView}
onChange={(e) => setCompactView(e.value)}
/>
<label htmlFor="compactView" className="ml-2 text-sm">Vue compacte</label>
</div>
<div className="field-checkbox">
<InputSwitch
inputId="showSubPhases"
checked={showSubPhases}
onChange={(e) => setShowSubPhases(e.value)}
/>
<label htmlFor="showSubPhases" className="ml-2 text-sm">Sous-phases</label>
</div>
</div>
);
// Filtrer les phases selon les options
const filteredPhases = phases.filter(phase => {
if (!showSubPhases && phase.phaseParent) return false;
return true;
});
// Déterminer les colonnes à afficher selon la taille d'écran
const getVisibleColumns = () => {
if (compactView) {
return ['nom', 'statut', 'pourcentageAvancement', 'actions'];
} else if (!isDesktop()) {
return ['nom', 'statut', 'pourcentageAvancement', 'validation', 'actions'];
} else {
return ['nom', 'statut', 'pourcentageAvancement', 'dateDebutPrevue', 'dateFinPrevue', 'validation', 'actions'];
}
};
const visibleColumns = getVisibleColumns();
return (
<div className={`card ${className}`}>
<Toolbar
start={toolbarStart}
end={toolbarEnd}
className="mb-4"
/>
<DataTable
value={filteredPhases}
selection={selectedPhase}
onSelectionChange={(e) => setSelectedPhase(e.value)}
selectionMode="single"
dataKey="id"
size={compactView ? 'small' : 'normal'}
stripedRows
responsiveLayout="scroll"
className="datatable-responsive"
emptyMessage="Aucune phase trouvée"
globalFilter={globalFilter}
header={
isDesktop() ? (
<div className="flex justify-content-between align-items-center">
<span className="text-xl font-semibold text-color">
Gestion des phases ({filteredPhases.length})
</span>
<span className="p-input-icon-left">
<i className="pi pi-search" />
<input
type="text"
className="p-inputtext p-component"
placeholder="Rechercher..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
/>
</span>
</div>
) : undefined
}
>
{visibleColumns.includes('nom') && (
<Column
field="nom"
header="Phase"
body={nameBodyTemplate}
sortable
style={{ minWidth: compactView ? '200px' : '250px' }}
/>
)}
{visibleColumns.includes('statut') && (
<Column
field="statut"
header="Statut"
body={statusBodyTemplate}
sortable
style={{ width: '120px' }}
/>
)}
{visibleColumns.includes('pourcentageAvancement') && (
<Column
field="pourcentageAvancement"
header="Avancement"
body={progressBodyTemplate}
sortable
style={{ width: compactView ? '120px' : '150px' }}
/>
)}
{visibleColumns.includes('dateDebutPrevue') && (
<Column
field="dateDebutPrevue"
header="Début prévu"
body={dateBodyTemplate('dateDebutPrevue')}
sortable
style={{ width: '130px' }}
/>
)}
{visibleColumns.includes('dateFinPrevue') && (
<Column
field="dateFinPrevue"
header="Fin prévue"
body={dateBodyTemplate('dateFinPrevue')}
sortable
style={{ width: '130px' }}
/>
)}
{visibleColumns.includes('validation') && (
<Column
header="Validation"
body={validationBodyTemplate}
style={{ width: '140px' }}
/>
)}
{visibleColumns.includes('actions') && (
<Column
header="Actions"
body={actionsBodyTemplate}
exportable={false}
style={{ width: '100px' }}
/>
)}
</DataTable>
{/* Informations sur la phase sélectionnée - Style Atlantis */}
{selectedPhase && (
<div className="card mt-3">
<div className="card-header">
<h6 className="m-0">Phase sélectionnée</h6>
</div>
<p className="m-0 mt-2">
<strong>{selectedPhase.nom}</strong> - {selectedPhase.statut} -
{selectedPhase.pourcentageAvancement || 0}% d'avancement
</p>
{selectedPhase.description && (
<p className="mt-2 mb-0 text-color-secondary">{selectedPhase.description}</p>
)}
</div>
)}
</div>
);
};
export default AtlantisResponsivePhasesTable;

View File

@@ -0,0 +1,584 @@
/**
* Dialog d'exécution budgétaire pour le suivi des dépenses réelles
* Permet la comparaison budget prévu vs coût réel avec analyse des écarts
*/
import React, { useState, useRef, useEffect } from 'react';
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
import { InputNumber } from 'primereact/inputnumber';
import { InputText } from 'primereact/inputtext';
import { Dropdown } from 'primereact/dropdown';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Card } from 'primereact/card';
import { TabView, TabPanel } from 'primereact/tabview';
import { Toast } from 'primereact/toast';
import { Divider } from 'primereact/divider';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { Calendar } from 'primereact/calendar';
import { Chart } from 'primereact/chart';
import { PhaseChantier } from '../../types/btp-extended';
interface DepenseReelle {
id?: string;
date: string;
categorie: 'MATERIEL' | 'MAIN_OEUVRE' | 'SOUS_TRAITANCE' | 'TRANSPORT' | 'AUTRES';
designation: string;
montant: number;
fournisseur?: string;
numeroPiece?: string; // Numéro de facture, bon de commande, etc.
notes?: string;
valide: boolean;
validePar?: string;
dateValidation?: string;
}
interface AnalyseEcart {
categorieId: string;
categorieNom: string;
budgetPrevu: number;
depenseReelle: number;
ecart: number;
ecartPourcentage: number;
statut: 'CONFORME' | 'ALERTE' | 'DEPASSEMENT';
}
interface BudgetExecutionDialogProps {
visible: boolean;
onHide: () => void;
phase: PhaseChantier | null;
onSave: (executionData: any) => void;
}
export const BudgetExecutionDialog: React.FC<BudgetExecutionDialogProps> = ({
visible,
onHide,
phase,
onSave
}) => {
const toast = useRef<Toast>(null);
const [activeIndex, setActiveIndex] = useState(0);
const [depenses, setDepenses] = useState<DepenseReelle[]>([]);
const [nouvelleDepense, setNouvelleDepense] = useState<DepenseReelle>({
date: new Date().toISOString().split('T')[0],
categorie: 'MATERIEL',
designation: '',
montant: 0,
valide: false
});
const categories = [
{ label: 'Matériel', value: 'MATERIEL', color: '#007ad9' },
{ label: 'Main d\'œuvre', value: 'MAIN_OEUVRE', color: '#22c55e' },
{ label: 'Sous-traitance', value: 'SOUS_TRAITANCE', color: '#f97316' },
{ label: 'Transport', value: 'TRANSPORT', color: '#8b5cf6' },
{ label: 'Autres', value: 'AUTRES', color: '#6b7280' }
];
// Simuler le budget prévu par catégorie (normalement récupéré de l'analyse budgétaire)
const budgetParCategorie = {
MATERIEL: phase?.budgetPrevu ? phase.budgetPrevu * 0.4 : 0,
MAIN_OEUVRE: phase?.budgetPrevu ? phase.budgetPrevu * 0.3 : 0,
SOUS_TRAITANCE: phase?.budgetPrevu ? phase.budgetPrevu * 0.2 : 0,
TRANSPORT: phase?.budgetPrevu ? phase.budgetPrevu * 0.05 : 0,
AUTRES: phase?.budgetPrevu ? phase.budgetPrevu * 0.05 : 0
};
// Charger les dépenses existantes au montage du composant
useEffect(() => {
if (phase && visible) {
loadDepenses();
}
}, [phase, visible]);
const loadDepenses = async () => {
// Simuler le chargement des dépenses depuis l'API
// En réalité, ceci ferait appel à une API
const depensesSimulees: DepenseReelle[] = [
{
id: '1',
date: '2025-01-15',
categorie: 'MATERIEL',
designation: 'Béton C25/30',
montant: 1500,
fournisseur: 'Béton Express',
numeroPiece: 'FC-2025-001',
valide: true,
validePar: 'Chef de projet',
dateValidation: '2025-01-16'
},
{
id: '2',
date: '2025-01-20',
categorie: 'MAIN_OEUVRE',
designation: 'Équipe de maçonnerie - 2 jours',
montant: 800,
numeroPiece: 'TS-2025-005',
valide: true,
validePar: 'Chef de projet',
dateValidation: '2025-01-21'
}
];
setDepenses(depensesSimulees);
};
// Ajouter une nouvelle dépense
const ajouterDepense = () => {
if (!nouvelleDepense.designation.trim() || nouvelleDepense.montant <= 0) {
toast.current?.show({
severity: 'warn',
summary: 'Champs requis',
detail: 'Veuillez remplir la désignation et le montant',
life: 3000
});
return;
}
const nouvelleDepenseAvecId = {
...nouvelleDepense,
id: `dep_${Date.now()}`
};
setDepenses([...depenses, nouvelleDepenseAvecId]);
// Réinitialiser le formulaire
setNouvelleDepense({
date: new Date().toISOString().split('T')[0],
categorie: 'MATERIEL',
designation: '',
montant: 0,
valide: false
});
toast.current?.show({
severity: 'success',
summary: 'Dépense ajoutée',
detail: 'La dépense a été enregistrée',
life: 3000
});
};
// Valider une dépense
const validerDepense = (depenseId: string) => {
setDepenses(depenses.map(d =>
d.id === depenseId
? {
...d,
valide: true,
validePar: 'Utilisateur actuel',
dateValidation: new Date().toISOString()
}
: d
));
};
// Supprimer une dépense
const supprimerDepense = (depenseId: string) => {
setDepenses(depenses.filter(d => d.id !== depenseId));
};
// Calculer l'analyse des écarts
const getAnalyseEcarts = (): AnalyseEcart[] => {
return categories.map(cat => {
const budgetPrevu = budgetParCategorie[cat.value as keyof typeof budgetParCategorie];
const depenseReelle = depenses
.filter(d => d.categorie === cat.value && d.valide)
.reduce((sum, d) => sum + d.montant, 0);
const ecart = depenseReelle - budgetPrevu;
const ecartPourcentage = budgetPrevu > 0 ? (ecart / budgetPrevu) * 100 : 0;
let statut: 'CONFORME' | 'ALERTE' | 'DEPASSEMENT' = 'CONFORME';
if (ecartPourcentage > 10) statut = 'DEPASSEMENT';
else if (ecartPourcentage > 5) statut = 'ALERTE';
return {
categorieId: cat.value,
categorieNom: cat.label,
budgetPrevu,
depenseReelle,
ecart,
ecartPourcentage,
statut
};
});
};
// Préparer les données pour le graphique
const getChartData = () => {
const analyses = getAnalyseEcarts();
return {
labels: analyses.map(a => a.categorieNom),
datasets: [
{
label: 'Budget prévu',
data: analyses.map(a => a.budgetPrevu),
backgroundColor: 'rgba(54, 162, 235, 0.6)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1
},
{
label: 'Dépense réelle',
data: analyses.map(a => a.depenseReelle),
backgroundColor: 'rgba(255, 99, 132, 0.6)',
borderColor: 'rgba(255, 99, 132, 1)',
borderWidth: 1
}
]
};
};
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top' as const
},
title: {
display: true,
text: 'Budget prévu vs Dépenses réelles'
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value: any) {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0
}).format(value);
}
}
}
}
};
// Templates pour le DataTable
const categorieTemplate = (rowData: DepenseReelle) => {
const category = categories.find(cat => cat.value === rowData.categorie);
const severityMap = {
'MATERIEL': 'info',
'MAIN_OEUVRE': 'success',
'SOUS_TRAITANCE': 'warning',
'TRANSPORT': 'help',
'AUTRES': 'secondary'
} as const;
return <Tag value={category?.label} severity={severityMap[rowData.categorie]} />;
};
const montantTemplate = (rowData: DepenseReelle) => {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(rowData.montant);
};
const validationTemplate = (rowData: DepenseReelle) => {
return rowData.valide ? (
<Tag value="Validé" severity="success" icon="pi pi-check" />
) : (
<Tag value="En attente" severity="warning" icon="pi pi-clock" />
);
};
const actionsTemplate = (rowData: DepenseReelle) => {
return (
<div className="flex gap-1">
{!rowData.valide && (
<Button
icon="pi pi-check"
className="p-button-success p-button-text p-button-sm"
tooltip="Valider"
onClick={() => validerDepense(rowData.id!)}
/>
)}
<Button
icon="pi pi-trash"
className="p-button-danger p-button-text p-button-sm"
tooltip="Supprimer"
onClick={() => supprimerDepense(rowData.id!)}
/>
</div>
);
};
const analysesEcarts = getAnalyseEcarts();
const totalBudgetPrevu = analysesEcarts.reduce((sum, a) => sum + a.budgetPrevu, 0);
const totalDepenseReelle = analysesEcarts.reduce((sum, a) => sum + a.depenseReelle, 0);
const ecartTotal = totalDepenseReelle - totalBudgetPrevu;
const ecartTotalPourcentage = totalBudgetPrevu > 0 ? (ecartTotal / totalBudgetPrevu) * 100 : 0;
const dialogFooter = (
<div className="flex justify-content-between">
<Button
label="Fermer"
icon="pi pi-times"
onClick={onHide}
className="p-button-text"
/>
<Button
label="Enregistrer l'exécution"
icon="pi pi-save"
onClick={() => {
const executionData = {
depenses: depenses.filter(d => d.valide),
analyse: analysesEcarts,
coutTotal: totalDepenseReelle,
ecartTotal,
ecartPourcentage: ecartTotalPourcentage,
dateAnalyse: new Date().toISOString()
};
onSave(executionData);
onHide();
}}
/>
</div>
);
return (
<>
<Toast ref={toast} />
<Dialog
header={`Exécution budgétaire - ${phase?.nom || 'Phase'}`}
visible={visible}
onHide={onHide}
footer={dialogFooter}
style={{ width: '95vw', maxWidth: '1200px' }}
modal
maximizable
>
<TabView activeIndex={activeIndex} onTabChange={(e) => setActiveIndex(e.index)}>
<TabPanel header="Saisie des dépenses" leftIcon="pi pi-plus">
<div className="grid">
{/* Formulaire d'ajout de dépense */}
<div className="col-12">
<Card title="Ajouter une dépense" className="mb-4">
<div className="grid">
<div className="col-12 md:col-2">
<label htmlFor="dateDepense" className="font-semibold">Date</label>
<Calendar
id="dateDepense"
value={nouvelleDepense.date ? new Date(nouvelleDepense.date) : null}
onChange={(e) => setNouvelleDepense({
...nouvelleDepense,
date: e.value ? e.value.toISOString().split('T')[0] : ''
})}
className="w-full"
dateFormat="dd/mm/yy"
/>
</div>
<div className="col-12 md:col-2">
<label htmlFor="categorieDepense" className="font-semibold">Catégorie</label>
<Dropdown
id="categorieDepense"
value={nouvelleDepense.categorie}
options={categories}
onChange={(e) => setNouvelleDepense({...nouvelleDepense, categorie: e.value})}
className="w-full"
/>
</div>
<div className="col-12 md:col-4">
<label htmlFor="designationDepense" className="font-semibold">Désignation</label>
<InputText
id="designationDepense"
value={nouvelleDepense.designation}
onChange={(e) => setNouvelleDepense({...nouvelleDepense, designation: e.target.value})}
className="w-full"
placeholder="Description de la dépense"
/>
</div>
<div className="col-12 md:col-2">
<label htmlFor="montantDepense" className="font-semibold">Montant ()</label>
<InputNumber
id="montantDepense"
value={nouvelleDepense.montant}
onValueChange={(e) => setNouvelleDepense({...nouvelleDepense, montant: e.value || 0})}
className="w-full"
mode="currency"
currency="EUR"
locale="fr-FR"
/>
</div>
<div className="col-12 md:col-2">
<label className="font-semibold">&nbsp;</label>
<Button
icon="pi pi-plus"
onClick={ajouterDepense}
className="w-full"
tooltip="Ajouter cette dépense"
/>
</div>
</div>
<div className="grid mt-3">
<div className="col-12 md:col-4">
<label htmlFor="fournisseurDepense" className="font-semibold">Fournisseur (optionnel)</label>
<InputText
id="fournisseurDepense"
value={nouvelleDepense.fournisseur || ''}
onChange={(e) => setNouvelleDepense({...nouvelleDepense, fournisseur: e.target.value})}
className="w-full"
placeholder="Nom du fournisseur"
/>
</div>
<div className="col-12 md:col-3">
<label htmlFor="numeroPiece" className="font-semibold">N° pièce</label>
<InputText
id="numeroPiece"
value={nouvelleDepense.numeroPiece || ''}
onChange={(e) => setNouvelleDepense({...nouvelleDepense, numeroPiece: e.target.value})}
className="w-full"
placeholder="N° facture, bon..."
/>
</div>
<div className="col-12 md:col-5">
<label htmlFor="notesDepense" className="font-semibold">Notes</label>
<InputText
id="notesDepense"
value={nouvelleDepense.notes || ''}
onChange={(e) => setNouvelleDepense({...nouvelleDepense, notes: e.target.value})}
className="w-full"
placeholder="Notes supplémentaires"
/>
</div>
</div>
</Card>
</div>
{/* Liste des dépenses */}
<div className="col-12">
<DataTable
value={depenses}
emptyMessage="Aucune dépense enregistrée"
size="small"
header="Dépenses enregistrées"
>
<Column field="date" header="Date" style={{ width: '8rem' }} />
<Column field="categorie" header="Catégorie" body={categorieTemplate} style={{ width: '10rem' }} />
<Column field="designation" header="Désignation" style={{ minWidth: '15rem' }} />
<Column field="montant" header="Montant" body={montantTemplate} style={{ width: '8rem' }} />
<Column field="fournisseur" header="Fournisseur" style={{ width: '10rem' }} />
<Column field="numeroPiece" header="N° pièce" style={{ width: '8rem' }} />
<Column field="valide" header="Statut" body={validationTemplate} style={{ width: '8rem' }} />
<Column header="Actions" body={actionsTemplate} style={{ width: '8rem' }} />
</DataTable>
</div>
</div>
</TabPanel>
<TabPanel header="Analyse des écarts" leftIcon="pi pi-chart-line">
<div className="grid">
<div className="col-12 lg:col-8">
<Card title="Comparaison budget/réalisé">
<div style={{ height: '300px' }}>
<Chart type="bar" data={getChartData()} options={chartOptions} />
</div>
</Card>
</div>
<div className="col-12 lg:col-4">
<Card title="Synthèse globale">
<div className="flex justify-content-between align-items-center mb-3">
<span>Budget total prévu:</span>
<span className="font-semibold">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(totalBudgetPrevu)}
</span>
</div>
<div className="flex justify-content-between align-items-center mb-3">
<span>Dépenses réelles:</span>
<span className="font-semibold">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(totalDepenseReelle)}
</span>
</div>
<Divider />
<div className="flex justify-content-between align-items-center mb-2">
<span>Écart total:</span>
<span className={`font-bold ${ecartTotal > 0 ? 'text-red-500' : 'text-green-500'}`}>
{ecartTotal > 0 ? '+' : ''}{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(ecartTotal)}
</span>
</div>
<div className="flex justify-content-between align-items-center mb-3">
<span>Écart (%):</span>
<span className={`font-bold ${ecartTotalPourcentage > 0 ? 'text-red-500' : 'text-green-500'}`}>
{ecartTotalPourcentage > 0 ? '+' : ''}{ecartTotalPourcentage.toFixed(1)}%
</span>
</div>
<ProgressBar
value={totalBudgetPrevu > 0 ? (totalDepenseReelle / totalBudgetPrevu) * 100 : 0}
className="mb-2"
color={ecartTotalPourcentage > 10 ? '#dc3545' : ecartTotalPourcentage > 5 ? '#ffc107' : '#22c55e'}
/>
<small className="text-color-secondary">
Taux de consommation budgétaire
</small>
</Card>
</div>
{/* Détail par catégorie */}
<div className="col-12">
<Card title="Analyse détaillée par catégorie">
<DataTable value={analysesEcarts} size="small">
<Column field="categorieNom" header="Catégorie" />
<Column
field="budgetPrevu"
header="Budget prévu"
body={(rowData) => new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(rowData.budgetPrevu)}
/>
<Column
field="depenseReelle"
header="Dépense réelle"
body={(rowData) => new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(rowData.depenseReelle)}
/>
<Column
field="ecart"
header="Écart"
body={(rowData) => (
<span className={rowData.ecart > 0 ? 'text-red-500 font-semibold' : 'text-green-500'}>
{rowData.ecart > 0 ? '+' : ''}{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(rowData.ecart)}
</span>
)}
/>
<Column
field="ecartPourcentage"
header="Écart %"
body={(rowData) => (
<span className={rowData.ecartPourcentage > 10 ? 'text-red-500 font-semibold' : rowData.ecartPourcentage > 5 ? 'text-orange-500' : 'text-green-500'}>
{rowData.ecartPourcentage > 0 ? '+' : ''}{rowData.ecartPourcentage.toFixed(1)}%
</span>
)}
/>
<Column
field="statut"
header="Statut"
body={(rowData) => {
const severityMap = {
'CONFORME': 'success',
'ALERTE': 'warning',
'DEPASSEMENT': 'danger'
} as const;
return <Tag value={rowData.statut} severity={severityMap[rowData.statut]} />;
}}
/>
</DataTable>
</Card>
</div>
</div>
</TabPanel>
</TabView>
</Dialog>
</>
);
};
export default BudgetExecutionDialog;

View File

@@ -0,0 +1,528 @@
/**
* Dialog de planification budgétaire avancée pour les phases
* Permet l'estimation détaillée des coûts par catégorie
*/
import React, { useState, useRef } from 'react';
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
import { InputNumber } from 'primereact/inputnumber';
import { InputText } from 'primereact/inputtext';
import { Dropdown } from 'primereact/dropdown';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Card } from 'primereact/card';
import { TabView, TabPanel } from 'primereact/tabview';
import { Toast } from 'primereact/toast';
import { Divider } from 'primereact/divider';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { PhaseChantier } from '../../types/btp-extended';
interface BudgetItem {
id?: string;
categorie: 'MATERIEL' | 'MAIN_OEUVRE' | 'SOUS_TRAITANCE' | 'TRANSPORT' | 'AUTRES';
designation: string;
quantite: number;
unite: string;
prixUnitaire: number;
montantHT: number;
tauxTVA: number;
montantTTC: number;
fournisseur?: string;
notes?: string;
}
interface BudgetAnalysis {
totalMateriel: number;
totalMainOeuvre: number;
totalSousTraitance: number;
totalTransport: number;
totalAutres: number;
totalHT: number;
totalTVA: number;
totalTTC: number;
margeObjectif: number;
tauxMarge: number;
prixVenteCalcule: number;
}
interface BudgetPlanningDialogProps {
visible: boolean;
onHide: () => void;
phase: PhaseChantier | null;
onSave: (budgetData: BudgetAnalysis) => void;
}
export const BudgetPlanningDialog: React.FC<BudgetPlanningDialogProps> = ({
visible,
onHide,
phase,
onSave
}) => {
const toast = useRef<Toast>(null);
const [activeIndex, setActiveIndex] = useState(0);
const [budgetItems, setBudgetItems] = useState<BudgetItem[]>([]);
const [newItem, setNewItem] = useState<BudgetItem>({
categorie: 'MATERIEL',
designation: '',
quantite: 1,
unite: 'unité',
prixUnitaire: 0,
montantHT: 0,
tauxTVA: 20,
montantTTC: 0
});
const [margeObjectif, setMargeObjectif] = useState(15); // 15% par défaut
const categories = [
{ label: 'Matériel', value: 'MATERIEL' },
{ label: 'Main d\'œuvre', value: 'MAIN_OEUVRE' },
{ label: 'Sous-traitance', value: 'SOUS_TRAITANCE' },
{ label: 'Transport', value: 'TRANSPORT' },
{ label: 'Autres', value: 'AUTRES' }
];
const unites = [
{ label: 'Unité', value: 'unité' },
{ label: 'Heure', value: 'h' },
{ label: 'Jour', value: 'j' },
{ label: 'Mètre', value: 'm' },
{ label: 'Mètre carré', value: 'm²' },
{ label: 'Mètre cube', value: 'm³' },
{ label: 'Kilogramme', value: 'kg' },
{ label: 'Tonne', value: 't' },
{ label: 'Forfait', value: 'forfait' }
];
// Calculer automatiquement les montants
const calculateAmounts = (item: BudgetItem) => {
const montantHT = item.quantite * item.prixUnitaire;
const montantTVA = montantHT * (item.tauxTVA / 100);
const montantTTC = montantHT + montantTVA;
return {
...item,
montantHT,
montantTTC
};
};
// Ajouter un nouvel élément au budget
const addBudgetItem = () => {
if (!newItem.designation.trim()) {
toast.current?.show({
severity: 'warn',
summary: 'Champ requis',
detail: 'Veuillez saisir une désignation',
life: 3000
});
return;
}
const calculatedItem = calculateAmounts({
...newItem,
id: `budget_${Date.now()}`
});
setBudgetItems([...budgetItems, calculatedItem]);
setNewItem({
categorie: 'MATERIEL',
designation: '',
quantite: 1,
unite: 'unité',
prixUnitaire: 0,
montantHT: 0,
tauxTVA: 20,
montantTTC: 0
});
};
// Supprimer un élément du budget
const removeBudgetItem = (itemId: string) => {
setBudgetItems(budgetItems.filter(item => item.id !== itemId));
};
// Calculer l'analyse budgétaire
const getBudgetAnalysis = (): BudgetAnalysis => {
const totalMateriel = budgetItems
.filter(item => item.categorie === 'MATERIEL')
.reduce((sum, item) => sum + item.montantHT, 0);
const totalMainOeuvre = budgetItems
.filter(item => item.categorie === 'MAIN_OEUVRE')
.reduce((sum, item) => sum + item.montantHT, 0);
const totalSousTraitance = budgetItems
.filter(item => item.categorie === 'SOUS_TRAITANCE')
.reduce((sum, item) => sum + item.montantHT, 0);
const totalTransport = budgetItems
.filter(item => item.categorie === 'TRANSPORT')
.reduce((sum, item) => sum + item.montantHT, 0);
const totalAutres = budgetItems
.filter(item => item.categorie === 'AUTRES')
.reduce((sum, item) => sum + item.montantHT, 0);
const totalHT = totalMateriel + totalMainOeuvre + totalSousTraitance + totalTransport + totalAutres;
const totalTVA = budgetItems.reduce((sum, item) => sum + (item.montantHT * item.tauxTVA / 100), 0);
const totalTTC = totalHT + totalTVA;
const montantMarge = totalHT * (margeObjectif / 100);
const prixVenteCalcule = totalHT + montantMarge;
return {
totalMateriel,
totalMainOeuvre,
totalSousTraitance,
totalTransport,
totalAutres,
totalHT,
totalTVA,
totalTTC,
margeObjectif: montantMarge,
tauxMarge: margeObjectif,
prixVenteCalcule
};
};
// Template pour afficher la catégorie
const categorieTemplate = (rowData: BudgetItem) => {
const category = categories.find(cat => cat.value === rowData.categorie);
const severityMap = {
'MATERIEL': 'info',
'MAIN_OEUVRE': 'success',
'SOUS_TRAITANCE': 'warning',
'TRANSPORT': 'help',
'AUTRES': 'secondary'
} as const;
return <Tag value={category?.label} severity={severityMap[rowData.categorie]} />;
};
// Template pour afficher les montants
const montantTemplate = (rowData: BudgetItem, field: keyof BudgetItem) => {
const value = rowData[field] as number;
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(value);
};
// Footer du dialog
const dialogFooter = (
<div className="flex justify-content-between">
<Button
label="Annuler"
icon="pi pi-times"
onClick={onHide}
className="p-button-text"
/>
<div className="flex gap-2">
<Button
label="Réinitialiser"
icon="pi pi-refresh"
onClick={() => setBudgetItems([])}
className="p-button-outlined"
/>
<Button
label="Enregistrer le budget"
icon="pi pi-check"
onClick={() => {
const analysis = getBudgetAnalysis();
onSave(analysis);
onHide();
}}
disabled={budgetItems.length === 0}
/>
</div>
</div>
);
const analysis = getBudgetAnalysis();
return (
<>
<Toast ref={toast} />
<Dialog
header={`Planification budgétaire - ${phase?.nom || 'Phase'}`}
visible={visible}
onHide={onHide}
footer={dialogFooter}
style={{ width: '95vw', maxWidth: '1200px' }}
modal
maximizable
>
<TabView activeIndex={activeIndex} onTabChange={(e) => setActiveIndex(e.index)}>
<TabPanel header="Saisie des coûts" leftIcon="pi pi-plus">
<div className="grid">
{/* Formulaire d'ajout */}
<div className="col-12">
<Card title="Ajouter un élément de coût" className="mb-4">
<div className="grid">
<div className="col-12 md:col-3">
<label htmlFor="categorie" className="font-semibold">Catégorie</label>
<Dropdown
id="categorie"
value={newItem.categorie}
options={categories}
onChange={(e) => setNewItem({...newItem, categorie: e.value})}
className="w-full"
/>
</div>
<div className="col-12 md:col-4">
<label htmlFor="designation" className="font-semibold">Désignation</label>
<InputText
id="designation"
value={newItem.designation}
onChange={(e) => setNewItem({...newItem, designation: e.target.value})}
className="w-full"
placeholder="Ex: Béton C25/30"
/>
</div>
<div className="col-12 md:col-2">
<label htmlFor="quantite" className="font-semibold">Quantité</label>
<InputNumber
id="quantite"
value={newItem.quantite}
onValueChange={(e) => setNewItem({...newItem, quantite: e.value || 1})}
className="w-full"
min={0.01}
step={0.01}
/>
</div>
<div className="col-12 md:col-2">
<label htmlFor="unite" className="font-semibold">Unité</label>
<Dropdown
id="unite"
value={newItem.unite}
options={unites}
onChange={(e) => setNewItem({...newItem, unite: e.value})}
className="w-full"
/>
</div>
<div className="col-12 md:col-1">
<label className="font-semibold">&nbsp;</label>
<Button
icon="pi pi-plus"
onClick={addBudgetItem}
className="w-full"
tooltip="Ajouter cet élément"
/>
</div>
</div>
<div className="grid mt-3">
<div className="col-12 md:col-3">
<label htmlFor="prixUnitaire" className="font-semibold">Prix unitaire HT ()</label>
<InputNumber
id="prixUnitaire"
value={newItem.prixUnitaire}
onValueChange={(e) => setNewItem({...newItem, prixUnitaire: e.value || 0})}
className="w-full"
mode="currency"
currency="EUR"
locale="fr-FR"
/>
</div>
<div className="col-12 md:col-2">
<label htmlFor="tauxTVA" className="font-semibold">TVA (%)</label>
<InputNumber
id="tauxTVA"
value={newItem.tauxTVA}
onValueChange={(e) => setNewItem({...newItem, tauxTVA: e.value || 20})}
className="w-full"
suffix="%"
min={0}
max={100}
/>
</div>
<div className="col-12 md:col-3">
<label htmlFor="fournisseur" className="font-semibold">Fournisseur (optionnel)</label>
<InputText
id="fournisseur"
value={newItem.fournisseur || ''}
onChange={(e) => setNewItem({...newItem, fournisseur: e.target.value})}
className="w-full"
placeholder="Nom du fournisseur"
/>
</div>
<div className="col-12 md:col-4">
<label htmlFor="notes" className="font-semibold">Notes (optionnel)</label>
<InputText
id="notes"
value={newItem.notes || ''}
onChange={(e) => setNewItem({...newItem, notes: e.target.value})}
className="w-full"
placeholder="Notes supplémentaires"
/>
</div>
</div>
</Card>
</div>
{/* Liste des éléments */}
<div className="col-12">
<DataTable
value={budgetItems}
emptyMessage="Aucun élément de coût ajouté"
size="small"
header="Éléments du budget"
>
<Column field="categorie" header="Catégorie" body={categorieTemplate} style={{ width: '10rem' }} />
<Column field="designation" header="Désignation" style={{ minWidth: '15rem' }} />
<Column field="quantite" header="Qté" style={{ width: '6rem' }} />
<Column field="unite" header="Unité" style={{ width: '6rem' }} />
<Column
field="prixUnitaire"
header="Prix unit. HT"
body={(rowData) => montantTemplate(rowData, 'prixUnitaire')}
style={{ width: '8rem' }}
/>
<Column
field="montantHT"
header="Montant HT"
body={(rowData) => montantTemplate(rowData, 'montantHT')}
style={{ width: '8rem' }}
/>
<Column
field="montantTTC"
header="Montant TTC"
body={(rowData) => montantTemplate(rowData, 'montantTTC')}
style={{ width: '8rem' }}
/>
<Column
header="Actions"
style={{ width: '6rem' }}
body={(rowData) => (
<Button
icon="pi pi-trash"
className="p-button-text p-button-danger"
onClick={() => removeBudgetItem(rowData.id)}
tooltip="Supprimer"
/>
)}
/>
</DataTable>
</div>
</div>
</TabPanel>
<TabPanel header="Analyse budgétaire" leftIcon="pi pi-chart-bar">
<div className="grid">
<div className="col-12 lg:col-8">
<Card title="Répartition des coûts">
<div className="grid">
<div className="col-6 md:col-3">
<div className="text-center">
<h6 className="m-0 text-color-secondary">Matériel</h6>
<span className="text-xl font-semibold text-primary">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(analysis.totalMateriel)}
</span>
<ProgressBar
value={analysis.totalHT > 0 ? (analysis.totalMateriel / analysis.totalHT) * 100 : 0}
className="mt-2"
color="#007ad9"
/>
</div>
</div>
<div className="col-6 md:col-3">
<div className="text-center">
<h6 className="m-0 text-color-secondary">Main d'œuvre</h6>
<span className="text-xl font-semibold text-green-500">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(analysis.totalMainOeuvre)}
</span>
<ProgressBar
value={analysis.totalHT > 0 ? (analysis.totalMainOeuvre / analysis.totalHT) * 100 : 0}
className="mt-2"
color="#22c55e"
/>
</div>
</div>
<div className="col-6 md:col-3">
<div className="text-center">
<h6 className="m-0 text-color-secondary">Sous-traitance</h6>
<span className="text-xl font-semibold text-orange-500">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(analysis.totalSousTraitance)}
</span>
<ProgressBar
value={analysis.totalHT > 0 ? (analysis.totalSousTraitance / analysis.totalHT) * 100 : 0}
className="mt-2"
color="#f97316"
/>
</div>
</div>
<div className="col-6 md:col-3">
<div className="text-center">
<h6 className="m-0 text-color-secondary">Transport + Autres</h6>
<span className="text-xl font-semibold text-purple-500">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(analysis.totalTransport + analysis.totalAutres)}
</span>
<ProgressBar
value={analysis.totalHT > 0 ? ((analysis.totalTransport + analysis.totalAutres) / analysis.totalHT) * 100 : 0}
className="mt-2"
color="#8b5cf6"
/>
</div>
</div>
</div>
</Card>
</div>
<div className="col-12 lg:col-4">
<Card title="Calcul de la marge">
<div className="field">
<label htmlFor="margeObjectif" className="font-semibold">Marge objectif (%)</label>
<InputNumber
id="margeObjectif"
value={margeObjectif}
onValueChange={(e) => setMargeObjectif(e.value || 15)}
className="w-full"
suffix="%"
min={0}
max={100}
/>
</div>
<Divider />
<div className="flex justify-content-between align-items-center mb-2">
<span>Total HT:</span>
<span className="font-semibold">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(analysis.totalHT)}
</span>
</div>
<div className="flex justify-content-between align-items-center mb-2">
<span>TVA:</span>
<span className="font-semibold">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(analysis.totalTVA)}
</span>
</div>
<div className="flex justify-content-between align-items-center mb-2">
<span>Marge ({margeObjectif}%):</span>
<span className="font-semibold text-green-500">
+{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(analysis.margeObjectif)}
</span>
</div>
<Divider />
<div className="flex justify-content-between align-items-center">
<span className="text-lg font-bold">Prix de vente calculé:</span>
<span className="text-xl font-bold text-primary">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(analysis.prixVenteCalcule)}
</span>
</div>
</Card>
</div>
</div>
</TabPanel>
</TabView>
</Dialog>
</>
);
};
export default BudgetPlanningDialog;

View File

@@ -0,0 +1,570 @@
'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;

View File

@@ -0,0 +1,364 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Panel } from 'primereact/panel';
import { Message } from 'primereact/message';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { Badge } from 'primereact/badge';
import { Timeline } from 'primereact/timeline';
import { Accordion, AccordionTab } from 'primereact/accordion';
import { Tooltip } from 'primereact/tooltip';
import phaseValidationService, {
type PhaseValidationResult,
type ValidationError,
type ValidationWarning
} from '../../services/phaseValidationService';
import type { PhaseChantier } from '../../types/btp';
interface PhaseValidationPanelProps {
phase: PhaseChantier;
allPhases: PhaseChantier[];
onStartPhase?: (phaseId: string) => void;
onViewPrerequisite?: (prerequisiteId: string) => void;
className?: string;
compact?: boolean;
}
const PhaseValidationPanel: React.FC<PhaseValidationPanelProps> = ({
phase,
allPhases,
onStartPhase,
onViewPrerequisite,
className = '',
compact = false
}) => {
const [validation, setValidation] = useState<PhaseValidationResult | null>(null);
const [loading, setLoading] = useState(true);
const [expandedSections, setExpandedSections] = useState<string[]>(['errors']);
useEffect(() => {
validatePhase();
}, [phase, allPhases]);
const validatePhase = async () => {
setLoading(true);
try {
const result = phaseValidationService.validatePhaseStart(phase, allPhases, {
strictMode: false
});
setValidation(result);
} catch (error) {
console.error('Erreur lors de la validation de la phase:', error);
} finally {
setLoading(false);
}
};
const getSeverityIcon = (severity: string) => {
switch (severity) {
case 'error': return 'pi pi-times-circle';
case 'warning': return 'pi pi-exclamation-triangle';
case 'info': return 'pi pi-info-circle';
default: return 'pi pi-circle';
}
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'error': return 'danger';
case 'warning': return 'warning';
case 'info': return 'info';
default: return 'secondary';
}
};
const renderValidationStatus = () => {
if (!validation) return null;
const statusColor = validation.readyToStart ? 'success' :
validation.canStart ? 'warning' : 'danger';
const statusIcon = validation.readyToStart ? 'pi pi-check-circle' :
validation.canStart ? 'pi pi-exclamation-triangle' : 'pi pi-times-circle';
const statusText = validation.readyToStart ? 'Prête à démarrer' :
validation.canStart ? 'Peut démarrer avec précautions' : 'Ne peut pas démarrer';
return (
<div className="flex align-items-center gap-2 mb-3">
<i className={`${statusIcon} text-${statusColor === 'danger' ? 'red' : statusColor === 'warning' ? 'yellow' : 'green'}-500 text-lg`}></i>
<span className="font-semibold">{statusText}</span>
{validation.errors.length > 0 && (
<Badge value={validation.errors.length} severity="danger" />
)}
{validation.warnings.length > 0 && (
<Badge value={validation.warnings.length} severity="warning" />
)}
</div>
);
};
const renderErrorsAndWarnings = () => {
if (!validation || (validation.errors.length === 0 && validation.warnings.length === 0)) {
return (
<Message
severity="success"
text="Aucun problème détecté"
className="w-full"
/>
);
}
return (
<div className="flex flex-column gap-2">
{validation.errors.map((error, index) => (
<Message
key={`error-${index}`}
severity={getSeverityColor(error.severity) as any}
className="w-full"
>
<div className="flex align-items-center justify-content-between w-full">
<span>{error.message}</span>
{error.phaseId && onViewPrerequisite && (
<Button
icon="pi pi-external-link"
className="p-button-text p-button-sm"
onClick={() => onViewPrerequisite(error.phaseId!)}
tooltip="Voir la phase"
/>
)}
</div>
</Message>
))}
{validation.warnings.map((warning, index) => (
<Message
key={`warning-${index}`}
severity="warn"
className="w-full"
>
<div>
<div>{warning.message}</div>
{warning.recommendation && (
<small className="text-600 mt-1 block">
💡 {warning.recommendation}
</small>
)}
</div>
</Message>
))}
</div>
);
};
const renderPrerequisites = () => {
if (!phase.prerequis || phase.prerequis.length === 0) {
return (
<Message
severity="info"
text="Aucun prérequis défini"
className="w-full"
/>
);
}
const prerequisitePhases = phase.prerequis
.map(prereqId => allPhases.find(p => p.id === prereqId))
.filter(Boolean) as PhaseChantier[];
if (prerequisitePhases.length === 0) {
return (
<Message
severity="warn"
text="Prérequis non trouvés dans le projet"
className="w-full"
/>
);
}
return (
<div className="flex flex-column gap-2">
{prerequisitePhases.map(prereq => {
const isCompleted = prereq.statut === 'TERMINEE';
const isInProgress = prereq.statut === 'EN_COURS';
return (
<div
key={prereq.id}
className="flex align-items-center justify-content-between p-3 border-1 surface-border border-round"
>
<div className="flex align-items-center gap-2">
<i className={`pi ${isCompleted ? 'pi-check-circle text-green-500' :
isInProgress ? 'pi-clock text-blue-500' :
'pi-circle text-gray-400'}`}></i>
<span className={isCompleted ? 'text-green-700' : isInProgress ? 'text-blue-700' : 'text-600'}>
{prereq.nom}
</span>
<Tag
value={prereq.statut}
severity={isCompleted ? 'success' : isInProgress ? 'info' : 'secondary'}
className="text-xs"
/>
{prereq.critique && (
<Tag value="Critique" severity="danger" className="text-xs" />
)}
</div>
<div className="flex align-items-center gap-2">
{prereq.dateFinReelle && (
<small className="text-600">
Terminé le {new Date(prereq.dateFinReelle).toLocaleDateString('fr-FR')}
</small>
)}
{prereq.dateFinPrevue && !prereq.dateFinReelle && (
<small className="text-600">
Prévu le {new Date(prereq.dateFinPrevue).toLocaleDateString('fr-FR')}
</small>
)}
{onViewPrerequisite && (
<Button
icon="pi pi-eye"
className="p-button-text p-button-sm"
onClick={() => onViewPrerequisite(prereq.id!)}
tooltip="Voir les détails"
/>
)}
</div>
</div>
);
})}
</div>
);
};
const renderBlockingPhases = () => {
if (!validation || validation.blockedBy.length === 0) {
return null;
}
return (
<Message severity="error" className="w-full">
<div>
<div className="font-semibold mb-2">Phase bloquée par :</div>
<div className="flex flex-wrap gap-1">
{validation.blockedBy.map((blocker, index) => (
<Tag key={index} value={blocker} severity="danger" />
))}
</div>
</div>
</Message>
);
};
const renderActionButtons = () => {
if (!validation || !onStartPhase) return null;
return (
<div className="flex gap-2 pt-3 border-top-1 surface-border">
<Button
label="Démarrer la phase"
icon="pi pi-play"
className="p-button-success"
disabled={!validation.canStart}
onClick={() => onStartPhase(phase.id!)}
/>
<Button
label="Revalider"
icon="pi pi-refresh"
className="p-button-outlined"
onClick={validatePhase}
/>
{validation.warnings.length > 0 && (
<Button
label="Ignorer les avertissements"
icon="pi pi-exclamation-triangle"
className="p-button-warning p-button-outlined"
disabled={validation.errors.length > 0}
onClick={() => onStartPhase && onStartPhase(phase.id!)}
/>
)}
</div>
);
};
if (loading) {
return (
<Panel header="Validation des prérequis" className={className}>
<div className="text-center p-4">
<i className="pi pi-spinner pi-spin text-2xl text-primary"></i>
<div className="mt-2">Validation en cours...</div>
</div>
</Panel>
);
}
if (compact) {
return (
<div className={`surface-card p-3 border-round ${className}`}>
{renderValidationStatus()}
{validation && validation.blockedBy.length > 0 && (
<div className="text-sm text-red-600">
Bloquée par : {validation.blockedBy.join(', ')}
</div>
)}
</div>
);
}
return (
<Panel
header={
<div className="flex align-items-center gap-2">
<span>Validation des prérequis</span>
{validation && !validation.readyToStart && (
<i className="pi pi-exclamation-triangle text-yellow-500"></i>
)}
</div>
}
className={className}
toggleable
>
{renderValidationStatus()}
{renderBlockingPhases()}
<Accordion
multiple
activeIndex={expandedSections}
onTabChange={(e) => setExpandedSections(e.index as string[])}
>
<AccordionTab
header={
<div className="flex align-items-center gap-2">
<span>Erreurs et avertissements</span>
{validation && (validation.errors.length > 0 || validation.warnings.length > 0) && (
<div className="flex gap-1">
{validation.errors.length > 0 && (
<Badge value={validation.errors.length} severity="danger" />
)}
{validation.warnings.length > 0 && (
<Badge value={validation.warnings.length} severity="warning" />
)}
</div>
)}
</div>
}
>
{renderErrorsAndWarnings()}
</AccordionTab>
<AccordionTab header="Prérequis">
{renderPrerequisites()}
</AccordionTab>
</Accordion>
{renderActionButtons()}
<Tooltip target=".validation-tooltip" />
</Panel>
);
};
export default PhaseValidationPanel;

View File

@@ -0,0 +1,192 @@
'use client';
import React from 'react';
import { Card } from 'primereact/card';
import { Badge } from 'primereact/badge';
import { Tag } from 'primereact/tag';
import { Button } from 'primereact/button';
import { ProgressBar } from 'primereact/progressbar';
import type { PhaseChantier } from '../../types/btp';
interface PhasesQuickPreviewProps {
phases: PhaseChantier[];
className?: string;
onViewDetails?: () => void;
}
const PhasesQuickPreview: React.FC<PhasesQuickPreviewProps> = ({
phases,
className = '',
onViewDetails
}) => {
const getPhaseStats = () => {
const total = phases.length;
const principales = phases.filter(p => !p.phaseParent).length;
const sousPhases = phases.filter(p => p.phaseParent).length;
const enCours = phases.filter(p => p.statut === 'EN_COURS').length;
const terminees = phases.filter(p => p.statut === 'TERMINEE').length;
const critiques = phases.filter(p => p.critique).length;
const enRetard = phases.filter(p => {
if (p.statut === 'TERMINEE') return false;
const maintenant = new Date();
const dateFinPrevue = p.dateFinPrevue ? new Date(p.dateFinPrevue) : null;
return dateFinPrevue ? dateFinPrevue < maintenant : false;
}).length;
const avancementMoyen = total > 0 ?
Math.round(phases.reduce((acc, p) => acc + (p.pourcentageAvancement || 0), 0) / total) : 0;
return { total, principales, sousPhases, enCours, terminees, critiques, enRetard, avancementMoyen };
};
const stats = getPhaseStats();
const getProgressColor = (percentage: number) => {
if (percentage >= 80) return 'success';
if (percentage >= 50) return 'info';
if (percentage >= 25) return 'warning';
return 'danger';
};
const getNextPhases = () => {
return phases
.filter(p => p.statut === 'PLANIFIEE' || p.statut === 'EN_COURS')
.sort((a, b) => (a.ordreExecution || 0) - (b.ordreExecution || 0))
.slice(0, 3);
};
const nextPhases = getNextPhases();
return (
<Card title="Aperçu des phases" className={className}>
{/* Statistiques rapides */}
<div className="grid mb-4">
<div className="col-6 md:col-3">
<div className="text-center p-2 border-1 surface-border border-round">
<div className="text-xl font-bold text-primary">{stats.total}</div>
<div className="text-600 text-sm">Total phases</div>
</div>
</div>
<div className="col-6 md:col-3">
<div className="text-center p-2 border-1 surface-border border-round">
<div className="text-xl font-bold text-green-500">{stats.terminees}</div>
<div className="text-600 text-sm">Terminées</div>
</div>
</div>
<div className="col-6 md:col-3">
<div className="text-center p-2 border-1 surface-border border-round">
<div className="text-xl font-bold text-blue-500">{stats.enCours}</div>
<div className="text-600 text-sm">En cours</div>
</div>
</div>
<div className="col-6 md:col-3">
<div className="text-center p-2 border-1 surface-border border-round">
<div className="text-xl font-bold text-red-500">{stats.critiques}</div>
<div className="text-600 text-sm">Critiques</div>
</div>
</div>
</div>
{/* Avancement global */}
<div className="mb-4">
<div className="flex justify-content-between align-items-center mb-2">
<span className="font-semibold">Avancement global</span>
<Badge value={`${stats.avancementMoyen}%`} severity={getProgressColor(stats.avancementMoyen)} />
</div>
<ProgressBar
value={stats.avancementMoyen}
color={
stats.avancementMoyen >= 80 ? '#10b981' :
stats.avancementMoyen >= 50 ? '#3b82f6' :
stats.avancementMoyen >= 25 ? '#f59e0b' : '#ef4444'
}
/>
</div>
{/* Alertes */}
{(stats.enRetard > 0 || stats.critiques > 0) && (
<div className="mb-4">
<div className="flex flex-wrap gap-2">
{stats.enRetard > 0 && (
<Tag
value={`${stats.enRetard} phase${stats.enRetard > 1 ? 's' : ''} en retard`}
severity="danger"
icon="pi pi-exclamation-triangle"
/>
)}
{stats.critiques > 0 && (
<Tag
value={`${stats.critiques} phase${stats.critiques > 1 ? 's' : ''} critique${stats.critiques > 1 ? 's' : ''}`}
severity="warning"
icon="pi pi-flag"
/>
)}
</div>
</div>
)}
{/* Prochaines phases */}
{nextPhases.length > 0 && (
<div className="mb-4">
<h4 className="text-lg font-semibold mb-3">Prochaines phases</h4>
<div className="flex flex-column gap-2">
{nextPhases.map((phase, index) => (
<div key={phase.id} className="flex align-items-center gap-3 p-2 surface-100 border-round">
<div className="flex align-items-center justify-content-center w-2rem h-2rem border-circle bg-primary text-white font-bold">
{index + 1}
</div>
<div className="flex-1">
<div className="font-semibold">{phase.nom}</div>
<div className="text-600 text-sm">
{phase.dateFinPrevue && `Prévue: ${new Date(phase.dateFinPrevue).toLocaleDateString('fr-FR')}`}
</div>
</div>
<div className="flex gap-1">
{phase.critique && (
<Tag value="Critique" severity="danger" className="text-xs" />
)}
<Tag
value={phase.statut}
severity={phase.statut === 'EN_COURS' ? 'info' : 'secondary'}
className="text-xs"
/>
</div>
</div>
))}
</div>
</div>
)}
{/* Structure hiérarchique */}
<div className="mb-4">
<div className="flex justify-content-between align-items-center">
<span className="font-semibold">Structure du projet</span>
<div className="flex gap-2">
<div className="flex align-items-center gap-1">
<div className="w-0.5rem h-0.5rem border-circle bg-primary"></div>
<span className="text-sm text-600">{stats.principales} principales</span>
</div>
<div className="flex align-items-center gap-1">
<div className="w-0.5rem h-0.5rem border-circle bg-gray-400"></div>
<span className="text-sm text-600">{stats.sousPhases} sous-phases</span>
</div>
</div>
</div>
</div>
{/* Actions */}
{onViewDetails && (
<div className="text-center">
<Button
label="Voir le détail des phases"
icon="pi pi-eye"
className="p-button-outlined"
onClick={onViewDetails}
/>
</div>
)}
</Card>
);
};
export default PhasesQuickPreview;

View File

@@ -0,0 +1,639 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { DataTable, DataTableExpandedRows } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { Badge } from 'primereact/badge';
import { Toast } from 'primereact/toast';
import { ConfirmDialog, confirmDialog } from 'primereact/confirmdialog';
import { Menu } from 'primereact/menu';
import { TabView, TabPanel } from 'primereact/tabview';
import {
ActionButtonGroup,
ViewButton,
EditButton,
DeleteButton,
StartButton,
CompleteButton,
ProgressButton,
BudgetPlanButton,
BudgetTrackButton
} from '../ui/ActionButton';
import { PhaseChantier, StatutPhase } from '../../types/btp-extended';
import phaseService from '../../services/phaseService';
import materielPhaseService from '../../services/materielPhaseService';
import fournisseurPhaseService from '../../services/fournisseurPhaseService';
export interface PhasesTableProps {
// Données
phases: PhaseChantier[];
loading?: boolean;
chantierId?: string;
// Affichage
showStats?: boolean;
showChantierColumn?: boolean;
showSubPhases?: boolean;
showBudget?: boolean;
showExpansion?: boolean;
showGlobalFilter?: boolean;
// Actions disponibles
actions?: Array<'view' | 'edit' | 'delete' | 'start' | 'complete' | 'progress' | 'budget-plan' | 'budget-track' | 'all'>;
// Callbacks
onRefresh?: () => void;
onPhaseSelect?: (phase: PhaseChantier) => void;
onPhaseEdit?: (phase: PhaseChantier) => void;
onPhaseDelete?: (phaseId: string) => void;
onPhaseStart?: (phaseId: string) => void;
onPhaseProgress?: (phase: PhaseChantier) => void;
onPhaseBudgetPlan?: (phase: PhaseChantier) => void;
onPhaseBudgetTrack?: (phase: PhaseChantier) => void;
onSubPhaseAdd?: (parentPhase: PhaseChantier) => void;
// Configuration
rows?: number;
emptyMessage?: string;
className?: string;
globalFilter?: string;
}
const PhasesTable: React.FC<PhasesTableProps> = ({
phases,
loading = false,
chantierId,
showStats = false,
showChantierColumn = false,
showSubPhases = true,
showBudget = true,
showExpansion = true,
showGlobalFilter = false,
actions = ['all'],
onRefresh,
onPhaseSelect,
onPhaseEdit,
onPhaseDelete,
onPhaseStart,
onPhaseProgress,
onPhaseBudgetPlan,
onPhaseBudgetTrack,
onSubPhaseAdd,
rows = 15,
emptyMessage = "Aucune phase trouvée",
className = "p-datatable-lg",
globalFilter = ''
}) => {
const toast = useRef<Toast>(null);
const [expandedRows, setExpandedRows] = useState<DataTableExpandedRows | undefined>(undefined);
const [materielsPhase, setMaterielsPhase] = useState<any[]>([]);
const [fournisseursPhase, setFournisseursPhase] = useState<any[]>([]);
// Déterminer quelles actions afficher
const shouldShowAction = (action: string) => {
return actions.includes('all') || actions.includes(action as any);
};
// Templates de colonnes
const statutBodyTemplate = (rowData: PhaseChantier) => {
const severityMap: Record<string, any> = {
'PLANIFIEE': 'secondary',
'EN_ATTENTE': 'warning',
'EN_COURS': 'info',
'SUSPENDUE': 'warning',
'TERMINEE': 'success',
'ANNULEE': 'danger'
};
return <Tag value={rowData.statut} severity={severityMap[rowData.statut]} />;
};
const avancementBodyTemplate = (rowData: PhaseChantier) => {
const progress = rowData.pourcentageAvancement || 0;
const color = progress === 100 ? 'var(--green-500)' : progress >= 50 ? 'var(--blue-500)' : 'var(--orange-500)';
return (
<div className="flex align-items-center gap-2">
<ProgressBar
value={progress}
style={{ width: '100px', height: '8px' }}
color={color}
showValue={false}
/>
<span className="text-sm font-semibold">{progress}%</span>
</div>
);
};
const dateBodyTemplate = (rowData: PhaseChantier, field: keyof PhaseChantier) => {
const date = rowData[field] as string;
if (!date) return <span className="text-color-secondary">-</span>;
const dateObj = new Date(date);
const isOverdue = field === 'dateFinPrevue' && dateObj < new Date() && rowData.statut !== 'TERMINEE';
return (
<span className={isOverdue ? 'text-red-500 font-semibold' : ''}>
{dateObj.toLocaleDateString('fr-FR')}
</span>
);
};
const prioriteBodyTemplate = (rowData: PhaseChantier) => {
const severityMap: Record<string, any> = {
'FAIBLE': 'secondary',
'MOYENNE': 'info',
'ELEVEE': 'warning',
'CRITIQUE': 'danger'
};
return rowData.priorite ? <Tag value={rowData.priorite} severity={severityMap[rowData.priorite]} /> : null;
};
const budgetBodyTemplate = (rowData: PhaseChantier) => {
return (
<span className="text-900 font-semibold">
{new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0
}).format(rowData.budgetPrevu || 0)}
</span>
);
};
const coutReelBodyTemplate = (rowData: PhaseChantier) => {
const cout = rowData.coutReel || 0;
const budget = rowData.budgetPrevu || 0;
const depassement = cout > budget;
return (
<span className={depassement ? 'text-red-500 font-semibold' : 'text-900'}>
{new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0
}).format(cout)}
{depassement && (
<i className="pi pi-exclamation-triangle text-red-500 ml-2" title="Dépassement budgétaire"></i>
)}
</span>
);
};
const chantierBodyTemplate = (rowData: PhaseChantier) => {
return rowData.chantier?.nom || '-';
};
const phaseNameBodyTemplate = (rowData: PhaseChantier) => {
return (
<div className="flex align-items-center gap-2">
{showSubPhases && (
<Badge
value={rowData.ordreExecution || 0}
className="bg-primary text-primary-50"
style={{ minWidth: '1.5rem' }}
/>
)}
<i className="pi pi-sitemap text-sm text-color-secondary"></i>
<div className="flex flex-column flex-1">
<span className="font-semibold text-color">
{rowData.nom}
</span>
{rowData.description && (
<small className="text-color-secondary text-xs mt-1">
{rowData.description.length > 60
? rowData.description.substring(0, 60) + '...'
: rowData.description
}
</small>
)}
</div>
{rowData.critique && (
<Badge
value="Critique"
severity="danger"
className="text-xs"
/>
)}
</div>
);
};
// Chargement du matériel et fournisseurs pour l'expansion
const loadMaterielPhase = async (phaseId: string) => {
try {
const materiels = await materielPhaseService.getByPhase(phaseId);
setMaterielsPhase(materiels);
} catch (error) {
console.error('Erreur lors du chargement du matériel:', error);
}
};
const loadFournisseursPhase = async (phaseId: string) => {
try {
const fournisseurs = await fournisseurPhaseService.getByPhase(phaseId);
setFournisseursPhase(fournisseurs);
} catch (error) {
console.error('Erreur lors du chargement des fournisseurs:', error);
}
};
// Template d'expansion
const rowExpansionTemplate = (data: PhaseChantier) => {
if (data.phaseParent || !showSubPhases) return null;
const sousPhases = phases.filter(p => p.phaseParent === data.id);
return (
<div className="p-4 bg-surface-50">
<TabView>
<TabPanel header="Sous-phases" leftIcon="pi pi-sitemap">
<div className="flex justify-content-between align-items-center mb-4">
<h6 className="m-0 text-color">
Sous-phases de "{data.nom}" ({sousPhases.length})
</h6>
{onSubPhaseAdd && (
<Button
label="Ajouter une sous-phase"
icon="pi pi-plus"
className="p-button-text p-button-rounded p-button-success p-button-sm"
onClick={() => onSubPhaseAdd(data)}
/>
)}
</div>
{sousPhases.length > 0 ? (
<DataTable
value={sousPhases}
size="small"
className="p-datatable-sm"
emptyMessage="Aucune sous-phase"
>
<Column
field="nom"
header="Sous-phase"
style={{ minWidth: '15rem' }}
body={(rowData) => (
<div className="flex align-items-center gap-2">
<Badge
value={rowData.ordreExecution || 0}
className="bg-surface-100 text-surface-700 text-xs"
style={{ minWidth: '1.2rem', fontSize: '0.7rem' }}
/>
<i className="pi pi-minus text-xs text-color-secondary"></i>
<span className="font-semibold flex-1">{rowData.nom}</span>
{rowData.critique && (
<Tag value="Critique" severity="danger" className="text-xs" />
)}
</div>
)}
/>
<Column
field="statut"
header="Statut"
style={{ width: '8rem' }}
body={statutBodyTemplate}
/>
<Column
field="pourcentageAvancement"
header="Avancement"
style={{ width: '10rem' }}
body={avancementBodyTemplate}
/>
<Column
field="dateDebutPrevue"
header="Début prévu"
style={{ width: '10rem' }}
body={(rowData) => dateBodyTemplate(rowData, 'dateDebutPrevue')}
/>
<Column
field="dateFinPrevue"
header="Fin prévue"
style={{ width: '10rem' }}
body={(rowData) => dateBodyTemplate(rowData, 'dateFinPrevue')}
/>
{showBudget && (
<>
<Column
field="budgetPrevu"
header="Budget"
style={{ width: '8rem' }}
body={budgetBodyTemplate}
/>
<Column
field="coutReel"
header="Coût réel"
style={{ width: '8rem' }}
body={coutReelBodyTemplate}
/>
</>
)}
<Column
header="Actions"
style={{ width: '10rem' }}
body={(rowData) => (
<ActionButtonGroup>
{shouldShowAction('view') && onPhaseSelect && (
<ViewButton
tooltip="Voir détails"
onClick={() => onPhaseSelect(rowData)}
/>
)}
{shouldShowAction('edit') && onPhaseEdit && (
<EditButton
tooltip="Modifier"
onClick={() => onPhaseEdit(rowData)}
/>
)}
{shouldShowAction('start') && onPhaseStart && (
<StartButton
tooltip="Démarrer"
disabled={rowData.statut !== 'PLANIFIEE'}
onClick={() => onPhaseStart(rowData.id!)}
/>
)}
{shouldShowAction('delete') && onPhaseDelete && (
<DeleteButton
tooltip="Supprimer"
onClick={() => onPhaseDelete(rowData.id!)}
/>
)}
</ActionButtonGroup>
)}
/>
</DataTable>
) : (
<div className="text-center p-4">
<i className="pi pi-info-circle text-4xl text-color-secondary mb-3"></i>
<p className="text-color-secondary m-0">
Aucune sous-phase définie pour cette phase.
</p>
</div>
)}
</TabPanel>
<TabPanel header="Matériel" leftIcon="pi pi-wrench">
<Button
label="Charger le matériel"
icon="pi pi-refresh"
onClick={() => loadMaterielPhase(data.id!)}
className="mb-3 p-button-text p-button-rounded"
/>
<DataTable
value={materielsPhase}
size="small"
emptyMessage="Aucun matériel assigné"
>
<Column field="nom" header="Matériel" />
<Column field="quantite" header="Quantité" />
<Column field="statut" header="Statut" />
</DataTable>
</TabPanel>
<TabPanel header="Fournisseurs" leftIcon="pi pi-users">
<Button
label="Charger les fournisseurs"
icon="pi pi-refresh"
onClick={() => loadFournisseursPhase(data.id!)}
className="mb-3 p-button-text p-button-rounded"
/>
<DataTable
value={fournisseursPhase}
size="small"
emptyMessage="Aucun fournisseur recommandé"
>
<Column field="nom" header="Fournisseur" />
<Column field="specialite" header="Spécialité" />
<Column field="notation" header="Note" />
</DataTable>
</TabPanel>
</TabView>
</div>
);
};
// Template des actions principales
const actionBodyTemplate = (rowData: PhaseChantier) => {
const handleDelete = () => {
confirmDialog({
message: `Êtes-vous sûr de vouloir supprimer la phase "${rowData.nom}" ?`,
header: 'Confirmer la suppression',
icon: 'pi pi-exclamation-triangle',
acceptClassName: 'p-button-danger',
acceptLabel: 'Supprimer',
rejectLabel: 'Annuler',
accept: async () => {
try {
await phaseService.delete(rowData.id!);
if (onRefresh) onRefresh();
toast.current?.show({
severity: 'success',
summary: 'Suppression réussie',
detail: 'La phase a été supprimée',
life: 3000
});
} catch (error) {
console.error('Erreur:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de supprimer la phase',
life: 5000
});
}
}
});
};
return (
<ActionButtonGroup>
{shouldShowAction('view') && onPhaseSelect && (
<ViewButton
tooltip="Voir détails"
onClick={() => onPhaseSelect(rowData)}
/>
)}
{shouldShowAction('edit') && onPhaseEdit && (
<EditButton
tooltip="Modifier"
onClick={() => onPhaseEdit(rowData)}
/>
)}
{shouldShowAction('start') && onPhaseStart && (
<StartButton
tooltip="Démarrer"
disabled={rowData.statut !== 'PLANIFIEE'}
onClick={() => onPhaseStart(rowData.id!)}
/>
)}
{shouldShowAction('progress') && onPhaseProgress && (
<ProgressButton
tooltip="Mettre à jour avancement"
onClick={() => onPhaseProgress(rowData)}
/>
)}
{shouldShowAction('budget-plan') && onPhaseBudgetPlan && (
<BudgetPlanButton
tooltip="Planifier le budget"
onClick={() => onPhaseBudgetPlan(rowData)}
/>
)}
{shouldShowAction('budget-track') && onPhaseBudgetTrack && (
<BudgetTrackButton
tooltip="Suivi des dépenses"
onClick={() => onPhaseBudgetTrack(rowData)}
/>
)}
{shouldShowAction('delete') && (onPhaseDelete || true) && (
<DeleteButton
tooltip="Supprimer"
onClick={() => onPhaseDelete ? onPhaseDelete(rowData.id!) : handleDelete()}
/>
)}
</ActionButtonGroup>
);
};
// Style des lignes
const phaseRowClassName = (rowData: PhaseChantier) => {
let className = '';
if (rowData.phaseParent) {
className += ' bg-surface-card border-left-4 border-surface-300';
} else {
className += ' bg-surface-ground border-left-4 border-primary font-semibold';
}
if (rowData.critique) {
className += ' border-red-500';
}
return className;
};
// Filtrer les phases principales seulement si subPhases est activé
const displayPhases = showSubPhases ? phases.filter(p => !p.phaseParent) : phases;
return (
<>
<Toast ref={toast} />
<ConfirmDialog />
<DataTable
value={displayPhases}
loading={loading}
paginator
rows={rows}
globalFilter={showGlobalFilter ? globalFilter : undefined}
emptyMessage={emptyMessage}
className={className}
dataKey="id"
expandedRows={showExpansion ? expandedRows : undefined}
onRowToggle={showExpansion ? (e) => setExpandedRows(e.data) : undefined}
rowExpansionTemplate={showExpansion ? rowExpansionTemplate : undefined}
rowClassName={phaseRowClassName}
>
{showExpansion && showSubPhases && <Column expander style={{ width: '3rem' }} />}
<Column
field="nom"
header="Phase"
sortable
style={{ minWidth: '20rem' }}
body={phaseNameBodyTemplate}
/>
{showChantierColumn && (
<Column
field="chantier.nom"
header="Chantier"
sortable
style={{ width: '15rem' }}
body={chantierBodyTemplate}
/>
)}
<Column
field="statut"
header="Statut"
body={statutBodyTemplate}
sortable
style={{ width: '10rem' }}
/>
<Column
field="priorite"
header="Priorité"
body={prioriteBodyTemplate}
sortable
style={{ width: '8rem' }}
/>
<Column
field="pourcentageAvancement"
header="Avancement"
body={avancementBodyTemplate}
style={{ width: '12rem' }}
/>
<Column
field="dateDebutPrevue"
header="Début prévu"
body={(rowData) => dateBodyTemplate(rowData, 'dateDebutPrevue')}
sortable
style={{ width: '10rem' }}
/>
<Column
field="dateFinPrevue"
header="Fin prévue"
body={(rowData) => dateBodyTemplate(rowData, 'dateFinPrevue')}
sortable
style={{ width: '10rem' }}
/>
<Column
field="dureeEstimeeHeures"
header="Durée (h)"
sortable
style={{ width: '8rem' }}
body={(rowData) => (
<span>{rowData.dureeEstimeeHeures || 0}h</span>
)}
/>
{showBudget && (
<>
<Column
field="budgetPrevu"
header="Budget prévu"
sortable
style={{ width: '10rem' }}
body={budgetBodyTemplate}
/>
<Column
field="coutReel"
header="Coût réel"
sortable
style={{ width: '10rem' }}
body={coutReelBodyTemplate}
/>
</>
)}
<Column
header="Actions"
style={{ width: '12rem' }}
body={actionBodyTemplate}
/>
</DataTable>
</>
);
};
export default PhasesTable;

View File

@@ -0,0 +1,386 @@
'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;

View File

@@ -0,0 +1,582 @@
'use client';
/**
* Étape 2: Personnalisation avancée avec tableau interactif
* Interface de configuration détaillée des phases et budgets
*/
import React, { useState, useRef, useEffect } from 'react';
import { Card } from 'primereact/card';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Button } from 'primereact/button';
import { InputNumber } from 'primereact/inputnumber';
import { InputText } from 'primereact/inputtext';
import { Checkbox } from 'primereact/checkbox';
import { Calendar } from 'primereact/calendar';
import { TabView, TabPanel } from 'primereact/tabview';
import { Accordion, AccordionTab } from 'primereact/accordion';
import { Tag } from 'primereact/tag';
import { Badge } from 'primereact/badge';
import { Toast } from 'primereact/toast';
import { Message } from 'primereact/message';
import { Divider } from 'primereact/divider';
import { Slider } from 'primereact/slider';
import { ToggleButton } from 'primereact/togglebutton';
import { PhaseTemplate, SousPhaseTemplate, WizardConfiguration } from '../PhaseGenerationWizard';
interface CustomizationStepProps {
configuration: WizardConfiguration;
onConfigurationChange: (config: WizardConfiguration) => void;
}
const CustomizationStep: React.FC<CustomizationStepProps> = ({
configuration,
onConfigurationChange
}) => {
const toast = useRef<Toast>(null);
const [activeTabIndex, setActiveTabIndex] = useState(0);
const [expandedPhases, setExpandedPhases] = useState<Record<string, boolean>>({});
const [editingCell, setEditingCell] = useState<string | null>(null);
// État local pour les modifications
const [localPhases, setLocalPhases] = useState<PhaseTemplate[]>(
configuration.phasesSelectionnees || []
);
// Synchroniser avec la configuration parent seulement si les localPhases sont vides
useEffect(() => {
if (configuration.phasesSelectionnees && localPhases.length === 0) {
console.log('🔄 Synchronisation initiale localPhases avec configuration:', configuration.phasesSelectionnees.length, 'phases');
setLocalPhases([...configuration.phasesSelectionnees]);
}
}, [configuration.phasesSelectionnees, localPhases.length]);
// Log les changements de localPhases pour debug
useEffect(() => {
console.log('📋 LocalPhases mis à jour:', localPhases.length, 'phases, budget total:',
localPhases.reduce((sum, p) => sum + (p.budgetEstime || 0), 0));
}, [localPhases]);
// Mettre à jour la configuration
const updateConfiguration = (updates: Partial<WizardConfiguration>) => {
onConfigurationChange({
...configuration,
...updates
});
};
// Mettre à jour une phase
const updatePhase = (phaseId: string, updates: Partial<PhaseTemplate>) => {
setLocalPhases(prevPhases => {
const updatedPhases = prevPhases.map(phase =>
phase.id === phaseId
? { ...phase, ...updates }
: phase
);
// Recalculer le budget et la durée globale
const budgetTotal = updatedPhases.reduce((sum, p) => sum + (p.budgetEstime || 0), 0);
const dureeTotal = updatedPhases.reduce((sum, p) => sum + (p.dureeEstimee || 0), 0);
console.log('💰 Budget mis à jour:', { phaseId, updates, budgetTotal, dureeTotal });
// Mettre à jour la configuration avec les nouvelles données
setTimeout(() => {
updateConfiguration({
phasesSelectionnees: updatedPhases,
budgetGlobal: budgetTotal,
dureeGlobale: dureeTotal
});
}, 0);
return updatedPhases;
});
};
// Mettre à jour une sous-phase
const updateSousPhase = (phaseId: string, sousPhaseId: string, updates: Partial<SousPhaseTemplate>) => {
setLocalPhases(prevPhases => {
const updatedPhases = prevPhases.map(phase => {
if (phase.id === phaseId) {
const updatedSousPhases = phase.sousPhases.map(sp =>
sp.id === sousPhaseId ? { ...sp, ...updates } : sp
);
return { ...phase, sousPhases: updatedSousPhases };
}
return phase;
});
setTimeout(() => {
updateConfiguration({ phasesSelectionnees: updatedPhases });
}, 0);
return updatedPhases;
});
};
// Templates pour le tableau des phases
const checkboxTemplate = (rowData: PhaseTemplate) => (
<Checkbox
checked={localPhases.some(p => p.id === rowData.id)}
onChange={(e) => {
setLocalPhases(prevPhases => {
const newPhases = e.checked
? [...prevPhases, rowData]
: prevPhases.filter(p => p.id !== rowData.id);
setTimeout(() => {
updateConfiguration({ phasesSelectionnees: newPhases });
}, 0);
return newPhases;
});
}}
/>
);
const nomTemplate = (rowData: PhaseTemplate) => (
<div className="flex align-items-center gap-2">
<Badge value={rowData.ordre} severity="info" />
<div>
<div className="font-semibold">{rowData.nom}</div>
<small className="text-color-secondary">{rowData.description}</small>
</div>
</div>
);
const categorieTemplate = (rowData: PhaseTemplate) => (
<Tag
value={rowData.categorieMetier}
severity={
rowData.categorieMetier === 'GROS_OEUVRE' ? 'warning' :
rowData.categorieMetier === 'SECOND_OEUVRE' ? 'info' :
rowData.categorieMetier === 'FINITIONS' ? 'success' :
rowData.categorieMetier === 'EQUIPEMENTS' ? 'danger' : 'secondary'
}
/>
);
const dureeTemplate = (rowData: PhaseTemplate) => {
const isEditing = editingCell === `duree_${rowData.id}`;
return isEditing ? (
<InputNumber
value={rowData.dureeEstimee}
onValueChange={(e) => updatePhase(rowData.id, { dureeEstimee: e.value || 0 })}
onBlur={() => setEditingCell(null)}
suffix=" j"
min={1}
max={365}
autoFocus
className="w-full"
/>
) : (
<div
className="cursor-pointer hover:surface-hover p-2 border-round"
onClick={() => setEditingCell(`duree_${rowData.id}`)}
>
<span className="font-semibold">{rowData.dureeEstimee}</span>
<small className="text-color-secondary ml-1">jours</small>
</div>
);
};
const budgetTemplate = (rowData: PhaseTemplate) => {
const isEditing = editingCell === `budget_${rowData.id}`;
return isEditing ? (
<InputNumber
value={rowData.budgetEstime}
onValueChange={(e) => {
const newValue = e.value || 0;
console.log('💰 Modification budget phase:', rowData.id, 'ancien:', rowData.budgetEstime, 'nouveau:', newValue);
updatePhase(rowData.id, { budgetEstime: newValue });
}}
onBlur={() => {
console.log('💰 Fin édition budget phase:', rowData.id);
setEditingCell(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Tab') {
setEditingCell(null);
}
}}
mode="decimal"
minFractionDigits={0}
maxFractionDigits={2}
min={0}
placeholder="0"
autoFocus
className="w-full"
style={{ textAlign: 'right' }}
/>
) : (
<div
className="cursor-pointer hover:surface-hover p-2 border-round flex align-items-center gap-2"
onClick={() => {
console.log('💰 Début édition budget phase:', rowData.id);
setEditingCell(`budget_${rowData.id}`);
}}
title="Cliquer pour modifier le budget"
>
<span>
{new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0
}).format(rowData.budgetEstime)}
</span>
<i className="pi pi-pencil text-xs text-color-secondary"></i>
</div>
);
};
const competencesTemplate = (rowData: PhaseTemplate) => (
<div className="flex flex-wrap gap-1 max-w-15rem">
{rowData.competencesRequises.slice(0, 3).map((comp, index) => (
<Badge key={index} value={comp} severity="secondary" className="text-xs" />
))}
{rowData.competencesRequises.length > 3 && (
<Badge value={`+${rowData.competencesRequises.length - 3}`} severity="info" className="text-xs" />
)}
</div>
);
const sousPhaseTemplate = (rowData: PhaseTemplate) => (
<Button
icon="pi pi-list"
label={`${rowData.sousPhases.length} sous-phases`}
className="p-button-text p-button-rounded p-button-sm"
onClick={() => {
setExpandedPhases({
...expandedPhases,
[rowData.id]: !expandedPhases[rowData.id]
});
}}
/>
);
// Panel d'options avancées
const optionsAvanceesPanel = () => (
<Card title="Options Avancées" className="mt-4">
<div className="grid">
<div className="col-12 md:col-6">
<div className="field-checkbox mb-4">
<Checkbox
id="integrerPlanning"
checked={configuration.optionsAvancees.integrerPlanning}
onChange={(e) => updateConfiguration({
optionsAvancees: {
...configuration.optionsAvancees,
integrerPlanning: e.checked || false
}
})}
/>
<label htmlFor="integrerPlanning" className="ml-2">
<strong>Intégrer au module Planning & Organisation</strong>
<div className="text-color-secondary text-sm">
Créer automatiquement les événements de planning
</div>
</label>
</div>
<div className="field-checkbox mb-4">
<Checkbox
id="calculerBudgetAuto"
checked={configuration.optionsAvancees.calculerBudgetAuto}
onChange={(e) => updateConfiguration({
optionsAvancees: {
...configuration.optionsAvancees,
calculerBudgetAuto: e.checked || false
}
})}
/>
<label htmlFor="calculerBudgetAuto" className="ml-2">
<strong>Calcul automatique des budgets</strong>
<div className="text-color-secondary text-sm">
Appliquer les coefficients et marges automatiquement
</div>
</label>
</div>
<div className="field-checkbox mb-4">
<Checkbox
id="appliquerMarges"
checked={configuration.optionsAvancees.appliquerMarges}
onChange={(e) => updateConfiguration({
optionsAvancees: {
...configuration.optionsAvancees,
appliquerMarges: e.checked || false
}
})}
/>
<label htmlFor="appliquerMarges" className="ml-2">
<strong>Appliquer les marges commerciales</strong>
<div className="text-color-secondary text-sm">
Inclure les taux de marge et aléas dans les calculs
</div>
</label>
</div>
</div>
{configuration.optionsAvancees.appliquerMarges && (
<div className="col-12 md:col-6">
<h6>Configuration des Taux</h6>
<div className="field mb-3">
<label htmlFor="margeCommerciale">Marge commerciale: {configuration.optionsAvancees.taux.margeCommerciale}%</label>
<Slider
id="margeCommerciale"
value={configuration.optionsAvancees.taux.margeCommerciale}
onChange={(e) => updateConfiguration({
optionsAvancees: {
...configuration.optionsAvancees,
taux: {
...configuration.optionsAvancees.taux,
margeCommerciale: e.value as number
}
}
})}
min={0}
max={50}
className="w-full"
/>
</div>
<div className="field mb-3">
<label htmlFor="alea">Aléa/Imprévus: {configuration.optionsAvancees.taux.alea}%</label>
<Slider
id="alea"
value={configuration.optionsAvancees.taux.alea}
onChange={(e) => updateConfiguration({
optionsAvancees: {
...configuration.optionsAvancees,
taux: {
...configuration.optionsAvancees.taux,
alea: e.value as number
}
}
})}
min={0}
max={30}
className="w-full"
/>
</div>
<div className="field mb-3">
<label htmlFor="tva">TVA: {configuration.optionsAvancees.taux.tva}%</label>
<Slider
id="tva"
value={configuration.optionsAvancees.taux.tva}
onChange={(e) => updateConfiguration({
optionsAvancees: {
...configuration.optionsAvancees,
taux: {
...configuration.optionsAvancees.taux,
tva: e.value as number
}
}
})}
min={0}
max={25}
step={0.5}
className="w-full"
/>
</div>
</div>
)}
</div>
</Card>
);
// Panel de planning
const planningPanel = () => (
<Card title="Configuration du Planning" className="mt-4">
<div className="grid">
<div className="col-12 md:col-6">
<div className="field">
<label htmlFor="dateDebut">Date de début souhaitée</label>
<Calendar
id="dateDebut"
value={configuration.dateDebutSouhaitee}
onChange={(e) => updateConfiguration({ dateDebutSouhaitee: e.value as Date })}
dateFormat="dd/mm/yy"
showIcon
className="w-full"
placeholder="Sélectionner une date"
/>
</div>
</div>
<div className="col-12 md:col-6">
<div className="field">
<label htmlFor="dureeGlobale">Durée globale estimée</label>
<InputNumber
id="dureeGlobale"
value={configuration.dureeGlobale}
onValueChange={(e) => updateConfiguration({ dureeGlobale: e.value || 0 })}
suffix=" jours"
min={1}
className="w-full"
/>
<small className="text-color-secondary">
Calculé automatiquement: {localPhases.reduce((sum, p) => sum + (p.dureeEstimee || 0), 0)} jours
</small>
</div>
</div>
</div>
</Card>
);
// Panel récapitulatif
const recapitulatifPanel = () => {
const budgetTotal = localPhases.reduce((sum, p) => sum + (p.budgetEstime || 0), 0);
const dureeTotal = localPhases.reduce((sum, p) => sum + (p.dureeEstimee || 0), 0);
const nombreSousPhases = localPhases.reduce((sum, p) => sum + p.sousPhases.length, 0);
console.log('📊 Récapitulatif recalculé:', {
budgetTotal,
dureeTotal,
nombrePhases: localPhases.length,
phases: localPhases.map(p => ({ id: p.id, nom: p.nom, budget: p.budgetEstime }))
});
return (
<Card title="Récapitulatif" className="mt-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-sitemap text-primary text-2xl mb-2"></i>
<div className="text-color font-bold text-xl">{localPhases.length}</div>
<small className="text-color-secondary">phases sélectionnées</small>
</div>
</div>
<div className="col-12 md:col-3">
<div className="text-center p-3 surface-100 border-round">
<i className="pi pi-list text-primary text-2xl mb-2"></i>
<div className="text-color font-bold text-xl">{nombreSousPhases}</div>
<small className="text-color-secondary">sous-phases incluses</small>
</div>
</div>
<div className="col-12 md:col-3">
<div className="text-center p-3 surface-100 border-round">
<i className="pi pi-clock text-primary text-2xl mb-2"></i>
<div className="text-color font-bold text-xl">{dureeTotal}</div>
<small className="text-color-secondary">jours estimés</small>
</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-2xl mb-2"></i>
<div className="text-color font-bold text-lg">
{new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0
}).format(budgetTotal)}
</div>
<small className="text-color-secondary">budget estimé</small>
</div>
</div>
</div>
</Card>
);
};
if (!configuration.typeChantier) {
return (
<Message
severity="warn"
text="Veuillez d'abord sélectionner un template de chantier dans l'étape précédente."
/>
);
}
return (
<div>
<div className="flex align-items-center gap-3 mb-4">
<i className="pi pi-cog text-primary text-2xl"></i>
<div>
<h4 className="m-0">Personnalisation Avancée</h4>
<p className="m-0 text-color-secondary">
Configurez les phases, budgets et options pour votre chantier
</p>
</div>
</div>
<TabView activeIndex={activeTabIndex} onTabChange={(e) => setActiveTabIndex(e.index)}>
<TabPanel header="Phases & Budgets" leftIcon="pi pi-table">
<Message
severity="info"
text="Cliquez sur les cellules Durée et Budget pour les modifier. Cochez/décochez les phases à inclure."
className="mb-4"
/>
<DataTable
value={configuration.typeChantier.phases}
responsiveLayout="scroll"
paginator={false}
emptyMessage="Aucune phase disponible"
className="p-datatable-sm"
>
<Column
header="Inclure"
body={checkboxTemplate}
style={{ width: '80px', textAlign: 'center' }}
/>
<Column
field="nom"
header="Phase"
body={nomTemplate}
style={{ minWidth: '250px' }}
/>
<Column
field="categorieMetier"
header="Catégorie"
body={categorieTemplate}
style={{ width: '150px' }}
/>
<Column
field="dureeEstimee"
header="Durée"
body={dureeTemplate}
style={{ width: '120px' }}
/>
<Column
field="budgetEstime"
header="Budget"
body={budgetTemplate}
style={{ width: '150px' }}
/>
<Column
field="competencesRequises"
header="Compétences"
body={competencesTemplate}
style={{ width: '200px' }}
/>
<Column
field="sousPhases"
header="Sous-phases"
body={sousPhaseTemplate}
style={{ width: '150px' }}
/>
</DataTable>
{recapitulatifPanel()}
</TabPanel>
<TabPanel header="Options Avancées" leftIcon="pi pi-cog">
{optionsAvanceesPanel()}
</TabPanel>
<TabPanel header="Planning" leftIcon="pi pi-calendar">
{planningPanel()}
</TabPanel>
</TabView>
<Toast ref={toast} />
</div>
);
};
export default CustomizationStep;

View File

@@ -0,0 +1,744 @@
'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;

View File

@@ -0,0 +1,497 @@
'use client';
/**
* Étape 1: Sélection et configuration du template de phases
* Interface de choix du type de chantier avec preview des phases incluses
*/
import React, { useState, useEffect } from 'react';
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
import { Tag } from 'primereact/tag';
import { Badge } from 'primereact/badge';
import { Skeleton } from 'primereact/skeleton';
import { ScrollPanel } from 'primereact/scrollpanel';
import { Divider } from 'primereact/divider';
import { ProgressBar } from 'primereact/progressbar';
import { InputText } from 'primereact/inputtext';
import { Dropdown } from 'primereact/dropdown';
import { Message } from 'primereact/message';
import { TypeChantierTemplate, WizardConfiguration } from '../PhaseGenerationWizard';
import { Chantier } from '../../../types/btp';
interface TemplateSelectionStepProps {
templates: TypeChantierTemplate[];
loading: boolean;
configuration: WizardConfiguration;
onConfigurationChange: (config: WizardConfiguration) => void;
chantier?: Chantier;
}
const TemplateSelectionStep: React.FC<TemplateSelectionStepProps> = ({
templates,
loading,
configuration,
onConfigurationChange,
chantier
}) => {
const [searchFilter, setSearchFilter] = useState('');
const [categorieFilter, setCategorieFilter] = useState('');
const [complexiteFilter, setComplexiteFilter] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState<TypeChantierTemplate | null>(configuration.typeChantier);
// Options de filtrage
const categorieOptions = [
{ label: 'Toutes catégories', value: '' },
{ label: 'Résidentiel', value: 'RESIDENTIEL' },
{ label: 'Commercial', value: 'COMMERCIAL' },
{ label: 'Industriel', value: 'INDUSTRIEL' },
{ label: 'Infrastructure', value: 'INFRASTRUCTURE' },
{ label: 'Rénovation', value: 'RENOVATION' }
];
const complexiteOptions = [
{ label: 'Toutes complexités', value: '' },
{ label: 'Simple', value: 'SIMPLE' },
{ label: 'Moyenne', value: 'MOYENNE' },
{ label: 'Complexe', value: 'COMPLEXE' },
{ label: 'Expert', value: 'EXPERT' }
];
// Filtrer les templates
const filteredTemplates = templates.filter(template => {
let matches = true;
if (searchFilter) {
const search = searchFilter.toLowerCase();
matches = matches && (
template.nom.toLowerCase().includes(search) ||
template.description.toLowerCase().includes(search) ||
template.tags.some(tag => tag.toLowerCase().includes(search))
);
}
if (categorieFilter) {
matches = matches && template.categorie === categorieFilter;
}
if (complexiteFilter) {
matches = matches && template.complexiteMetier === complexiteFilter;
}
return matches;
});
// Sélectionner un template
const selectTemplate = (template: TypeChantierTemplate) => {
setSelectedTemplate(template);
const newConfig = {
...configuration,
typeChantier: template,
phasesSelectionnees: template.phases, // Par défaut, toutes les phases sont sélectionnées
// Préserver les valeurs du chantier si elles existent, sinon utiliser celles du template
budgetGlobal: configuration.budgetGlobal || template.budgetGlobalEstime,
dureeGlobale: configuration.dureeGlobale || template.dureeGlobaleEstimee
};
onConfigurationChange(newConfig);
};
// Template de carte de template
const templateCard = (template: TypeChantierTemplate) => {
const isSelected = selectedTemplate?.id === template.id;
const complexiteSeverityMap = {
'SIMPLE': 'success',
'MOYENNE': 'info',
'COMPLEXE': 'warning',
'EXPERT': 'danger'
} as const;
const cardHeader = (
<div className="flex justify-content-between align-items-start">
<div className="flex-1">
<h5 className="m-0 mb-2">{template.nom}</h5>
<p className="text-color-secondary text-sm m-0 mb-3 line-height-3">
{template.description}
</p>
</div>
{isSelected && (
<i className="pi pi-check-circle text-green-500 text-2xl ml-2"></i>
)}
</div>
);
const cardFooter = (
<div className="flex justify-content-between align-items-center">
<div className="flex align-items-center gap-2">
<Tag
value={template.categorie}
severity="info"
className="text-xs"
/>
<Tag
value={template.complexiteMetier}
severity={complexiteSeverityMap[template.complexiteMetier]}
className="text-xs"
/>
</div>
<Button
label={isSelected ? "Sélectionné" : "Sélectionner"}
icon={isSelected ? "pi pi-check" : "pi pi-arrow-right"}
className={isSelected ? "p-button-text p-button-rounded p-button-success" : "p-button-text p-button-rounded"}
size="small"
onClick={() => selectTemplate(template)}
/>
</div>
);
return (
<Card
key={template.id}
header={cardHeader}
footer={cardFooter}
className={`h-full cursor-pointer transition-all duration-200 ${
isSelected
? 'border-primary-500 shadow-4'
: 'hover:shadow-2 border-transparent'
}`}
onClick={() => selectTemplate(template)}
>
<div className="grid">
<div className="col-12 md:col-6">
<div className="flex align-items-center gap-2 mb-2">
<i className="pi pi-sitemap text-primary"></i>
<span className="font-semibold">{template.nombreTotalPhases} phases</span>
</div>
<div className="flex align-items-center gap-2 mb-2">
<i className="pi pi-clock text-orange-500"></i>
<span>{template.dureeGlobaleEstimee} jours</span>
</div>
<div className="flex align-items-center gap-2">
<i className="pi pi-euro text-green-500"></i>
<span>
{new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0
}).format(template.budgetGlobalEstime)}
</span>
</div>
</div>
<div className="col-12 md:col-6">
<div className="flex align-items-center gap-1 mb-2">
<span className="text-sm text-color-secondary">Catégories:</span>
</div>
<div className="flex flex-wrap gap-1">
{template.phases.slice(0, 3).map((phase, index) => (
<Badge
key={index}
value={phase.categorieMetier}
severity="info"
className="text-xs"
/>
))}
{template.phases.length > 3 && (
<Badge
value={`+${template.phases.length - 3}`}
severity="secondary"
className="text-xs"
/>
)}
</div>
{template.tags.length > 0 && (
<div className="mt-2">
<div className="flex flex-wrap gap-1">
{template.tags.slice(0, 2).map((tag, index) => (
<span
key={index}
className="inline-block bg-blue-50 text-blue-700 px-2 py-1 border-round text-xs"
>
{tag}
</span>
))}
{template.tags.length > 2 && (
<span className="inline-block bg-gray-100 text-gray-600 px-2 py-1 border-round text-xs">
+{template.tags.length - 2}
</span>
)}
</div>
</div>
)}
</div>
</div>
</Card>
);
};
// Prévisualisation du template sélectionné
const previewPanel = () => {
if (!selectedTemplate) return null;
return (
<Card className="mt-4">
<div className="flex align-items-center gap-3 mb-4">
<i className="pi pi-eye text-primary text-2xl"></i>
<h5 className="m-0">Prévisualisation: {selectedTemplate.nom}</h5>
</div>
<div className="grid">
<div className="col-12 md:col-4">
<Card className="bg-blue-50 h-full">
<div className="text-center">
<i className="pi pi-sitemap text-blue-600 text-3xl mb-3"></i>
<h6 className="text-blue-800 m-0 mb-2">Phases incluses</h6>
<div className="text-blue-900 font-bold text-2xl">
{selectedTemplate.nombreTotalPhases}
</div>
<small className="text-blue-600">
phases + sous-phases
</small>
</div>
</Card>
</div>
<div className="col-12 md:col-4">
<Card className="bg-orange-50 h-full">
<div className="text-center">
<i className="pi pi-clock text-orange-600 text-3xl mb-3"></i>
<h6 className="text-orange-800 m-0 mb-2">Durée estimée</h6>
<div className="text-orange-900 font-bold text-2xl">
{selectedTemplate.dureeGlobaleEstimee}
</div>
<small className="text-orange-600">
jours ouvrés
</small>
</div>
</Card>
</div>
<div className="col-12 md:col-4">
<Card className="bg-green-50 h-full">
<div className="text-center">
<i className="pi pi-euro text-green-600 text-3xl mb-3"></i>
<h6 className="text-green-800 m-0 mb-2">Budget estimé</h6>
<div className="text-green-900 font-bold text-xl">
{new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0
}).format(selectedTemplate.budgetGlobalEstime)}
</div>
<small className="text-green-600">
estimation globale
</small>
</div>
</Card>
</div>
</div>
<Divider />
<div className="grid">
<div className="col-12 md:col-6">
<h6 className="text-primary mb-3">
<i className="pi pi-list mr-2"></i>
Phases principales ({selectedTemplate.phases.length})
</h6>
<ScrollPanel style={{ width: '100%', height: '200px' }}>
{selectedTemplate.phases.map((phase, index) => (
<div key={phase.id} className="flex align-items-center gap-2 mb-2 p-2 bg-gray-50 border-round">
<Badge value={index + 1} className="bg-primary" />
<div className="flex-1">
<div className="font-semibold text-sm">{phase.nom}</div>
<small className="text-color-secondary">
{phase.dureeEstimee}j {phase.sousPhases.length} sous-phases
</small>
</div>
<Tag
value={phase.categorieMetier}
severity="info"
className="text-xs"
/>
</div>
))}
</ScrollPanel>
</div>
<div className="col-12 md:col-6">
<h6 className="text-primary mb-3">
<i className="pi pi-tags mr-2"></i>
Caractéristiques du template
</h6>
<div className="flex flex-column gap-3">
<div>
<span className="font-semibold">Catégorie:</span>
<Tag value={selectedTemplate.categorie} severity="info" className="ml-2" />
</div>
<div>
<span className="font-semibold">Complexité:</span>
<Tag
value={selectedTemplate.complexiteMetier}
severity={
selectedTemplate.complexiteMetier === 'SIMPLE' ? 'success' :
selectedTemplate.complexiteMetier === 'MOYENNE' ? 'info' :
selectedTemplate.complexiteMetier === 'COMPLEXE' ? 'warning' : 'danger'
}
className="ml-2"
/>
</div>
{selectedTemplate.tags.length > 0 && (
<div>
<span className="font-semibold mb-2 block">Tags:</span>
<div className="flex flex-wrap gap-1">
{selectedTemplate.tags.map((tag, index) => (
<span
key={index}
className="inline-block bg-blue-50 text-blue-700 px-2 py-1 border-round text-xs"
>
{tag}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
</Card>
);
};
return (
<div>
<div className="flex align-items-center gap-3 mb-4">
<i className="pi pi-th-large text-primary text-2xl"></i>
<h4 className="m-0">Sélection du Template de Chantier</h4>
</div>
{/* Informations du chantier */}
{chantier && (
<Card className="mb-4" style={{ backgroundColor: 'var(--surface-50)', border: '1px solid var(--primary-200)' }}>
<div className="flex align-items-center gap-3 mb-3">
<i className="pi pi-info-circle text-primary"></i>
<h6 className="m-0 text-color">Informations du chantier</h6>
</div>
<div className="grid">
<div className="col-12 md:col-6">
<div className="flex flex-column gap-2">
<div className="text-color">
<span className="font-semibold">Nom :</span> {chantier.nom}
</div>
{chantier.typeChantier && (
<div className="text-color">
<span className="font-semibold">Type :</span> {chantier.typeChantier}
</div>
)}
{chantier.surface && (
<div className="text-color">
<span className="font-semibold">Surface :</span> {chantier.surface} m²
</div>
)}
</div>
</div>
<div className="col-12 md:col-6">
<div className="flex flex-column gap-2">
{chantier.montantPrevu && (
<div className="text-color">
<span className="font-semibold">Budget prévu :</span> {chantier.montantPrevu.toLocaleString('fr-FR', {
style: 'currency',
currency: 'EUR'
})}
</div>
)}
{chantier.dateDebut && (
<div className="text-color">
<span className="font-semibold">Date de début :</span> {new Date(chantier.dateDebut).toLocaleDateString('fr-FR')}
</div>
)}
{chantier.dateFinPrevue && (
<div className="text-color">
<span className="font-semibold">Date de fin prévue :</span> {new Date(chantier.dateFinPrevue).toLocaleDateString('fr-FR')}
</div>
)}
</div>
</div>
</div>
<div className="mt-3 p-2 surface-ground border-round">
<small className="text-primary">
<i className="pi pi-lightbulb mr-1"></i>
Ces informations seront utilisées pour personnaliser les phases générées
</small>
</div>
</Card>
)}
<Message
severity="info"
text="Choisissez le type de chantier qui correspond le mieux à votre projet. Le template sélectionné déterminera les phases qui seront générées automatiquement."
className="mb-4"
/>
{/* Filtres */}
<Card className="mb-4">
<div className="grid">
<div className="col-12 md:col-4">
<span className="p-input-icon-left w-full">
<i className="pi pi-search" />
<InputText
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
placeholder="Rechercher un template..."
className="w-full"
/>
</span>
</div>
<div className="col-12 md:col-4">
<Dropdown
value={categorieFilter}
options={categorieOptions}
onChange={(e) => setCategorieFilter(e.value)}
placeholder="Filtrer par catégorie"
className="w-full"
/>
</div>
<div className="col-12 md:col-4">
<Dropdown
value={complexiteFilter}
options={complexiteOptions}
onChange={(e) => setComplexiteFilter(e.value)}
placeholder="Filtrer par complexité"
className="w-full"
/>
</div>
</div>
</Card>
{/* Liste des templates */}
{loading ? (
<div className="grid">
{[1, 2, 3, 4].map((item) => (
<div key={item} className="col-12 md:col-6 xl:col-4">
<Card>
<Skeleton width="100%" height="200px" />
</Card>
</div>
))}
</div>
) : filteredTemplates.length === 0 ? (
<Message
severity="warn"
text="Aucun template trouvé avec les critères de recherche actuels."
/>
) : (
<div className="grid">
{filteredTemplates.map((template) => (
<div key={template.id} className="col-12 md:col-6 xl:col-4">
{templateCard(template)}
</div>
))}
</div>
)}
{/* Prévisualisation */}
{previewPanel()}
</div>
);
};
export default TemplateSelectionStep;