Initial commit
This commit is contained in:
266
components/phases/AtlantisAccessibilityControls.tsx
Normal file
266
components/phases/AtlantisAccessibilityControls.tsx
Normal 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;
|
||||
371
components/phases/AtlantisResponsivePhasesTable.tsx
Normal file
371
components/phases/AtlantisResponsivePhasesTable.tsx
Normal 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;
|
||||
584
components/phases/BudgetExecutionDialog.tsx
Normal file
584
components/phases/BudgetExecutionDialog.tsx
Normal 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"> </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;
|
||||
528
components/phases/BudgetPlanningDialog.tsx
Normal file
528
components/phases/BudgetPlanningDialog.tsx
Normal 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"> </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;
|
||||
570
components/phases/PhaseGenerationWizard.tsx
Normal file
570
components/phases/PhaseGenerationWizard.tsx
Normal 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;
|
||||
364
components/phases/PhaseValidationPanel.tsx
Normal file
364
components/phases/PhaseValidationPanel.tsx
Normal 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;
|
||||
192
components/phases/PhasesQuickPreview.tsx
Normal file
192
components/phases/PhasesQuickPreview.tsx
Normal 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;
|
||||
639
components/phases/PhasesTable.tsx
Normal file
639
components/phases/PhasesTable.tsx
Normal 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;
|
||||
386
components/phases/PhasesTimelinePreview.tsx
Normal file
386
components/phases/PhasesTimelinePreview.tsx
Normal 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;
|
||||
582
components/phases/wizard/CustomizationStep.tsx
Normal file
582
components/phases/wizard/CustomizationStep.tsx
Normal 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;
|
||||
744
components/phases/wizard/PreviewGenerationStep.tsx
Normal file
744
components/phases/wizard/PreviewGenerationStep.tsx
Normal 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;
|
||||
497
components/phases/wizard/TemplateSelectionStep.tsx
Normal file
497
components/phases/wizard/TemplateSelectionStep.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user