Initial commit
This commit is contained in:
583
app/(main)/chantiers/workflow/page.tsx
Normal file
583
app/(main)/chantiers/workflow/page.tsx
Normal file
@@ -0,0 +1,583 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
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 { Dialog } from 'primereact/dialog';
|
||||
import { InputTextarea } from 'primereact/inputtextarea';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Timeline } from 'primereact/timeline';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { Divider } from 'primereact/divider';
|
||||
import { Badge } from 'primereact/badge';
|
||||
import { Knob } from 'primereact/knob';
|
||||
import chantierService from '../../../../services/chantierService';
|
||||
|
||||
/**
|
||||
* Page Workflow Chantiers BTP Express
|
||||
* Gestion complète du cycle de vie des chantiers avec transitions d'état
|
||||
*/
|
||||
const WorkflowChantiers = () => {
|
||||
const [chantiers, setChantiers] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [workflowDialog, setWorkflowDialog] = useState(false);
|
||||
const [selectedChantier, setSelectedChantier] = useState<any>(null);
|
||||
const [nouveauStatut, setNouveauStatut] = useState('');
|
||||
const [commentaire, setCommentaire] = useState('');
|
||||
const [historique, setHistorique] = useState<any[]>([]);
|
||||
const [metriques, setMetriques] = useState<any>({});
|
||||
const toast = useRef<Toast>(null);
|
||||
|
||||
// Définition des transitions workflow BTP
|
||||
const workflowTransitions = {
|
||||
'PLANIFIE': [
|
||||
{ label: 'Démarrer le chantier', value: 'EN_COURS', icon: 'pi-play', color: 'success' },
|
||||
{ label: 'Annuler', value: 'ANNULE', icon: 'pi-times', color: 'danger' }
|
||||
],
|
||||
'EN_COURS': [
|
||||
{ label: 'Terminer le chantier', value: 'TERMINE', icon: 'pi-check', color: 'success' },
|
||||
{ label: 'Suspendre', value: 'SUSPENDU', icon: 'pi-pause', color: 'warning' },
|
||||
{ label: 'Annuler', value: 'ANNULE', icon: 'pi-times', color: 'danger' }
|
||||
],
|
||||
'SUSPENDU': [
|
||||
{ label: 'Reprendre', value: 'EN_COURS', icon: 'pi-play', color: 'info' },
|
||||
{ label: 'Annuler définitivement', value: 'ANNULE', icon: 'pi-times', color: 'danger' }
|
||||
],
|
||||
'TERMINE': [], // Statut final
|
||||
'ANNULE': [] // Statut final
|
||||
};
|
||||
|
||||
const statutsConfig = {
|
||||
'PLANIFIE': { color: 'info', icon: 'pi-calendar', label: 'Planifié' },
|
||||
'EN_COURS': { color: 'success', icon: 'pi-cog', label: 'En cours' },
|
||||
'SUSPENDU': { color: 'warning', icon: 'pi-pause', label: 'Suspendu' },
|
||||
'TERMINE': { color: 'success', icon: 'pi-check-circle', label: 'Terminé' },
|
||||
'ANNULE': { color: 'danger', icon: 'pi-times-circle', label: 'Annulé' }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadChantiers();
|
||||
loadMetriques();
|
||||
}, []);
|
||||
|
||||
const loadChantiers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Simulation données chantiers avec workflow
|
||||
const mockChantiers = [
|
||||
{
|
||||
id: '1',
|
||||
nom: 'Rénovation Villa Rousseau',
|
||||
client: 'Claire Rousseau',
|
||||
statut: 'EN_COURS',
|
||||
avancement: 65,
|
||||
dateDebut: '2025-01-15',
|
||||
dateFinPrevue: '2025-03-20',
|
||||
montantPrevu: 85000,
|
||||
montantReel: 72500,
|
||||
equipe: 'Équipe Rénovation A',
|
||||
phases: [
|
||||
{ nom: 'Démolition', statut: 'TERMINE', avancement: 100 },
|
||||
{ nom: 'Gros œuvre', statut: 'EN_COURS', avancement: 80 },
|
||||
{ nom: 'Second œuvre', statut: 'PLANIFIE', avancement: 0 },
|
||||
{ nom: 'Finitions', statut: 'PLANIFIE', avancement: 0 }
|
||||
],
|
||||
alertes: ['Retard de 3 jours sur livraison matériaux'],
|
||||
derniereMiseAJour: new Date()
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
nom: 'Extension Maison Martin',
|
||||
client: 'Sophie Martin',
|
||||
statut: 'PLANIFIE',
|
||||
avancement: 0,
|
||||
dateDebut: '2025-02-10',
|
||||
dateFinPrevue: '2025-05-15',
|
||||
montantPrevu: 45000,
|
||||
montantReel: 0,
|
||||
equipe: 'Équipe Extension B',
|
||||
phases: [
|
||||
{ nom: 'Fondations', statut: 'PLANIFIE', avancement: 0 },
|
||||
{ nom: 'Élévation', statut: 'PLANIFIE', avancement: 0 },
|
||||
{ nom: 'Couverture', statut: 'PLANIFIE', avancement: 0 },
|
||||
{ nom: 'Aménagement', statut: 'PLANIFIE', avancement: 0 }
|
||||
],
|
||||
alertes: [],
|
||||
derniereMiseAJour: new Date()
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
nom: 'Réfection Toiture Dupont',
|
||||
client: 'Jean Dupont',
|
||||
statut: 'SUSPENDU',
|
||||
avancement: 30,
|
||||
dateDebut: '2025-01-08',
|
||||
dateFinPrevue: '2025-02-28',
|
||||
montantPrevu: 28000,
|
||||
montantReel: 12000,
|
||||
equipe: 'Équipe Couverture C',
|
||||
phases: [
|
||||
{ nom: 'Dépose ancienne toiture', statut: 'TERMINE', avancement: 100 },
|
||||
{ nom: 'Charpente', statut: 'SUSPENDU', avancement: 60 },
|
||||
{ nom: 'Couverture neuve', statut: 'PLANIFIE', avancement: 0 },
|
||||
{ nom: 'Isolation', statut: 'PLANIFIE', avancement: 0 }
|
||||
],
|
||||
alertes: ['Chantier suspendu - Problème météorologique', 'Attente validation assurance'],
|
||||
derniereMiseAJour: new Date()
|
||||
}
|
||||
];
|
||||
|
||||
setChantiers(mockChantiers);
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement chantiers:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMetriques = () => {
|
||||
setMetriques({
|
||||
totalChantiers: 12,
|
||||
enCours: 5,
|
||||
planifies: 4,
|
||||
terminesRecemment: 2,
|
||||
suspendus: 1,
|
||||
tauxReussite: 92,
|
||||
delaiMoyen: -2 // Négatif = en avance
|
||||
});
|
||||
};
|
||||
|
||||
const ouvrirWorkflow = (chantier: any) => {
|
||||
setSelectedChantier(chantier);
|
||||
setNouveauStatut('');
|
||||
setCommentaire('');
|
||||
|
||||
// Charger historique du chantier
|
||||
const mockHistorique = [
|
||||
{
|
||||
date: new Date('2025-01-15T08:00:00'),
|
||||
statut: 'EN_COURS',
|
||||
utilisateur: 'M. Laurent',
|
||||
commentaire: 'Démarrage chantier - Équipe mobilisée',
|
||||
automatique: false
|
||||
},
|
||||
{
|
||||
date: new Date('2025-01-20T14:30:00'),
|
||||
statut: 'EN_COURS',
|
||||
utilisateur: 'Système',
|
||||
commentaire: 'Phase démolition terminée automatiquement',
|
||||
automatique: true
|
||||
},
|
||||
{
|
||||
date: new Date('2025-01-25T10:15:00'),
|
||||
statut: 'EN_COURS',
|
||||
utilisateur: 'Mme Petit',
|
||||
commentaire: 'Avancement gros œuvre - 50% réalisé',
|
||||
automatique: false
|
||||
}
|
||||
];
|
||||
|
||||
setHistorique(mockHistorique);
|
||||
setWorkflowDialog(true);
|
||||
};
|
||||
|
||||
const executerTransition = async () => {
|
||||
if (!selectedChantier || !nouveauStatut) return;
|
||||
|
||||
try {
|
||||
// Simuler appel API pour changer le statut
|
||||
const chantierMisAJour = {
|
||||
...selectedChantier,
|
||||
statut: nouveauStatut,
|
||||
derniereMiseAJour: new Date()
|
||||
};
|
||||
|
||||
// Logique métier spécifique selon le statut
|
||||
if (nouveauStatut === 'EN_COURS') {
|
||||
chantierMisAJour.dateDebutReel = new Date();
|
||||
} else if (nouveauStatut === 'TERMINE') {
|
||||
chantierMisAJour.dateFinReelle = new Date();
|
||||
chantierMisAJour.avancement = 100;
|
||||
}
|
||||
|
||||
// Mettre à jour la liste
|
||||
const chantiersUpdated = chantiers.map(c =>
|
||||
c.id === selectedChantier.id ? chantierMisAJour : c
|
||||
);
|
||||
setChantiers(chantiersUpdated);
|
||||
|
||||
// Ajouter à l'historique
|
||||
const nouvelleEntree = {
|
||||
date: new Date(),
|
||||
statut: nouveauStatut,
|
||||
utilisateur: 'Utilisateur actuel',
|
||||
commentaire: commentaire || `Changement vers ${statutsConfig[nouveauStatut as keyof typeof statutsConfig]?.label}`,
|
||||
automatique: false
|
||||
};
|
||||
|
||||
setHistorique([nouvelleEntree, ...historique]);
|
||||
|
||||
toast.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Transition réussie',
|
||||
detail: `Chantier "${selectedChantier.nom}" passé en statut ${statutsConfig[nouveauStatut as keyof typeof statutsConfig]?.label}`,
|
||||
life: 4000
|
||||
});
|
||||
|
||||
setWorkflowDialog(false);
|
||||
loadMetriques(); // Recalculer les métriques
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur transition:', error);
|
||||
toast.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Erreur',
|
||||
detail: 'Impossible d\'effectuer la transition',
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const statutBodyTemplate = (rowData: any) => {
|
||||
const config = statutsConfig[rowData.statut as keyof typeof statutsConfig];
|
||||
return (
|
||||
<div className="flex align-items-center gap-2">
|
||||
<i className={`pi ${config.icon} text-${config.color}`} />
|
||||
<Tag value={config.label} severity={config.color} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const avancementBodyTemplate = (rowData: any) => {
|
||||
return (
|
||||
<div className="flex align-items-center gap-2">
|
||||
<ProgressBar
|
||||
value={rowData.avancement}
|
||||
style={{ width: '100px', height: '8px' }}
|
||||
color={rowData.avancement >= 100 ? '#10B981' : rowData.avancement >= 50 ? '#F59E0B' : '#3B82F6'}
|
||||
/>
|
||||
<span className="text-sm font-medium">{rowData.avancement}%</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const alertesBodyTemplate = (rowData: any) => {
|
||||
if (!rowData.alertes || rowData.alertes.length === 0) {
|
||||
return <span className="text-color-secondary">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex align-items-center gap-2">
|
||||
<Badge value={rowData.alertes.length} severity="danger" />
|
||||
<Button
|
||||
icon="pi pi-exclamation-triangle"
|
||||
text
|
||||
size="small"
|
||||
severity="danger"
|
||||
tooltip={rowData.alertes.join(', ')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const actionsBodyTemplate = (rowData: any) => {
|
||||
const transitionsPossibles = workflowTransitions[rowData.statut as keyof typeof workflowTransitions] || [];
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
icon="pi pi-cog"
|
||||
size="small"
|
||||
severity="info"
|
||||
tooltip="Gérer workflow"
|
||||
onClick={() => ouvrirWorkflow(rowData)}
|
||||
/>
|
||||
|
||||
{transitionsPossibles.length > 0 && (
|
||||
<Button
|
||||
icon="pi pi-arrow-right"
|
||||
size="small"
|
||||
severity="success"
|
||||
tooltip="Transitions disponibles"
|
||||
onClick={() => ouvrirWorkflow(rowData)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
icon="pi pi-eye"
|
||||
size="small"
|
||||
severity="help"
|
||||
tooltip="Voir détails"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const phasesBodyTemplate = (rowData: any) => {
|
||||
const phasesTerminees = rowData.phases?.filter((p: any) => p.statut === 'TERMINE').length || 0;
|
||||
const totalPhases = rowData.phases?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="flex align-items-center gap-2">
|
||||
<span className="text-sm">{phasesTerminees}/{totalPhases}</span>
|
||||
<ProgressBar
|
||||
value={totalPhases > 0 ? (phasesTerminees / totalPhases) * 100 : 0}
|
||||
style={{ width: '60px', height: '6px' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<Toast ref={toast} />
|
||||
|
||||
{/* Métriques Workflow */}
|
||||
<div className="col-12">
|
||||
<div className="grid">
|
||||
<div className="col-12 md:col-2">
|
||||
<Card>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-500">{metriques.totalChantiers}</div>
|
||||
<div className="text-color-secondary">Total chantiers</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="col-12 md:col-2">
|
||||
<Card>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-500">{metriques.enCours}</div>
|
||||
<div className="text-color-secondary">En cours</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="col-12 md:col-2">
|
||||
<Card>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-cyan-500">{metriques.planifies}</div>
|
||||
<div className="text-color-secondary">Planifiés</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="col-12 md:col-2">
|
||||
<Card>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-orange-500">{metriques.suspendus}</div>
|
||||
<div className="text-color-secondary">Suspendus</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="col-12 md:col-2">
|
||||
<Card>
|
||||
<div className="text-center">
|
||||
<Knob
|
||||
value={metriques.tauxReussite}
|
||||
size={60}
|
||||
strokeWidth={8}
|
||||
valueColor="#10B981"
|
||||
/>
|
||||
<div className="text-color-secondary mt-2">Taux réussite</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="col-12 md:col-2">
|
||||
<Card>
|
||||
<div className="text-center">
|
||||
<div className={`text-2xl font-bold ${metriques.delaiMoyen < 0 ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{metriques.delaiMoyen > 0 ? `+${metriques.delaiMoyen}` : metriques.delaiMoyen}j
|
||||
</div>
|
||||
<div className="text-color-secondary">Délai moyen</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12">
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
{/* Tableau chantiers avec workflow */}
|
||||
<div className="col-12">
|
||||
<Card title="Gestion Workflow Chantiers">
|
||||
<DataTable
|
||||
value={chantiers}
|
||||
loading={loading}
|
||||
paginator
|
||||
rows={10}
|
||||
dataKey="id"
|
||||
emptyMessage="Aucun chantier trouvé"
|
||||
responsiveLayout="scroll"
|
||||
>
|
||||
<Column field="nom" header="Chantier" sortable style={{ minWidth: '200px' }} />
|
||||
<Column field="client" header="Client" sortable style={{ minWidth: '150px' }} />
|
||||
<Column header="Statut" body={statutBodyTemplate} sortable style={{ minWidth: '120px' }} />
|
||||
<Column header="Avancement" body={avancementBodyTemplate} style={{ minWidth: '150px' }} />
|
||||
<Column header="Phases" body={phasesBodyTemplate} style={{ minWidth: '100px' }} />
|
||||
<Column field="equipe" header="Équipe" style={{ minWidth: '150px' }} />
|
||||
<Column header="Alertes" body={alertesBodyTemplate} style={{ minWidth: '80px' }} />
|
||||
<Column
|
||||
field="montantPrevu"
|
||||
header="Budget"
|
||||
body={(rowData) => new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
notation: 'compact'
|
||||
}).format(rowData.montantPrevu)}
|
||||
style={{ minWidth: '100px' }}
|
||||
/>
|
||||
<Column body={actionsBodyTemplate} style={{ minWidth: '150px' }} />
|
||||
</DataTable>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Dialog Workflow */}
|
||||
<Dialog
|
||||
visible={workflowDialog}
|
||||
style={{ width: '90vw', height: '90vh' }}
|
||||
header={`Workflow - ${selectedChantier?.nom}`}
|
||||
modal
|
||||
onHide={() => setWorkflowDialog(false)}
|
||||
maximizable
|
||||
>
|
||||
{selectedChantier && (
|
||||
<div className="grid">
|
||||
{/* Informations chantier */}
|
||||
<div className="col-12 md:col-4">
|
||||
<Card title="Informations Chantier">
|
||||
<div className="flex flex-column gap-3">
|
||||
<div>
|
||||
<strong>Client:</strong> {selectedChantier.client}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Statut actuel:</strong>
|
||||
<Tag
|
||||
value={statutsConfig[selectedChantier.statut as keyof typeof statutsConfig]?.label}
|
||||
severity={statutsConfig[selectedChantier.statut as keyof typeof statutsConfig]?.color}
|
||||
className="ml-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Avancement:</strong>
|
||||
<ProgressBar value={selectedChantier.avancement} className="mt-2" />
|
||||
</div>
|
||||
<div>
|
||||
<strong>Équipe:</strong> {selectedChantier.equipe}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Budget:</strong> {new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(selectedChantier.montantPrevu)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Transitions disponibles */}
|
||||
<div className="col-12 md:col-4">
|
||||
<Card title="Actions Disponibles">
|
||||
<div className="flex flex-column gap-3">
|
||||
{workflowTransitions[selectedChantier.statut as keyof typeof workflowTransitions]?.map((transition) => (
|
||||
<Button
|
||||
key={transition.value}
|
||||
label={transition.label}
|
||||
icon={`pi ${transition.icon}`}
|
||||
severity={transition.color}
|
||||
className="justify-content-start"
|
||||
onClick={() => setNouveauStatut(transition.value)}
|
||||
outlined={nouveauStatut !== transition.value}
|
||||
/>
|
||||
))}
|
||||
|
||||
{workflowTransitions[selectedChantier.statut as keyof typeof workflowTransitions]?.length === 0 && (
|
||||
<div className="text-center text-color-secondary p-4">
|
||||
<i className="pi pi-lock text-3xl mb-3" />
|
||||
<div>Aucune transition disponible</div>
|
||||
<div className="text-sm">Statut final atteint</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nouveauStatut && (
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Commentaire (optionnel)
|
||||
</label>
|
||||
<InputTextarea
|
||||
value={commentaire}
|
||||
onChange={(e) => setCommentaire(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Ajoutez un commentaire sur cette transition..."
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button
|
||||
label="Confirmer la transition"
|
||||
icon="pi pi-check"
|
||||
severity="success"
|
||||
onClick={executerTransition}
|
||||
/>
|
||||
<Button
|
||||
label="Annuler"
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => {
|
||||
setNouveauStatut('');
|
||||
setCommentaire('');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Historique */}
|
||||
<div className="col-12 md:col-4">
|
||||
<Card title="Historique des Changements">
|
||||
<Timeline
|
||||
value={historique}
|
||||
align="left"
|
||||
content={(item) => (
|
||||
<div className="p-2">
|
||||
<div className="flex align-items-center gap-2 mb-1">
|
||||
<Tag
|
||||
value={statutsConfig[item.statut as keyof typeof statutsConfig]?.label}
|
||||
severity={statutsConfig[item.statut as keyof typeof statutsConfig]?.color}
|
||||
/>
|
||||
{item.automatique && <Badge value="Auto" severity="info" />}
|
||||
</div>
|
||||
<div className="text-sm text-color-secondary mb-1">
|
||||
{item.date.toLocaleString('fr-FR')}
|
||||
</div>
|
||||
<div className="text-sm font-medium mb-1">
|
||||
{item.utilisateur}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{item.commentaire}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowChantiers;
|
||||
Reference in New Issue
Block a user