584 lines
28 KiB
TypeScript
Executable File
584 lines
28 KiB
TypeScript
Executable File
/**
|
|
* 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; |