625 lines
28 KiB
TypeScript
625 lines
28 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* Page principale de planification budgétaire
|
|
* Vue d'ensemble de tous les budgets avec possibilité de planification détaillée
|
|
*/
|
|
|
|
import React, { useState, useRef, useEffect, useContext } from 'react';
|
|
import { LayoutContext } from '../../../../layout/context/layoutcontext';
|
|
import { Card } from 'primereact/card';
|
|
import { DataTable } from 'primereact/datatable';
|
|
import { Column } from 'primereact/column';
|
|
import { Button } from 'primereact/button';
|
|
import { Tag } from 'primereact/tag';
|
|
import { ProgressBar } from 'primereact/progressbar';
|
|
import { Toolbar } from 'primereact/toolbar';
|
|
import { InputText } from 'primereact/inputtext';
|
|
import { Dropdown } from 'primereact/dropdown';
|
|
import { Calendar } from 'primereact/calendar';
|
|
import { Toast } from 'primereact/toast';
|
|
import { Chart } from 'primereact/chart';
|
|
import { Divider } from 'primereact/divider';
|
|
import { TabView, TabPanel } from 'primereact/tabview';
|
|
import { Panel } from 'primereact/panel';
|
|
import BudgetPlanningDialog from '../../../../components/phases/BudgetPlanningDialog';
|
|
|
|
interface BudgetChantier {
|
|
id: string;
|
|
chantierNom: string;
|
|
client: string;
|
|
dateDebut: string;
|
|
dateFin: string;
|
|
budgetTotal: number;
|
|
budgetPlanifie: number;
|
|
avancementPlanification: number;
|
|
nombrePhases: number;
|
|
nombrePhasesPlanifiees: number;
|
|
statut: 'NON_PLANIFIE' | 'EN_COURS_PLANIFICATION' | 'PLANIFIE' | 'VALIDE';
|
|
priorite: 'HAUTE' | 'MOYENNE' | 'BASSE';
|
|
responsable: string;
|
|
derniereMaj: string;
|
|
}
|
|
|
|
const BudgetPlanificationPage = () => {
|
|
const { layoutConfig, setLayoutConfig, layoutState, setLayoutState } = useContext(LayoutContext);
|
|
const toast = useRef<Toast>(null);
|
|
|
|
// États
|
|
const [budgets, setBudgets] = useState<BudgetChantier[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [globalFilter, setGlobalFilter] = useState('');
|
|
const [selectedBudget, setSelectedBudget] = useState<BudgetChantier | null>(null);
|
|
const [showPlanningDialog, setShowPlanningDialog] = useState(false);
|
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
|
|
// Filtres
|
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
|
const [prioriteFilter, setPrioriteFilter] = useState<string>('');
|
|
const [dateFilter, setDateFilter] = useState<Date | null>(null);
|
|
|
|
// Options pour les filtres
|
|
const statusOptions = [
|
|
{ label: 'Tous les statuts', value: '' },
|
|
{ label: 'Non planifié', value: 'NON_PLANIFIE' },
|
|
{ label: 'En cours', value: 'EN_COURS_PLANIFICATION' },
|
|
{ label: 'Planifié', value: 'PLANIFIE' },
|
|
{ label: 'Validé', value: 'VALIDE' }
|
|
];
|
|
|
|
const prioriteOptions = [
|
|
{ label: 'Toutes priorités', value: '' },
|
|
{ label: 'Haute', value: 'HAUTE' },
|
|
{ label: 'Moyenne', value: 'MOYENNE' },
|
|
{ label: 'Basse', value: 'BASSE' }
|
|
];
|
|
|
|
// Charger les données
|
|
useEffect(() => {
|
|
loadBudgets();
|
|
}, []);
|
|
|
|
const loadBudgets = async () => {
|
|
try {
|
|
setLoading(true);
|
|
|
|
// Simuler des données de budget (en production, ceci viendrait de l'API)
|
|
const budgetsSimules: BudgetChantier[] = [
|
|
{
|
|
id: '1',
|
|
chantierNom: 'Villa Moderne Bordeaux',
|
|
client: 'Jean Dupont',
|
|
dateDebut: '2025-03-01',
|
|
dateFin: '2025-08-30',
|
|
budgetTotal: 250000,
|
|
budgetPlanifie: 180000,
|
|
avancementPlanification: 72,
|
|
nombrePhases: 8,
|
|
nombrePhasesPlanifiees: 6,
|
|
statut: 'EN_COURS_PLANIFICATION',
|
|
priorite: 'HAUTE',
|
|
responsable: 'Marie Martin',
|
|
derniereMaj: '2025-01-28'
|
|
},
|
|
{
|
|
id: '2',
|
|
chantierNom: 'Extension Maison Lyon',
|
|
client: 'Sophie Lambert',
|
|
dateDebut: '2025-02-15',
|
|
dateFin: '2025-06-15',
|
|
budgetTotal: 120000,
|
|
budgetPlanifie: 120000,
|
|
avancementPlanification: 100,
|
|
nombrePhases: 5,
|
|
nombrePhasesPlanifiees: 5,
|
|
statut: 'PLANIFIE',
|
|
priorite: 'MOYENNE',
|
|
responsable: 'Pierre Dubois',
|
|
derniereMaj: '2025-01-25'
|
|
},
|
|
{
|
|
id: '3',
|
|
chantierNom: 'Rénovation Appartement Paris',
|
|
client: 'Michel Robert',
|
|
dateDebut: '2025-04-01',
|
|
dateFin: '2025-07-31',
|
|
budgetTotal: 85000,
|
|
budgetPlanifie: 0,
|
|
avancementPlanification: 0,
|
|
nombrePhases: 6,
|
|
nombrePhasesPlanifiees: 0,
|
|
statut: 'NON_PLANIFIE',
|
|
priorite: 'HAUTE',
|
|
responsable: 'Anne Legrand',
|
|
derniereMaj: '2025-01-20'
|
|
}
|
|
];
|
|
|
|
setBudgets(budgetsSimules);
|
|
|
|
} catch (error) {
|
|
console.error('Erreur lors du chargement des budgets:', error);
|
|
toast.current?.show({
|
|
severity: 'error',
|
|
summary: 'Erreur',
|
|
detail: 'Impossible de charger les budgets',
|
|
life: 3000
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Templates pour le DataTable
|
|
const statutTemplate = (rowData: BudgetChantier) => {
|
|
const severityMap = {
|
|
'NON_PLANIFIE': 'danger',
|
|
'EN_COURS_PLANIFICATION': 'warning',
|
|
'PLANIFIE': 'info',
|
|
'VALIDE': 'success'
|
|
} as const;
|
|
|
|
const labelMap = {
|
|
'NON_PLANIFIE': 'Non planifié',
|
|
'EN_COURS_PLANIFICATION': 'En cours',
|
|
'PLANIFIE': 'Planifié',
|
|
'VALIDE': 'Validé'
|
|
};
|
|
|
|
return <Tag value={labelMap[rowData.statut]} severity={severityMap[rowData.statut]} />;
|
|
};
|
|
|
|
const prioriteTemplate = (rowData: BudgetChantier) => {
|
|
const severityMap = {
|
|
'HAUTE': 'danger',
|
|
'MOYENNE': 'warning',
|
|
'BASSE': 'info'
|
|
} as const;
|
|
|
|
return <Tag value={rowData.priorite} severity={severityMap[rowData.priorite]} />;
|
|
};
|
|
|
|
const budgetTemplate = (rowData: BudgetChantier) => {
|
|
const pourcentagePlanifie = rowData.budgetTotal > 0 ? (rowData.budgetPlanifie / rowData.budgetTotal) * 100 : 0;
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex justify-content-between mb-1">
|
|
<span className="text-sm text-color-secondary">
|
|
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(rowData.budgetPlanifie)}
|
|
</span>
|
|
<span className="text-sm text-color-secondary">
|
|
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(rowData.budgetTotal)}
|
|
</span>
|
|
</div>
|
|
<ProgressBar
|
|
value={pourcentagePlanifie}
|
|
className="w-full"
|
|
style={{ height: '8px' }}
|
|
color={pourcentagePlanifie < 50 ? '#dc3545' : pourcentagePlanifie < 80 ? '#ffc107' : '#22c55e'}
|
|
/>
|
|
<small className="text-color-secondary">{pourcentagePlanifie.toFixed(0)}% planifié</small>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const avancementTemplate = (rowData: BudgetChantier) => {
|
|
return (
|
|
<div>
|
|
<div className="flex justify-content-between mb-1">
|
|
<span className="text-sm">Phases planifiées</span>
|
|
<span className="text-sm font-semibold">{rowData.nombrePhasesPlanifiees}/{rowData.nombrePhases}</span>
|
|
</div>
|
|
<ProgressBar
|
|
value={rowData.avancementPlanification}
|
|
className="w-full"
|
|
style={{ height: '8px' }}
|
|
/>
|
|
<small className="text-color-secondary">{rowData.avancementPlanification}% avancement</small>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const dateTemplate = (rowData: BudgetChantier, field: keyof BudgetChantier) => {
|
|
const date = rowData[field] as string;
|
|
return new Date(date).toLocaleDateString('fr-FR');
|
|
};
|
|
|
|
const actionsTemplate = (rowData: BudgetChantier) => {
|
|
return (
|
|
<div className="flex gap-1">
|
|
<Button
|
|
icon="pi pi-calculator"
|
|
className="p-button-outlined p-button-sm"
|
|
tooltip="Planifier le budget"
|
|
onClick={() => {
|
|
setSelectedBudget(rowData);
|
|
setShowPlanningDialog(true);
|
|
}}
|
|
/>
|
|
<Button
|
|
icon="pi pi-eye"
|
|
className="p-button-outlined p-button-sm"
|
|
tooltip="Voir détails"
|
|
onClick={() => {
|
|
// Navigation vers le détail du chantier
|
|
window.open(`/chantiers/${rowData.id}`, '_blank');
|
|
}}
|
|
/>
|
|
<Button
|
|
icon="pi pi-check"
|
|
className="p-button-success p-button-sm"
|
|
tooltip="Valider le budget"
|
|
disabled={rowData.statut !== 'PLANIFIE'}
|
|
onClick={() => validerBudget(rowData.id)}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Fonctions d'action
|
|
const validerBudget = async (budgetId: string) => {
|
|
try {
|
|
// Appel API pour valider le budget
|
|
setBudgets(budgets.map(b =>
|
|
b.id === budgetId ? { ...b, statut: 'VALIDE' as const } : b
|
|
));
|
|
|
|
toast.current?.show({
|
|
severity: 'success',
|
|
summary: 'Budget validé',
|
|
detail: 'Le budget a été validé avec succès',
|
|
life: 3000
|
|
});
|
|
} catch (error) {
|
|
toast.current?.show({
|
|
severity: 'error',
|
|
summary: 'Erreur',
|
|
detail: 'Impossible de valider le budget',
|
|
life: 3000
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleSaveBudgetPlanning = async (budgetData: any) => {
|
|
if (!selectedBudget) return;
|
|
|
|
try {
|
|
// Mettre à jour le budget avec les nouvelles données
|
|
setBudgets(budgets.map(b =>
|
|
b.id === selectedBudget.id
|
|
? {
|
|
...b,
|
|
budgetPlanifie: budgetData.prixVenteCalcule,
|
|
statut: 'PLANIFIE' as const,
|
|
avancementPlanification: 100,
|
|
derniereMaj: new Date().toISOString().split('T')[0]
|
|
}
|
|
: b
|
|
));
|
|
|
|
toast.current?.show({
|
|
severity: 'success',
|
|
summary: 'Budget planifié',
|
|
detail: `Le budget du chantier "${selectedBudget.chantierNom}" a été mis à jour`,
|
|
life: 3000
|
|
});
|
|
} catch (error) {
|
|
toast.current?.show({
|
|
severity: 'error',
|
|
summary: 'Erreur',
|
|
detail: 'Impossible de sauvegarder la planification',
|
|
life: 3000
|
|
});
|
|
}
|
|
};
|
|
|
|
// Filtrer les données
|
|
const filteredBudgets = budgets.filter(budget => {
|
|
let matches = true;
|
|
|
|
if (globalFilter) {
|
|
const search = globalFilter.toLowerCase();
|
|
matches = matches && (
|
|
budget.chantierNom.toLowerCase().includes(search) ||
|
|
budget.client.toLowerCase().includes(search) ||
|
|
budget.responsable.toLowerCase().includes(search)
|
|
);
|
|
}
|
|
|
|
if (statusFilter) {
|
|
matches = matches && budget.statut === statusFilter;
|
|
}
|
|
|
|
if (prioriteFilter) {
|
|
matches = matches && budget.priorite === prioriteFilter;
|
|
}
|
|
|
|
return matches;
|
|
});
|
|
|
|
// Statistiques
|
|
const stats = {
|
|
totalBudgets: budgets.length,
|
|
budgetsNonPlanifies: budgets.filter(b => b.statut === 'NON_PLANIFIE').length,
|
|
budgetsEnCours: budgets.filter(b => b.statut === 'EN_COURS_PLANIFICATION').length,
|
|
budgetsPlanifies: budgets.filter(b => b.statut === 'PLANIFIE').length,
|
|
budgetsValides: budgets.filter(b => b.statut === 'VALIDE').length,
|
|
montantTotalBudget: budgets.reduce((sum, b) => sum + b.budgetTotal, 0),
|
|
montantTotalPlanifie: budgets.reduce((sum, b) => sum + b.budgetPlanifie, 0)
|
|
};
|
|
|
|
// Données pour le graphique
|
|
const chartData = {
|
|
labels: ['Non planifié', 'En cours', 'Planifié', 'Validé'],
|
|
datasets: [
|
|
{
|
|
data: [
|
|
stats.budgetsNonPlanifies,
|
|
stats.budgetsEnCours,
|
|
stats.budgetsPlanifies,
|
|
stats.budgetsValides
|
|
],
|
|
backgroundColor: ['#dc3545', '#ffc107', '#007ad9', '#22c55e'],
|
|
borderWidth: 0
|
|
}
|
|
]
|
|
};
|
|
|
|
// Template de la toolbar
|
|
const toolbarStartTemplate = (
|
|
<div className="flex align-items-center gap-2">
|
|
<h5 className="m-0">Planification Budgétaire</h5>
|
|
<Tag value={`${stats.totalBudgets} chantiers`} className="ml-2" />
|
|
</div>
|
|
);
|
|
|
|
const toolbarEndTemplate = (
|
|
<div className="flex gap-2">
|
|
<span className="p-input-icon-left">
|
|
<i className="pi pi-search" />
|
|
<InputText
|
|
value={globalFilter}
|
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
|
placeholder="Rechercher..."
|
|
/>
|
|
</span>
|
|
<Dropdown
|
|
value={statusFilter}
|
|
options={statusOptions}
|
|
onChange={(e) => setStatusFilter(e.value)}
|
|
placeholder="Statut"
|
|
className="w-10rem"
|
|
/>
|
|
<Dropdown
|
|
value={prioriteFilter}
|
|
options={prioriteOptions}
|
|
onChange={(e) => setPrioriteFilter(e.value)}
|
|
placeholder="Priorité"
|
|
className="w-10rem"
|
|
/>
|
|
<Button
|
|
label="Nouveau budget"
|
|
icon="pi pi-plus"
|
|
className="p-button-success"
|
|
onClick={() => {
|
|
// Navigation vers création de nouveau budget
|
|
window.location.href = '/budget/planification/nouveau';
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="grid">
|
|
<Toast ref={toast} />
|
|
|
|
<div className="col-12">
|
|
<Card>
|
|
<TabView activeIndex={activeIndex} onTabChange={(e) => setActiveIndex(e.index)}>
|
|
<TabPanel header="Vue d'ensemble" leftIcon="pi pi-chart-pie">
|
|
{/* Statistiques générales */}
|
|
<div className="grid mb-4">
|
|
<div className="col-12 lg:col-3">
|
|
<Card className="bg-blue-50">
|
|
<div className="flex justify-content-between">
|
|
<div>
|
|
<span className="block text-blue-600 font-medium mb-3">Budget Total</span>
|
|
<div className="text-blue-900 font-bold text-xl">
|
|
{new Intl.NumberFormat('fr-FR', {
|
|
style: 'currency',
|
|
currency: 'EUR',
|
|
minimumFractionDigits: 0
|
|
}).format(stats.montantTotalBudget)}
|
|
</div>
|
|
</div>
|
|
<div className="flex align-items-center justify-content-center bg-blue-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
|
|
<i className="pi pi-euro text-blue-500 text-xl"></i>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
<div className="col-12 lg:col-3">
|
|
<Card className="bg-green-50">
|
|
<div className="flex justify-content-between">
|
|
<div>
|
|
<span className="block text-green-600 font-medium mb-3">Budget Planifié</span>
|
|
<div className="text-green-900 font-bold text-xl">
|
|
{new Intl.NumberFormat('fr-FR', {
|
|
style: 'currency',
|
|
currency: 'EUR',
|
|
minimumFractionDigits: 0
|
|
}).format(stats.montantTotalPlanifie)}
|
|
</div>
|
|
</div>
|
|
<div className="flex align-items-center justify-content-center bg-green-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
|
|
<i className="pi pi-check-circle text-green-500 text-xl"></i>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
<div className="col-12 lg:col-3">
|
|
<Card className="bg-orange-50">
|
|
<div className="flex justify-content-between">
|
|
<div>
|
|
<span className="block text-orange-600 font-medium mb-3">En Attente</span>
|
|
<div className="text-orange-900 font-bold text-xl">
|
|
{stats.budgetsNonPlanifies + stats.budgetsEnCours}
|
|
</div>
|
|
<small className="text-orange-600">chantiers</small>
|
|
</div>
|
|
<div className="flex align-items-center justify-content-center bg-orange-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
|
|
<i className="pi pi-clock text-orange-500 text-xl"></i>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
<div className="col-12 lg:col-3">
|
|
<Card className="bg-purple-50">
|
|
<div className="flex justify-content-between">
|
|
<div>
|
|
<span className="block text-purple-600 font-medium mb-3">Taux Planification</span>
|
|
<div className="text-purple-900 font-bold text-xl">
|
|
{stats.totalBudgets > 0 ?
|
|
(((stats.budgetsPlanifies + stats.budgetsValides) / stats.totalBudgets) * 100).toFixed(0)
|
|
: 0}%
|
|
</div>
|
|
</div>
|
|
<div className="flex align-items-center justify-content-center bg-purple-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
|
|
<i className="pi pi-chart-pie text-purple-500 text-xl"></i>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Graphique de répartition */}
|
|
<div className="grid mb-4">
|
|
<div className="col-12 lg:col-6">
|
|
<Card title="Répartition par statut">
|
|
<Chart type="doughnut" data={chartData} style={{ height: '300px' }} />
|
|
</Card>
|
|
</div>
|
|
<div className="col-12 lg:col-6">
|
|
<Card title="Actions prioritaires">
|
|
<div className="flex flex-column gap-3">
|
|
<Panel header="Budgets à planifier" toggleable>
|
|
<p className="m-0">
|
|
<strong>{stats.budgetsNonPlanifies}</strong> chantiers n'ont pas encore de budget planifié.
|
|
</p>
|
|
{stats.budgetsNonPlanifies > 0 && (
|
|
<Button
|
|
label="Voir la liste"
|
|
icon="pi pi-list"
|
|
className="mt-2"
|
|
onClick={() => setStatusFilter('NON_PLANIFIE')}
|
|
/>
|
|
)}
|
|
</Panel>
|
|
<Panel header="Budgets à valider" toggleable>
|
|
<p className="m-0">
|
|
<strong>{stats.budgetsPlanifies}</strong> budgets sont prêts à être validés.
|
|
</p>
|
|
{stats.budgetsPlanifies > 0 && (
|
|
<Button
|
|
label="Voir la liste"
|
|
icon="pi pi-list"
|
|
className="mt-2"
|
|
onClick={() => setStatusFilter('PLANIFIE')}
|
|
/>
|
|
)}
|
|
</Panel>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</TabPanel>
|
|
|
|
<TabPanel header="Liste des budgets" leftIcon="pi pi-list">
|
|
<Toolbar
|
|
start={toolbarStartTemplate}
|
|
end={toolbarEndTemplate}
|
|
className="mb-4"
|
|
/>
|
|
|
|
<DataTable
|
|
value={filteredBudgets}
|
|
loading={loading}
|
|
paginator
|
|
rows={20}
|
|
emptyMessage="Aucun budget trouvé"
|
|
className="p-datatable-lg"
|
|
dataKey="id"
|
|
sortMode="multiple"
|
|
>
|
|
<Column field="chantierNom" header="Chantier" sortable style={{ minWidth: '15rem' }} />
|
|
<Column field="client" header="Client" sortable style={{ width: '12rem' }} />
|
|
<Column
|
|
field="dateDebut"
|
|
header="Début"
|
|
body={(rowData) => dateTemplate(rowData, 'dateDebut')}
|
|
sortable
|
|
style={{ width: '8rem' }}
|
|
/>
|
|
<Column
|
|
field="dateFin"
|
|
header="Fin prévue"
|
|
body={(rowData) => dateTemplate(rowData, 'dateFin')}
|
|
sortable
|
|
style={{ width: '8rem' }}
|
|
/>
|
|
<Column
|
|
field="budgetTotal"
|
|
header="Budget"
|
|
body={budgetTemplate}
|
|
sortable
|
|
style={{ width: '12rem' }}
|
|
/>
|
|
<Column
|
|
field="avancementPlanification"
|
|
header="Avancement"
|
|
body={avancementTemplate}
|
|
style={{ width: '12rem' }}
|
|
/>
|
|
<Column
|
|
field="statut"
|
|
header="Statut"
|
|
body={statutTemplate}
|
|
sortable
|
|
style={{ width: '8rem' }}
|
|
/>
|
|
<Column
|
|
field="priorite"
|
|
header="Priorité"
|
|
body={prioriteTemplate}
|
|
sortable
|
|
style={{ width: '8rem' }}
|
|
/>
|
|
<Column field="responsable" header="Responsable" sortable style={{ width: '10rem' }} />
|
|
<Column
|
|
header="Actions"
|
|
body={actionsTemplate}
|
|
style={{ width: '10rem' }}
|
|
/>
|
|
</DataTable>
|
|
</TabPanel>
|
|
</TabView>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Dialog de planification budgétaire */}
|
|
<BudgetPlanningDialog
|
|
visible={showPlanningDialog}
|
|
onHide={() => setShowPlanningDialog(false)}
|
|
phase={selectedBudget ? {
|
|
id: parseInt(selectedBudget.id),
|
|
nom: selectedBudget.chantierNom,
|
|
budgetPrevu: selectedBudget.budgetTotal
|
|
} as any : null}
|
|
onSave={handleSaveBudgetPlanning}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default BudgetPlanificationPage; |