/** * 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 = ({ visible, onHide, phase, onSave }) => { const toast = useRef(null); const [activeIndex, setActiveIndex] = useState(0); const [depenses, setDepenses] = useState([]); const [nouvelleDepense, setNouvelleDepense] = useState({ 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': 'danger', 'AUTRES': 'secondary' } as const; return ; }; const montantTemplate = (rowData: DepenseReelle) => { return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(rowData.montant); }; const validationTemplate = (rowData: DepenseReelle) => { return rowData.valide ? ( ) : ( ); }; const actionsTemplate = (rowData: DepenseReelle) => { return (
{!rowData.valide && (
); }; 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 = (
); return ( <> setActiveIndex(e.index)}>
{/* Formulaire d'ajout de dépense */}
setNouvelleDepense({ ...nouvelleDepense, date: e.value ? e.value.toISOString().split('T')[0] : '' })} className="w-full" dateFormat="dd/mm/yy" />
setNouvelleDepense({...nouvelleDepense, categorie: e.value})} className="w-full" />
setNouvelleDepense({...nouvelleDepense, designation: e.target.value})} className="w-full" placeholder="Description de la dépense" />
setNouvelleDepense({...nouvelleDepense, montant: e.value || 0})} className="w-full" mode="currency" currency="EUR" locale="fr-FR" />
setNouvelleDepense({...nouvelleDepense, fournisseur: e.target.value})} className="w-full" placeholder="Nom du fournisseur" />
setNouvelleDepense({...nouvelleDepense, numeroPiece: e.target.value})} className="w-full" placeholder="N° facture, bon..." />
setNouvelleDepense({...nouvelleDepense, notes: e.target.value})} className="w-full" placeholder="Notes supplémentaires" />
{/* Liste des dépenses */}
Budget total prévu: {new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(totalBudgetPrevu)}
Dépenses réelles: {new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(totalDepenseReelle)}
Écart total: 0 ? 'text-red-500' : 'text-green-500'}`}> {ecartTotal > 0 ? '+' : ''}{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(ecartTotal)}
Écart (%): 0 ? 'text-red-500' : 'text-green-500'}`}> {ecartTotalPourcentage > 0 ? '+' : ''}{ecartTotalPourcentage.toFixed(1)}%
0 ? (totalDepenseReelle / totalBudgetPrevu) * 100 : 0} className="mb-2" color={ecartTotalPourcentage > 10 ? '#dc3545' : ecartTotalPourcentage > 5 ? '#ffc107' : '#22c55e'} /> Taux de consommation budgétaire
{/* Détail par catégorie */}
new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(rowData.budgetPrevu)} /> new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(rowData.depenseReelle)} /> ( 0 ? 'text-red-500 font-semibold' : 'text-green-500'}> {rowData.ecart > 0 ? '+' : ''}{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(rowData.ecart)} )} /> ( 10 ? 'text-red-500 font-semibold' : rowData.ecartPourcentage > 5 ? 'text-orange-500' : 'text-green-500'}> {rowData.ecartPourcentage > 0 ? '+' : ''}{rowData.ecartPourcentage.toFixed(1)}% )} /> { const severityMap = { 'CONFORME': 'success', 'ALERTE': 'warning', 'DEPASSEMENT': 'danger' } as const; return ; }} />
); }; export default BudgetExecutionDialog;