- Correction des erreurs TypeScript dans userService.ts et workflowTester.ts - Ajout des propriétés manquantes aux objets User mockés - Conversion des dates de string vers objets Date - Correction des appels asynchrones et des types incompatibles - Ajout de dynamic rendering pour résoudre les erreurs useSearchParams - Enveloppement de useSearchParams dans Suspense boundary - Configuration de force-dynamic au niveau du layout principal Build réussi: 126 pages générées avec succès 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
590 lines
26 KiB
TypeScript
590 lines
26 KiB
TypeScript
'use client';
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
|
|
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' as any,
|
|
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' as any, avancement: 100 },
|
|
{ nom: 'Gros œuvre', statut: 'EN_COURS' as any, 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' as any,
|
|
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' as any, avancement: 100 },
|
|
{ nom: 'Charpente', statut: 'SUSPENDU' as any, 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' as any,
|
|
utilisateur: 'M. Laurent',
|
|
commentaire: 'Démarrage chantier - Équipe mobilisée',
|
|
automatique: false
|
|
},
|
|
{
|
|
date: new Date('2025-01-20T14:30:00'),
|
|
statut: 'EN_COURS' as any,
|
|
utilisateur: 'Système',
|
|
commentaire: 'Phase démolition terminée automatiquement',
|
|
automatique: true
|
|
},
|
|
{
|
|
date: new Date('2025-01-25T10:15:00'),
|
|
statut: 'EN_COURS' as any,
|
|
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 as any} />
|
|
</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 as any}
|
|
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" as any}
|
|
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 as any}
|
|
/>
|
|
{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;
|
|
|
|
|
|
|
|
|