Initial commit

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

View File

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