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,241 @@
import React from 'react'
import { render, screen, waitFor } from '../../../../test-utils'
import userEvent from '@testing-library/user-event'
import Dashboard from '../page'
import useDashboard from '../../../../hooks/useDashboard'
// Mock des hooks
jest.mock('../../../../hooks/useDashboard')
jest.mock('../../../../components/auth/ProtectedRoute', () => {
return function MockProtectedRoute({ children }: { children: React.ReactNode }) {
return <div data-testid="protected-route">{children}</div>
}
})
const mockUseDashboard = useDashboard as jest.MockedFunction<typeof useDashboard>
const mockDashboardData = {
stats: {
totalChantiers: 12,
chiffreAffaires: 125000,
totalClients: 34,
facturesEnRetard: 3,
},
chantiersRecents: [
{
id: '1',
nom: 'Rénovation Bureau',
client: 'Entreprise ABC',
statut: 'EN_COURS' as const,
dateDebut: '2024-01-15',
montantPrevu: 45000,
},
{
id: '2',
nom: 'Construction Garage',
client: 'Client XYZ',
statut: 'PLANIFIE' as const,
dateDebut: '2024-02-01',
montantPrevu: 25000,
},
],
facturesEnRetard: [
{
id: '1',
numero: 'F2024-001',
client: 'Client A',
montant: 5000,
dateEcheance: '2024-01-01',
},
],
devisEnAttente: [
{
id: '1',
numero: 'D2024-001',
client: 'Client B',
montant: 8000,
dateCreation: '2024-01-10',
},
],
loading: false,
error: null,
refresh: jest.fn(),
}
describe('Page Dashboard', () => {
beforeEach(() => {
jest.clearAllMocks()
mockUseDashboard.mockReturnValue(mockDashboardData)
})
it('devrait afficher le dashboard avec toutes les sections', () => {
render(<Dashboard />)
expect(screen.getByTestId('protected-route')).toBeInTheDocument()
expect(screen.getByText('Tableau de Bord BTP Xpress')).toBeInTheDocument()
expect(screen.getByText('Bienvenue sur votre plateforme de gestion BTP. Voici un aperçu de vos projets en cours.')).toBeInTheDocument()
})
it('devrait afficher les cartes de statistiques', () => {
render(<Dashboard />)
expect(screen.getByText('Projets actifs')).toBeInTheDocument()
expect(screen.getByText('Budget total')).toBeInTheDocument()
expect(screen.getByText('Clients')).toBeInTheDocument()
expect(screen.getByText('Factures impayées')).toBeInTheDocument()
// Vérifier les valeurs
expect(screen.getByText('12')).toBeInTheDocument()
expect(screen.getByText('125 000 €')).toBeInTheDocument()
expect(screen.getByText('34')).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument()
})
it('devrait afficher les chantiers récents', () => {
render(<Dashboard />)
expect(screen.getByText('Rénovation Bureau')).toBeInTheDocument()
expect(screen.getByText('Construction Garage')).toBeInTheDocument()
expect(screen.getByText('Entreprise ABC')).toBeInTheDocument()
expect(screen.getByText('Client XYZ')).toBeInTheDocument()
})
it('devrait permettre d\'actualiser les données', async () => {
const user = userEvent.setup()
const mockRefresh = jest.fn()
mockUseDashboard.mockReturnValue({
...mockDashboardData,
refresh: mockRefresh,
})
render(<Dashboard />)
const refreshButton = screen.getByText('Actualiser')
await user.click(refreshButton)
expect(mockRefresh).toHaveBeenCalledTimes(1)
})
it('devrait afficher l\'état de chargement', () => {
mockUseDashboard.mockReturnValue({
...mockDashboardData,
loading: true,
})
render(<Dashboard />)
const refreshButton = screen.getByText('Actualiser')
expect(refreshButton).toHaveAttribute('data-pc-section', 'loadingicon')
})
it('devrait afficher les erreurs', () => {
mockUseDashboard.mockReturnValue({
...mockDashboardData,
error: 'Erreur de connexion au serveur',
})
render(<Dashboard />)
expect(screen.getByText('Erreur de connexion au serveur')).toBeInTheDocument()
expect(screen.getByText('Réessayer')).toBeInTheDocument()
})
it('devrait permettre de réessayer après une erreur', async () => {
const user = userEvent.setup()
const mockRefresh = jest.fn()
mockUseDashboard.mockReturnValue({
...mockDashboardData,
error: 'Erreur de connexion',
refresh: mockRefresh,
})
render(<Dashboard />)
const retryButton = screen.getByText('Réessayer')
await user.click(retryButton)
expect(mockRefresh).toHaveBeenCalledTimes(1)
})
it('devrait afficher les valeurs par défaut quand les stats sont vides', () => {
mockUseDashboard.mockReturnValue({
...mockDashboardData,
stats: null,
})
render(<Dashboard />)
// Vérifier que les valeurs par défaut (0) sont affichées
expect(screen.getByText('0')).toBeInTheDocument()
})
it('devrait afficher les icônes correctes pour chaque statistique', () => {
render(<Dashboard />)
expect(document.querySelector('.pi-building')).toBeInTheDocument()
expect(document.querySelector('.pi-euro')).toBeInTheDocument()
expect(document.querySelector('.pi-users')).toBeInTheDocument()
expect(document.querySelector('.pi-exclamation-triangle')).toBeInTheDocument()
})
it('devrait afficher les tendances sur les cartes', () => {
render(<Dashboard />)
// Vérifier que les tendances sont affichées
expect(screen.getByText('+12%')).toBeInTheDocument()
expect(screen.getByText('+8%')).toBeInTheDocument()
expect(screen.getByText('+15%')).toBeInTheDocument()
expect(screen.getByText('-5%')).toBeInTheDocument()
})
it('devrait avoir une mise en page responsive', () => {
const { container } = render(<Dashboard />)
// Vérifier la structure en grille
expect(container.querySelector('.grid')).toBeInTheDocument()
expect(container.querySelector('.col-12')).toBeInTheDocument()
expect(container.querySelector('.col-12.md\\:col-3')).toBeInTheDocument()
expect(container.querySelector('.col-12.md\\:col-8')).toBeInTheDocument()
expect(container.querySelector('.col-12.md\\:col-4')).toBeInTheDocument()
})
it('devrait gérer les actions sur les chantiers', () => {
// Spy sur console.log pour vérifier les handlers
const consoleSpy = jest.spyOn(console, 'log').mockImplementation()
render(<Dashboard />)
// Les handlers sont appelés dans le composant mais pas directement testables
// On peut vérifier leur présence dans le code
expect(consoleSpy).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('devrait utiliser le ProtectedRoute avec les bonnes permissions', () => {
render(<Dashboard />)
// Vérifier que le composant est wrappé dans ProtectedRoute
expect(screen.getByTestId('protected-route')).toBeInTheDocument()
})
it('devrait afficher un toast après actualisation', async () => {
const user = userEvent.setup()
render(<Dashboard />)
const refreshButton = screen.getByText('Actualiser')
await user.click(refreshButton)
// Le toast est géré par PrimeReact, on vérifie juste que la fonction est appelée
await waitFor(() => {
expect(mockDashboardData.refresh).toHaveBeenCalled()
})
})
it('devrait formater correctement les montants', () => {
render(<Dashboard />)
// Vérifier que le chiffre d'affaires est formaté
expect(screen.getByText('125 000 €')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,698 @@
'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 { Tag } from 'primereact/tag';
import { Button } from 'primereact/button';
import { Toast } from 'primereact/toast';
import { Dropdown } from 'primereact/dropdown';
import { Calendar } from 'primereact/calendar';
import { Badge } from 'primereact/badge';
import { Timeline } from 'primereact/timeline';
import { Message } from 'primereact/message';
import { Divider } from 'primereact/divider';
import { ProgressBar } from 'primereact/progressbar';
import { useRouter } from 'next/navigation';
interface Alerte {
id: string;
titre: string;
description: string;
type: string;
severite: string;
statut: string;
dateCreation: Date;
dateEcheance?: Date;
source: string;
entite: string;
entiteId: string;
actions: string[];
responsable?: string;
lu: boolean;
archive: boolean;
}
const DashboardAlertes = () => {
const toast = useRef<Toast>(null);
const router = useRouter();
const [alertes, setAlertes] = useState<Alerte[]>([]);
const [loading, setLoading] = useState(true);
const [selectedType, setSelectedType] = useState('');
const [selectedSeverite, setSelectedSeverite] = useState('');
const [selectedStatut, setSelectedStatut] = useState('');
const [showArchived, setShowArchived] = useState(false);
const [timelineEvents, setTimelineEvents] = useState([]);
const typeOptions = [
{ label: 'Tous les types', value: '' },
{ label: 'Sécurité', value: 'SECURITE' },
{ label: 'Maintenance', value: 'MAINTENANCE' },
{ label: 'Budget', value: 'BUDGET' },
{ label: 'Planning', value: 'PLANNING' },
{ label: 'Qualité', value: 'QUALITE' },
{ label: 'Ressources', value: 'RESSOURCES' },
{ label: 'Conformité', value: 'CONFORMITE' },
{ label: 'Système', value: 'SYSTEME' }
];
const severiteOptions = [
{ label: 'Toutes les sévérités', value: '' },
{ label: 'Critique', value: 'CRITIQUE' },
{ label: 'Élevée', value: 'ELEVEE' },
{ label: 'Moyenne', value: 'MOYENNE' },
{ label: 'Faible', value: 'FAIBLE' },
{ label: 'Info', value: 'INFO' }
];
const statutOptions = [
{ label: 'Tous les statuts', value: '' },
{ label: 'Nouvelle', value: 'NOUVELLE' },
{ label: 'En cours', value: 'EN_COURS' },
{ label: 'Résolue', value: 'RESOLUE' },
{ label: 'Fermée', value: 'FERMEE' },
{ label: 'Ignorée', value: 'IGNOREE' }
];
useEffect(() => {
loadAlertes();
initTimeline();
}, [selectedType, selectedSeverite, selectedStatut, showArchived]);
const loadAlertes = async () => {
try {
setLoading(true);
// TODO: Remplacer par un vrai appel API
// const response = await alerteService.getDashboardData({
// type: selectedType,
// severite: selectedSeverite,
// statut: selectedStatut,
// archived: showArchived
// });
// Données simulées pour la démonstration
const mockAlertes: Alerte[] = [
{
id: '1',
titre: 'Maintenance urgente requise',
description: 'La pelleteuse CAT 320D présente des signes de défaillance hydraulique',
type: 'MAINTENANCE',
severite: 'CRITIQUE',
statut: 'NOUVELLE',
dateCreation: new Date('2024-12-28T10:30:00'),
dateEcheance: new Date('2024-12-29T17:00:00'),
source: 'Système de monitoring',
entite: 'Matériel',
entiteId: 'MAT-002',
actions: ['Arrêt immédiat', 'Inspection technique', 'Réparation'],
responsable: 'Marie Martin',
lu: false,
archive: false
},
{
id: '2',
titre: 'Dépassement budget chantier',
description: 'Le chantier Résidence Les Jardins dépasse le budget prévu de 15%',
type: 'BUDGET',
severite: 'ELEVEE',
statut: 'EN_COURS',
dateCreation: new Date('2024-12-27T14:15:00'),
dateEcheance: new Date('2024-12-30T12:00:00'),
source: 'Contrôle de gestion',
entite: 'Chantier',
entiteId: 'CHT-001',
actions: ['Analyse des coûts', 'Révision budget', 'Validation client'],
responsable: 'Jean Dupont',
lu: true,
archive: false
},
{
id: '3',
titre: 'Retard de livraison matériaux',
description: 'Livraison d\'acier reportée de 3 jours pour le Centre Commercial Atlantis',
type: 'PLANNING',
severite: 'MOYENNE',
statut: 'EN_COURS',
dateCreation: new Date('2024-12-26T09:45:00'),
dateEcheance: new Date('2025-01-02T08:00:00'),
source: 'Fournisseur',
entite: 'Chantier',
entiteId: 'CHT-002',
actions: ['Replanification', 'Contact fournisseur', 'Solutions alternatives'],
responsable: 'Pierre Leroy',
lu: true,
archive: false
},
{
id: '4',
titre: 'Certification CACES expirée',
description: 'La certification CACES de Luc Bernard expire dans 7 jours',
type: 'CONFORMITE',
severite: 'ELEVEE',
statut: 'NOUVELLE',
dateCreation: new Date('2024-12-25T16:20:00'),
dateEcheance: new Date('2025-01-01T23:59:00'),
source: 'Gestion RH',
entite: 'Employé',
entiteId: 'EMP-004',
actions: ['Planifier formation', 'Restriction temporaire', 'Renouvellement'],
responsable: 'Sophie Dubois',
lu: false,
archive: false
},
{
id: '5',
titre: 'Incident sécurité mineur',
description: 'Chute d\'un ouvrier sans blessure grave sur le chantier Hôtel Luxe',
type: 'SECURITE',
severite: 'MOYENNE',
statut: 'RESOLUE',
dateCreation: new Date('2024-12-24T11:30:00'),
source: 'Chef de chantier',
entite: 'Chantier',
entiteId: 'CHT-003',
actions: ['Rapport incident', 'Analyse causes', 'Mesures préventives'],
responsable: 'Marc Rousseau',
lu: true,
archive: false
},
{
id: '6',
titre: 'Surcharge serveur de monitoring',
description: 'Le serveur de monitoring des équipements approche 90% de capacité',
type: 'SYSTEME',
severite: 'FAIBLE',
statut: 'EN_COURS',
dateCreation: new Date('2024-12-23T08:15:00'),
source: 'Système automatique',
entite: 'Infrastructure',
entiteId: 'SYS-001',
actions: ['Optimisation', 'Augmentation capacité', 'Surveillance'],
responsable: 'Admin Système',
lu: true,
archive: false
}
];
// Filtrer selon les critères sélectionnés
let filteredAlertes = mockAlertes;
if (selectedType) {
filteredAlertes = filteredAlertes.filter(a => a.type === selectedType);
}
if (selectedSeverite) {
filteredAlertes = filteredAlertes.filter(a => a.severite === selectedSeverite);
}
if (selectedStatut) {
filteredAlertes = filteredAlertes.filter(a => a.statut === selectedStatut);
}
if (!showArchived) {
filteredAlertes = filteredAlertes.filter(a => !a.archive);
}
setAlertes(filteredAlertes);
} catch (error) {
console.error('Erreur lors du chargement des alertes:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger les alertes'
});
} finally {
setLoading(false);
}
};
const initTimeline = () => {
const recentAlertes = alertes
.filter(a => a.severite === 'CRITIQUE' || a.severite === 'ELEVEE')
.sort((a, b) => b.dateCreation.getTime() - a.dateCreation.getTime())
.slice(0, 5)
.map(a => ({
status: a.severite === 'CRITIQUE' ? 'danger' : 'warning',
date: a.dateCreation.toLocaleDateString('fr-FR'),
time: a.dateCreation.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
icon: getTypeIcon(a.type),
color: getSeveriteColor(a.severite),
titre: a.titre,
description: a.description,
statut: a.statut
}));
setTimelineEvents(recentAlertes);
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'SECURITE': return 'pi pi-shield';
case 'MAINTENANCE': return 'pi pi-wrench';
case 'BUDGET': return 'pi pi-euro';
case 'PLANNING': return 'pi pi-calendar';
case 'QUALITE': return 'pi pi-star';
case 'RESSOURCES': return 'pi pi-users';
case 'CONFORMITE': return 'pi pi-verified';
case 'SYSTEME': return 'pi pi-server';
default: return 'pi pi-info-circle';
}
};
const getSeveriteColor = (severite: string) => {
switch (severite) {
case 'CRITIQUE': return '#dc2626';
case 'ELEVEE': return '#ea580c';
case 'MOYENNE': return '#d97706';
case 'FAIBLE': return '#65a30d';
case 'INFO': return '#2563eb';
default: return '#6b7280';
}
};
const getSeveriteSeverity = (severite: string) => {
switch (severite) {
case 'CRITIQUE': return 'danger';
case 'ELEVEE': return 'warning';
case 'MOYENNE': return 'info';
case 'FAIBLE': return 'success';
case 'INFO': return 'info';
default: return 'secondary';
}
};
const getStatutSeverity = (statut: string) => {
switch (statut) {
case 'NOUVELLE': return 'danger';
case 'EN_COURS': return 'warning';
case 'RESOLUE': return 'success';
case 'FERMEE': return 'secondary';
case 'IGNOREE': return 'secondary';
default: return 'info';
}
};
const getTypeSeverity = (type: string) => {
switch (type) {
case 'SECURITE': return 'danger';
case 'MAINTENANCE': return 'warning';
case 'BUDGET': return 'info';
case 'PLANNING': return 'info';
case 'QUALITE': return 'success';
case 'RESSOURCES': return 'info';
case 'CONFORMITE': return 'warning';
case 'SYSTEME': return 'secondary';
default: return 'info';
}
};
const severiteBodyTemplate = (rowData: Alerte) => (
<Tag value={rowData.severite} severity={getSeveriteSeverity(rowData.severite)} />
);
const statutBodyTemplate = (rowData: Alerte) => (
<Tag value={rowData.statut} severity={getStatutSeverity(rowData.statut)} />
);
const typeBodyTemplate = (rowData: Alerte) => (
<div className="flex align-items-center">
<i className={`${getTypeIcon(rowData.type)} mr-2`} style={{ color: getSeveriteColor(rowData.severite) }}></i>
<Tag value={rowData.type} severity={getTypeSeverity(rowData.type)} />
</div>
);
const titreBodyTemplate = (rowData: Alerte) => (
<div className="flex align-items-center">
{!rowData.lu && (
<div className="w-1rem h-1rem bg-primary border-circle mr-2"></div>
)}
<div>
<div className={`font-medium ${!rowData.lu ? 'font-bold' : ''}`}>
{rowData.titre}
</div>
<div className="text-sm text-500 mt-1">
{rowData.description.substring(0, 80)}...
</div>
</div>
</div>
);
const echeanceBodyTemplate = (rowData: Alerte) => {
if (!rowData.dateEcheance) return <span className="text-500">-</span>;
const now = new Date();
const echeance = rowData.dateEcheance;
const diffHours = (echeance.getTime() - now.getTime()) / (1000 * 60 * 60);
let severity = 'info';
if (diffHours < 0) severity = 'danger';
else if (diffHours < 24) severity = 'warning';
else if (diffHours < 72) severity = 'info';
return (
<div>
<div className="text-sm">{echeance.toLocaleDateString('fr-FR')}</div>
<Tag
value={diffHours < 0 ? 'Échue' : `${Math.ceil(diffHours)}h restantes`}
severity={severity}
className="text-xs mt-1"
/>
</div>
);
};
const actionsBodyTemplate = (rowData: Alerte) => (
<div className="flex flex-wrap gap-1">
{rowData.actions.slice(0, 2).map((action, index) => (
<Tag key={index} value={action} severity="secondary" className="text-xs" />
))}
{rowData.actions.length > 2 && (
<Tag value={`+${rowData.actions.length - 2}`} severity="info" className="text-xs" />
)}
</div>
);
const actionButtonsTemplate = (rowData: Alerte) => (
<div className="flex gap-2">
{!rowData.lu && (
<Button
icon="pi pi-eye"
className="p-button-text p-button-sm"
tooltip="Marquer comme lu"
onClick={() => handleMarkAsRead(rowData)}
/>
)}
<Button
icon="pi pi-pencil"
className="p-button-text p-button-sm"
tooltip="Traiter"
onClick={() => handleTreatAlert(rowData)}
/>
<Button
icon="pi pi-check"
className="p-button-text p-button-sm p-button-success"
tooltip="Résoudre"
onClick={() => handleResolveAlert(rowData)}
disabled={rowData.statut === 'RESOLUE' || rowData.statut === 'FERMEE'}
/>
<Button
icon="pi pi-times"
className="p-button-text p-button-sm p-button-danger"
tooltip="Ignorer"
onClick={() => handleIgnoreAlert(rowData)}
disabled={rowData.statut === 'IGNOREE'}
/>
</div>
);
const handleMarkAsRead = (alerte: Alerte) => {
// TODO: Appel API pour marquer comme lu
toast.current?.show({
severity: 'success',
summary: 'Alerte marquée comme lue',
detail: alerte.titre,
life: 3000
});
loadAlertes();
};
const handleTreatAlert = (alerte: Alerte) => {
router.push(`/alertes/${alerte.id}/traiter`);
};
const handleResolveAlert = (alerte: Alerte) => {
// TODO: Appel API pour résoudre
toast.current?.show({
severity: 'success',
summary: 'Alerte résolue',
detail: alerte.titre,
life: 3000
});
loadAlertes();
};
const handleIgnoreAlert = (alerte: Alerte) => {
// TODO: Appel API pour ignorer
toast.current?.show({
severity: 'info',
summary: 'Alerte ignorée',
detail: alerte.titre,
life: 3000
});
loadAlertes();
};
// Calculs des métriques
const alertesCritiques = alertes.filter(a => a.severite === 'CRITIQUE').length;
const alertesNonLues = alertes.filter(a => !a.lu).length;
const alertesEchues = alertes.filter(a => a.dateEcheance && a.dateEcheance < new Date()).length;
const alertesEnCours = alertes.filter(a => a.statut === 'EN_COURS').length;
return (
<div className="grid">
<Toast ref={toast} />
{/* En-tête avec filtres */}
<div className="col-12">
<Card>
<div className="flex justify-content-between align-items-center mb-4">
<h2 className="text-2xl font-bold m-0">Dashboard Alertes</h2>
<div className="flex gap-2">
<Button
label="Marquer tout comme lu"
icon="pi pi-check-circle"
className="p-button-outlined"
onClick={() => {
toast.current?.show({
severity: 'success',
summary: 'Toutes les alertes marquées comme lues',
life: 3000
});
}}
/>
<Button
label="Nouvelle alerte"
icon="pi pi-plus"
onClick={() => router.push('/alertes/nouvelle')}
/>
</div>
</div>
<div className="flex flex-wrap gap-3 align-items-center">
<div className="field">
<label htmlFor="type" className="font-semibold">Type</label>
<Dropdown
id="type"
value={selectedType}
options={typeOptions}
onChange={(e) => setSelectedType(e.value)}
className="w-full md:w-14rem"
/>
</div>
<div className="field">
<label htmlFor="severite" className="font-semibold">Sévérité</label>
<Dropdown
id="severite"
value={selectedSeverite}
options={severiteOptions}
onChange={(e) => setSelectedSeverite(e.value)}
className="w-full md:w-14rem"
/>
</div>
<div className="field">
<label htmlFor="statut" className="font-semibold">Statut</label>
<Dropdown
id="statut"
value={selectedStatut}
options={statutOptions}
onChange={(e) => setSelectedStatut(e.value)}
className="w-full md:w-14rem"
/>
</div>
<div className="field">
<div className="flex align-items-center">
<input
type="checkbox"
id="archived"
checked={showArchived}
onChange={(e) => setShowArchived(e.target.checked)}
className="mr-2"
/>
<label htmlFor="archived" className="font-semibold">Inclure archivées</label>
</div>
</div>
<Button
icon="pi pi-refresh"
className="p-button-outlined"
onClick={loadAlertes}
loading={loading}
tooltip="Actualiser"
/>
</div>
</Card>
</div>
{/* Métriques principales */}
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Critiques</span>
<div className="text-900 font-medium text-xl">{alertesCritiques}</div>
</div>
<div className="flex align-items-center justify-content-center bg-red-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
<i className="pi pi-exclamation-triangle text-red-500 text-xl"></i>
</div>
</div>
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Non lues</span>
<div className="text-900 font-medium text-xl">{alertesNonLues}</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-envelope text-blue-500 text-xl"></i>
</div>
</div>
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Échues</span>
<div className="text-900 font-medium text-xl">{alertesEchues}</div>
</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 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">En cours</span>
<div className="text-900 font-medium text-xl">{alertesEnCours}</div>
</div>
<div className="flex align-items-center justify-content-center bg-yellow-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
<i className="pi pi-cog text-yellow-500 text-xl"></i>
</div>
</div>
</Card>
</div>
{/* Alertes critiques en cours */}
{alertesCritiques > 0 && (
<div className="col-12">
<Message
severity="error"
text={`${alertesCritiques} alerte(s) critique(s) nécessitent une attention immédiate`}
className="w-full"
/>
</div>
)}
{/* Timeline des alertes récentes */}
<div className="col-12 lg:col-6">
<Card>
<h6>Alertes Récentes (Critiques & Élevées)</h6>
<Timeline
value={timelineEvents}
align="alternate"
className="customized-timeline"
marker={(item) => <span className={`flex w-2rem h-2rem align-items-center justify-content-center text-white border-circle z-1 shadow-1`} style={{ backgroundColor: item.color }}>
<i className={item.icon}></i>
</span>}
content={(item) => (
<Card title={item.titre} subTitle={`${item.date} à ${item.time}`}>
<p className="text-sm mb-2">{item.description}</p>
<Tag value={item.statut} severity={getStatutSeverity(item.statut)} />
</Card>
)}
/>
</Card>
</div>
{/* Répartition par type */}
<div className="col-12 lg:col-6">
<Card>
<h6>Répartition par Type</h6>
<div className="grid">
{typeOptions.slice(1).map((type, index) => {
const count = alertes.filter(a => a.type === type.value).length;
const percentage = alertes.length > 0 ? (count / alertes.length) * 100 : 0;
return (
<div key={index} className="col-12 mb-3">
<div className="flex justify-content-between align-items-center mb-2">
<div className="flex align-items-center">
<i className={`${getTypeIcon(type.value)} mr-2`}></i>
<span className="font-medium">{type.label}</span>
</div>
<Badge value={count} />
</div>
<ProgressBar value={percentage} showValue={false} style={{ height: '6px' }} />
</div>
);
})}
</div>
</Card>
</div>
{/* Tableau des alertes */}
<div className="col-12">
<Card>
<div className="flex justify-content-between align-items-center mb-4">
<h6>Liste des Alertes ({alertes.length})</h6>
<div className="flex gap-2">
<Badge value={`${alertesNonLues} non lues`} severity="danger" />
<Badge value={`${alertesEnCours} en cours`} severity="warning" />
</div>
</div>
<DataTable
value={alertes}
loading={loading}
responsiveLayout="scroll"
paginator
rows={10}
rowsPerPageOptions={[5, 10, 25]}
emptyMessage="Aucune alerte trouvée"
sortMode="multiple"
sortField="dateCreation"
sortOrder={-1}
>
<Column field="titre" header="Titre" body={titreBodyTemplate} sortable style={{ width: '25%' }} />
<Column field="type" header="Type" body={typeBodyTemplate} sortable />
<Column field="severite" header="Sévérité" body={severiteBodyTemplate} sortable />
<Column field="statut" header="Statut" body={statutBodyTemplate} sortable />
<Column field="dateCreation" header="Créée le" body={(rowData) => rowData.dateCreation.toLocaleDateString('fr-FR')} sortable />
<Column field="dateEcheance" header="Échéance" body={echeanceBodyTemplate} sortable />
<Column field="responsable" header="Responsable" sortable />
<Column field="actions" header="Actions suggérées" body={actionsBodyTemplate} />
<Column header="Actions" body={actionButtonsTemplate} style={{ width: '150px' }} />
</DataTable>
</Card>
</div>
</div>
);
};
export default DashboardAlertes;

View File

@@ -0,0 +1,545 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { Card } from 'primereact/card';
import { Chart } from 'primereact/chart';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { Button } from 'primereact/button';
import { Toast } from 'primereact/toast';
import { Dropdown } from 'primereact/dropdown';
import { Calendar } from 'primereact/calendar';
import { Badge } from 'primereact/badge';
import { Divider } from 'primereact/divider';
import { useRouter } from 'next/navigation';
interface ChantierDashboard {
id: string;
nom: string;
client: string;
statut: string;
avancement: number;
budget: number;
coutReel: number;
dateDebut: Date;
dateFinPrevue: Date;
nombreEmployes: number;
nombrePhases: number;
phasesTerminees: number;
retard: number;
priorite: string;
localisation: string;
}
const DashboardChantiers = () => {
const toast = useRef<Toast>(null);
const router = useRouter();
const [chantiers, setChantiers] = useState<ChantierDashboard[]>([]);
const [loading, setLoading] = useState(true);
const [selectedPeriod, setSelectedPeriod] = useState('30');
const [selectedStatut, setSelectedStatut] = useState('');
const [dateRange, setDateRange] = useState<Date[]>([]);
const [chartData, setChartData] = useState({});
const [chartOptions, setChartOptions] = useState({});
const periodOptions = [
{ label: '7 derniers jours', value: '7' },
{ label: '30 derniers jours', value: '30' },
{ label: '3 derniers mois', value: '90' },
{ label: '6 derniers mois', value: '180' },
{ label: 'Cette année', value: '365' }
];
const statutOptions = [
{ label: 'Tous les statuts', value: '' },
{ label: 'Planifié', value: 'PLANIFIE' },
{ label: 'En cours', value: 'EN_COURS' },
{ label: 'En pause', value: 'EN_PAUSE' },
{ label: 'Terminé', value: 'TERMINE' },
{ label: 'Annulé', value: 'ANNULE' }
];
useEffect(() => {
loadChantiers();
initCharts();
}, [selectedPeriod, selectedStatut, dateRange]);
const loadChantiers = async () => {
try {
setLoading(true);
// TODO: Remplacer par un vrai appel API
// const response = await chantierService.getDashboardData({
// period: selectedPeriod,
// statut: selectedStatut,
// dateRange
// });
// Données simulées pour la démonstration
const mockChantiers: ChantierDashboard[] = [
{
id: '1',
nom: 'Résidence Les Jardins',
client: 'Immobilière Moderne',
statut: 'EN_COURS',
avancement: 75,
budget: 850000,
coutReel: 620000,
dateDebut: new Date('2024-03-15'),
dateFinPrevue: new Date('2024-12-20'),
nombreEmployes: 12,
nombrePhases: 8,
phasesTerminees: 6,
retard: 0,
priorite: 'HAUTE',
localisation: 'Paris 15ème'
},
{
id: '2',
nom: 'Centre Commercial Atlantis',
client: 'Groupe Retail Plus',
statut: 'EN_COURS',
avancement: 45,
budget: 2500000,
coutReel: 1200000,
dateDebut: new Date('2024-01-10'),
dateFinPrevue: new Date('2025-06-30'),
nombreEmployes: 25,
nombrePhases: 12,
phasesTerminees: 5,
retard: 15,
priorite: 'CRITIQUE',
localisation: 'Nanterre'
},
{
id: '3',
nom: 'Rénovation Hôtel Luxe',
client: 'Hôtels Prestige',
statut: 'EN_PAUSE',
avancement: 30,
budget: 1200000,
coutReel: 380000,
dateDebut: new Date('2024-02-01'),
dateFinPrevue: new Date('2024-11-15'),
nombreEmployes: 8,
nombrePhases: 10,
phasesTerminees: 3,
retard: 45,
priorite: 'MOYENNE',
localisation: 'Lyon'
},
{
id: '4',
nom: 'Usine Pharmaceutique',
client: 'PharmaFrance',
statut: 'PLANIFIE',
avancement: 0,
budget: 3200000,
coutReel: 0,
dateDebut: new Date('2025-02-01'),
dateFinPrevue: new Date('2026-08-30'),
nombreEmployes: 0,
nombrePhases: 15,
phasesTerminees: 0,
retard: 0,
priorite: 'HAUTE',
localisation: 'Marseille'
},
{
id: '5',
nom: 'Pont Autoroutier A86',
client: 'Ministère des Transports',
statut: 'TERMINE',
avancement: 100,
budget: 4500000,
coutReel: 4350000,
dateDebut: new Date('2023-06-01'),
dateFinPrevue: new Date('2024-10-31'),
nombreEmployes: 0,
nombrePhases: 20,
phasesTerminees: 20,
retard: -10,
priorite: 'CRITIQUE',
localisation: 'Île-de-France'
}
];
// Filtrer selon les critères sélectionnés
let filteredChantiers = mockChantiers;
if (selectedStatut) {
filteredChantiers = filteredChantiers.filter(c => c.statut === selectedStatut);
}
setChantiers(filteredChantiers);
} catch (error) {
console.error('Erreur lors du chargement des chantiers:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger les données des chantiers'
});
} finally {
setLoading(false);
}
};
const initCharts = () => {
const documentStyle = getComputedStyle(document.documentElement);
const textColor = documentStyle.getPropertyValue('--text-color');
const textColorSecondary = documentStyle.getPropertyValue('--text-color-secondary');
const surfaceBorder = documentStyle.getPropertyValue('--surface-border');
// Graphique de répartition par statut
const statutData = {
labels: ['En cours', 'Planifié', 'En pause', 'Terminé', 'Annulé'],
datasets: [
{
data: [
chantiers.filter(c => c.statut === 'EN_COURS').length,
chantiers.filter(c => c.statut === 'PLANIFIE').length,
chantiers.filter(c => c.statut === 'EN_PAUSE').length,
chantiers.filter(c => c.statut === 'TERMINE').length,
chantiers.filter(c => c.statut === 'ANNULE').length
],
backgroundColor: [
documentStyle.getPropertyValue('--green-500'),
documentStyle.getPropertyValue('--blue-500'),
documentStyle.getPropertyValue('--orange-500'),
documentStyle.getPropertyValue('--cyan-500'),
documentStyle.getPropertyValue('--red-500')
],
hoverBackgroundColor: [
documentStyle.getPropertyValue('--green-400'),
documentStyle.getPropertyValue('--blue-400'),
documentStyle.getPropertyValue('--orange-400'),
documentStyle.getPropertyValue('--cyan-400'),
documentStyle.getPropertyValue('--red-400')
]
}
]
};
const options = {
plugins: {
legend: {
labels: {
usePointStyle: true,
color: textColor
}
}
}
};
setChartData(statutData);
setChartOptions(options);
};
const getStatutSeverity = (statut: string) => {
switch (statut) {
case 'EN_COURS': return 'success';
case 'PLANIFIE': return 'info';
case 'EN_PAUSE': return 'warning';
case 'TERMINE': return 'success';
case 'ANNULE': return 'danger';
default: return 'info';
}
};
const getPrioriteSeverity = (priorite: string) => {
switch (priorite) {
case 'CRITIQUE': return 'danger';
case 'HAUTE': return 'warning';
case 'MOYENNE': return 'info';
case 'BASSE': return 'success';
default: return 'info';
}
};
const statutBodyTemplate = (rowData: ChantierDashboard) => (
<Tag value={rowData.statut} severity={getStatutSeverity(rowData.statut)} />
);
const prioriteBodyTemplate = (rowData: ChantierDashboard) => (
<Tag value={rowData.priorite} severity={getPrioriteSeverity(rowData.priorite)} />
);
const avancementBodyTemplate = (rowData: ChantierDashboard) => (
<div className="flex align-items-center">
<ProgressBar
value={rowData.avancement}
style={{ width: '100px', marginRight: '8px' }}
showValue={false}
/>
<span className="text-sm font-semibold">{rowData.avancement}%</span>
</div>
);
const budgetBodyTemplate = (rowData: ChantierDashboard) => {
const pourcentageUtilise = (rowData.coutReel / rowData.budget) * 100;
const couleur = pourcentageUtilise > 90 ? 'text-red-500' : pourcentageUtilise > 75 ? 'text-orange-500' : 'text-green-500';
return (
<div>
<div className="font-semibold">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', notation: 'compact' }).format(rowData.budget)}
</div>
<div className={`text-sm ${couleur}`}>
{pourcentageUtilise.toFixed(1)}% utilisé
</div>
</div>
);
};
const retardBodyTemplate = (rowData: ChantierDashboard) => {
if (rowData.retard === 0) {
return <Tag value="À l'heure" severity="success" />;
} else if (rowData.retard > 0) {
return <Tag value={`+${rowData.retard}j`} severity="danger" />;
} else {
return <Tag value={`${rowData.retard}j`} severity="info" />;
}
};
const actionBodyTemplate = (rowData: ChantierDashboard) => (
<div className="flex gap-2">
<Button
icon="pi pi-eye"
className="p-button-text p-button-sm"
tooltip="Voir détails"
onClick={() => router.push(`/chantiers/${rowData.id}`)}
/>
<Button
icon="pi pi-sitemap"
className="p-button-text p-button-sm"
tooltip="Gérer phases"
onClick={() => router.push(`/chantiers/${rowData.id}/phases`)}
/>
<Button
icon="pi pi-calendar"
className="p-button-text p-button-sm"
tooltip="Planning"
onClick={() => router.push(`/planning?chantier=${rowData.id}`)}
/>
</div>
);
// Calculs des métriques
const totalBudget = chantiers.reduce((sum, c) => sum + c.budget, 0);
const totalCoutReel = chantiers.reduce((sum, c) => sum + c.coutReel, 0);
const chantiersEnRetard = chantiers.filter(c => c.retard > 0).length;
const avancementMoyen = chantiers.length > 0 ?
chantiers.reduce((sum, c) => sum + c.avancement, 0) / chantiers.length : 0;
return (
<div className="grid">
<Toast ref={toast} />
{/* En-tête avec filtres */}
<div className="col-12">
<Card>
<div className="flex justify-content-between align-items-center mb-4">
<h2 className="text-2xl font-bold m-0">Dashboard Chantiers</h2>
<Button
label="Nouveau chantier"
icon="pi pi-plus"
onClick={() => router.push('/chantiers/nouveau')}
/>
</div>
<div className="flex flex-wrap gap-3 align-items-center">
<div className="field">
<label htmlFor="period" className="font-semibold">Période</label>
<Dropdown
id="period"
value={selectedPeriod}
options={periodOptions}
onChange={(e) => setSelectedPeriod(e.value)}
className="w-full md:w-14rem"
/>
</div>
<div className="field">
<label htmlFor="statut" className="font-semibold">Statut</label>
<Dropdown
id="statut"
value={selectedStatut}
options={statutOptions}
onChange={(e) => setSelectedStatut(e.value)}
className="w-full md:w-14rem"
/>
</div>
<div className="field">
<label htmlFor="dateRange" className="font-semibold">Plage de dates</label>
<Calendar
id="dateRange"
value={dateRange}
onChange={(e) => setDateRange(e.value as Date[])}
selectionMode="range"
readOnlyInput
hideOnRangeSelection
className="w-full md:w-14rem"
placeholder="Sélectionner une période"
/>
</div>
<Button
icon="pi pi-refresh"
className="p-button-outlined"
onClick={loadChantiers}
loading={loading}
tooltip="Actualiser"
/>
</div>
</Card>
</div>
{/* Métriques principales */}
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Total Chantiers</span>
<div className="text-900 font-medium text-xl">{chantiers.length}</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-building text-blue-500 text-xl"></i>
</div>
</div>
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Budget Total</span>
<div className="text-900 font-medium text-xl">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', notation: 'compact' }).format(totalBudget)}
</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-euro text-green-500 text-xl"></i>
</div>
</div>
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">En Retard</span>
<div className="text-900 font-medium text-xl">{chantiersEnRetard}</div>
</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-exclamation-triangle text-orange-500 text-xl"></i>
</div>
</div>
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Avancement Moyen</span>
<div className="text-900 font-medium text-xl">{avancementMoyen.toFixed(1)}%</div>
</div>
<div className="flex align-items-center justify-content-center bg-cyan-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
<i className="pi pi-chart-line text-cyan-500 text-xl"></i>
</div>
</div>
</Card>
</div>
{/* Graphique de répartition */}
<div className="col-12 lg:col-6">
<Card>
<h6>Répartition par Statut</h6>
<Chart type="doughnut" data={chartData} options={chartOptions} className="w-full md:w-30rem" />
</Card>
</div>
{/* Indicateurs de performance */}
<div className="col-12 lg:col-6">
<Card>
<h6>Indicateurs de Performance</h6>
<div className="grid">
<div className="col-6">
<div className="text-center">
<div className="text-2xl font-bold text-green-500">
{((totalBudget - totalCoutReel) / totalBudget * 100).toFixed(1)}%
</div>
<div className="text-sm text-500">Économies réalisées</div>
</div>
</div>
<div className="col-6">
<div className="text-center">
<div className="text-2xl font-bold text-blue-500">
{chantiers.filter(c => c.statut === 'EN_COURS').length}
</div>
<div className="text-sm text-500">Chantiers actifs</div>
</div>
</div>
<div className="col-6">
<div className="text-center">
<div className="text-2xl font-bold text-purple-500">
{chantiers.reduce((sum, c) => sum + c.nombreEmployes, 0)}
</div>
<div className="text-sm text-500">Employés mobilisés</div>
</div>
</div>
<div className="col-6">
<div className="text-center">
<div className="text-2xl font-bold text-orange-500">
{(chantiersEnRetard / chantiers.length * 100).toFixed(1)}%
</div>
<div className="text-sm text-500">Taux de retard</div>
</div>
</div>
</div>
</Card>
</div>
{/* Tableau des chantiers */}
<div className="col-12">
<Card>
<div className="flex justify-content-between align-items-center mb-4">
<h6>Liste des Chantiers ({chantiers.length})</h6>
<Badge value={`${chantiers.filter(c => c.statut === 'EN_COURS').length} actifs`} severity="success" />
</div>
<DataTable
value={chantiers}
loading={loading}
responsiveLayout="scroll"
paginator
rows={10}
rowsPerPageOptions={[5, 10, 25]}
emptyMessage="Aucun chantier trouvé"
sortMode="multiple"
>
<Column field="nom" header="Chantier" sortable />
<Column field="client" header="Client" sortable />
<Column field="statut" header="Statut" body={statutBodyTemplate} sortable />
<Column field="priorite" header="Priorité" body={prioriteBodyTemplate} sortable />
<Column field="avancement" header="Avancement" body={avancementBodyTemplate} sortable />
<Column field="budget" header="Budget" body={budgetBodyTemplate} sortable />
<Column field="retard" header="Retard" body={retardBodyTemplate} sortable />
<Column field="localisation" header="Localisation" sortable />
<Column header="Actions" body={actionBodyTemplate} style={{ width: '120px' }} />
</DataTable>
</Card>
</div>
</div>
);
};
export default DashboardChantiers;

View File

@@ -0,0 +1,538 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { Card } from 'primereact/card';
import { Chart } from 'primereact/chart';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { Button } from 'primereact/button';
import { Toast } from 'primereact/toast';
import { Dropdown } from 'primereact/dropdown';
import { Calendar } from 'primereact/calendar';
import { Badge } from 'primereact/badge';
import { Timeline } from 'primereact/timeline';
import { useRouter } from 'next/navigation';
interface MaintenanceDashboard {
id: string;
materiel: string;
type: string;
statut: string;
priorite: string;
dateCreation: Date;
datePrevue: Date;
dateRealisation?: Date;
technicien: string;
coutEstime: number;
coutReel: number;
dureeEstimee: number;
dureeReelle?: number;
description: string;
localisation: string;
}
const DashboardMaintenance = () => {
const toast = useRef<Toast>(null);
const router = useRouter();
const [maintenances, setMaintenances] = useState<MaintenanceDashboard[]>([]);
const [loading, setLoading] = useState(true);
const [selectedType, setSelectedType] = useState('');
const [selectedStatut, setSelectedStatut] = useState('');
const [dateRange, setDateRange] = useState<Date[]>([]);
const [chartData, setChartData] = useState({});
const [chartOptions, setChartOptions] = useState({});
const [timelineEvents, setTimelineEvents] = useState([]);
const typeOptions = [
{ label: 'Tous les types', value: '' },
{ label: 'Préventive', value: 'PREVENTIVE' },
{ label: 'Corrective', value: 'CORRECTIVE' },
{ label: 'Prédictive', value: 'PREDICTIVE' },
{ label: 'Urgente', value: 'URGENTE' }
];
const statutOptions = [
{ label: 'Tous les statuts', value: '' },
{ label: 'Planifiée', value: 'PLANIFIEE' },
{ label: 'En cours', value: 'EN_COURS' },
{ label: 'Terminée', value: 'TERMINEE' },
{ label: 'Reportée', value: 'REPORTEE' },
{ label: 'Annulée', value: 'ANNULEE' }
];
useEffect(() => {
loadMaintenances();
initCharts();
initTimeline();
}, [selectedType, selectedStatut, dateRange]);
const loadMaintenances = async () => {
try {
setLoading(true);
// TODO: Remplacer par un vrai appel API
// const response = await maintenanceService.getDashboardData({
// type: selectedType,
// statut: selectedStatut,
// dateRange
// });
// Données simulées pour la démonstration
const mockMaintenances: MaintenanceDashboard[] = [
{
id: '1',
materiel: 'Grue mobile Liebherr LTM 1050',
type: 'PREVENTIVE',
statut: 'PLANIFIEE',
priorite: 'MOYENNE',
dateCreation: new Date('2024-12-15'),
datePrevue: new Date('2025-01-15'),
technicien: 'Jean Dupont',
coutEstime: 1200,
coutReel: 0,
dureeEstimee: 4,
description: 'Révision générale et contrôle sécurité',
localisation: 'Atelier Central'
},
{
id: '2',
materiel: 'Pelleteuse CAT 320D',
type: 'CORRECTIVE',
statut: 'EN_COURS',
priorite: 'HAUTE',
dateCreation: new Date('2024-12-20'),
datePrevue: new Date('2024-12-28'),
dateRealisation: new Date('2024-12-28'),
technicien: 'Marie Martin',
coutEstime: 800,
coutReel: 950,
dureeEstimee: 2,
dureeReelle: 3,
description: 'Réparation système hydraulique',
localisation: 'Chantier Résidence Les Jardins'
},
{
id: '3',
materiel: 'Bétonnière Schwing S36X',
type: 'URGENTE',
statut: 'TERMINEE',
priorite: 'CRITIQUE',
dateCreation: new Date('2024-12-18'),
datePrevue: new Date('2024-12-19'),
dateRealisation: new Date('2024-12-19'),
technicien: 'Pierre Leroy',
coutEstime: 1500,
coutReel: 1650,
dureeEstimee: 6,
dureeReelle: 8,
description: 'Remplacement pompe défaillante',
localisation: 'Chantier Centre Commercial'
},
{
id: '4',
materiel: 'Compacteur Bomag BW 213',
type: 'PREDICTIVE',
statut: 'PLANIFIEE',
priorite: 'BASSE',
dateCreation: new Date('2024-12-22'),
datePrevue: new Date('2025-02-01'),
technicien: 'Luc Bernard',
coutEstime: 600,
coutReel: 0,
dureeEstimee: 3,
description: 'Analyse vibratoire et diagnostic',
localisation: 'Atelier Central'
},
{
id: '5',
materiel: 'Nacelle Genie Z-45/25J',
type: 'PREVENTIVE',
statut: 'REPORTEE',
priorite: 'MOYENNE',
dateCreation: new Date('2024-12-10'),
datePrevue: new Date('2024-12-25'),
technicien: 'Sophie Dubois',
coutEstime: 400,
coutReel: 0,
dureeEstimee: 2,
description: 'Contrôle sécurité annuel',
localisation: 'Atelier Central'
}
];
// Filtrer selon les critères sélectionnés
let filteredMaintenances = mockMaintenances;
if (selectedType) {
filteredMaintenances = filteredMaintenances.filter(m => m.type === selectedType);
}
if (selectedStatut) {
filteredMaintenances = filteredMaintenances.filter(m => m.statut === selectedStatut);
}
setMaintenances(filteredMaintenances);
} catch (error) {
console.error('Erreur lors du chargement des maintenances:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger les données de maintenance'
});
} finally {
setLoading(false);
}
};
const initCharts = () => {
const documentStyle = getComputedStyle(document.documentElement);
// Graphique de répartition par type
const typeData = {
labels: ['Préventive', 'Corrective', 'Prédictive', 'Urgente'],
datasets: [
{
data: [
maintenances.filter(m => m.type === 'PREVENTIVE').length,
maintenances.filter(m => m.type === 'CORRECTIVE').length,
maintenances.filter(m => m.type === 'PREDICTIVE').length,
maintenances.filter(m => m.type === 'URGENTE').length
],
backgroundColor: [
documentStyle.getPropertyValue('--green-500'),
documentStyle.getPropertyValue('--orange-500'),
documentStyle.getPropertyValue('--blue-500'),
documentStyle.getPropertyValue('--red-500')
]
}
]
};
const options = {
plugins: {
legend: {
labels: {
usePointStyle: true,
color: documentStyle.getPropertyValue('--text-color')
}
}
}
};
setChartData(typeData);
setChartOptions(options);
};
const initTimeline = () => {
const events = maintenances
.filter(m => m.statut === 'PLANIFIEE' || m.statut === 'EN_COURS')
.sort((a, b) => a.datePrevue.getTime() - b.datePrevue.getTime())
.slice(0, 5)
.map(m => ({
status: m.priorite === 'CRITIQUE' ? 'danger' : m.priorite === 'HAUTE' ? 'warning' : 'info',
date: m.datePrevue.toLocaleDateString('fr-FR'),
icon: m.type === 'URGENTE' ? 'pi pi-exclamation-triangle' : 'pi pi-wrench',
color: m.priorite === 'CRITIQUE' ? '#e74c3c' : m.priorite === 'HAUTE' ? '#f39c12' : '#3498db',
materiel: m.materiel,
type: m.type,
technicien: m.technicien
}));
setTimelineEvents(events);
};
const getStatutSeverity = (statut: string) => {
switch (statut) {
case 'PLANIFIEE': return 'info';
case 'EN_COURS': return 'warning';
case 'TERMINEE': return 'success';
case 'REPORTEE': return 'warning';
case 'ANNULEE': return 'danger';
default: return 'info';
}
};
const getPrioriteSeverity = (priorite: string) => {
switch (priorite) {
case 'CRITIQUE': return 'danger';
case 'HAUTE': return 'warning';
case 'MOYENNE': return 'info';
case 'BASSE': return 'success';
default: return 'info';
}
};
const getTypeSeverity = (type: string) => {
switch (type) {
case 'URGENTE': return 'danger';
case 'CORRECTIVE': return 'warning';
case 'PREVENTIVE': return 'success';
case 'PREDICTIVE': return 'info';
default: return 'info';
}
};
const statutBodyTemplate = (rowData: MaintenanceDashboard) => (
<Tag value={rowData.statut} severity={getStatutSeverity(rowData.statut)} />
);
const prioriteBodyTemplate = (rowData: MaintenanceDashboard) => (
<Tag value={rowData.priorite} severity={getPrioriteSeverity(rowData.priorite)} />
);
const typeBodyTemplate = (rowData: MaintenanceDashboard) => (
<Tag value={rowData.type} severity={getTypeSeverity(rowData.type)} />
);
const coutBodyTemplate = (rowData: MaintenanceDashboard) => {
const ecart = rowData.coutReel - rowData.coutEstime;
const couleur = ecart > 0 ? 'text-red-500' : ecart < 0 ? 'text-green-500' : 'text-gray-500';
return (
<div>
<div className="font-semibold">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(rowData.coutEstime)}
</div>
{rowData.coutReel > 0 && (
<div className={`text-sm ${couleur}`}>
Réel: {new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(rowData.coutReel)}
</div>
)}
</div>
);
};
const dureeBodyTemplate = (rowData: MaintenanceDashboard) => (
<div>
<div className="font-semibold">{rowData.dureeEstimee}h</div>
{rowData.dureeReelle && (
<div className={`text-sm ${rowData.dureeReelle > rowData.dureeEstimee ? 'text-red-500' : 'text-green-500'}`}>
Réel: {rowData.dureeReelle}h
</div>
)}
</div>
);
const actionBodyTemplate = (rowData: MaintenanceDashboard) => (
<div className="flex gap-2">
<Button
icon="pi pi-eye"
className="p-button-text p-button-sm"
tooltip="Voir détails"
onClick={() => router.push(`/maintenance/${rowData.id}`)}
/>
<Button
icon="pi pi-pencil"
className="p-button-text p-button-sm"
tooltip="Modifier"
onClick={() => router.push(`/maintenance/${rowData.id}/edit`)}
/>
<Button
icon="pi pi-calendar"
className="p-button-text p-button-sm"
tooltip="Planifier"
onClick={() => router.push(`/maintenance/planification?materiel=${rowData.materiel}`)}
/>
</div>
);
// Calculs des métriques
const totalCoutEstime = maintenances.reduce((sum, m) => sum + m.coutEstime, 0);
const totalCoutReel = maintenances.reduce((sum, m) => sum + (m.coutReel || 0), 0);
const maintenancesUrgentes = maintenances.filter(m => m.priorite === 'CRITIQUE' || m.type === 'URGENTE').length;
const tauxRealisation = maintenances.length > 0 ?
(maintenances.filter(m => m.statut === 'TERMINEE').length / maintenances.length) * 100 : 0;
return (
<div className="grid">
<Toast ref={toast} />
{/* En-tête avec filtres */}
<div className="col-12">
<Card>
<div className="flex justify-content-between align-items-center mb-4">
<h2 className="text-2xl font-bold m-0">Dashboard Maintenance</h2>
<Button
label="Nouvelle maintenance"
icon="pi pi-plus"
onClick={() => router.push('/maintenance/nouveau')}
/>
</div>
<div className="flex flex-wrap gap-3 align-items-center">
<div className="field">
<label htmlFor="type" className="font-semibold">Type</label>
<Dropdown
id="type"
value={selectedType}
options={typeOptions}
onChange={(e) => setSelectedType(e.value)}
className="w-full md:w-14rem"
/>
</div>
<div className="field">
<label htmlFor="statut" className="font-semibold">Statut</label>
<Dropdown
id="statut"
value={selectedStatut}
options={statutOptions}
onChange={(e) => setSelectedStatut(e.value)}
className="w-full md:w-14rem"
/>
</div>
<div className="field">
<label htmlFor="dateRange" className="font-semibold">Plage de dates</label>
<Calendar
id="dateRange"
value={dateRange}
onChange={(e) => setDateRange(e.value as Date[])}
selectionMode="range"
readOnlyInput
hideOnRangeSelection
className="w-full md:w-14rem"
placeholder="Sélectionner une période"
/>
</div>
<Button
icon="pi pi-refresh"
className="p-button-outlined"
onClick={loadMaintenances}
loading={loading}
tooltip="Actualiser"
/>
</div>
</Card>
</div>
{/* Métriques principales */}
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Total Maintenances</span>
<div className="text-900 font-medium text-xl">{maintenances.length}</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-wrench text-blue-500 text-xl"></i>
</div>
</div>
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Coût Total</span>
<div className="text-900 font-medium text-xl">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', notation: 'compact' }).format(totalCoutEstime)}
</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-euro text-green-500 text-xl"></i>
</div>
</div>
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Urgentes</span>
<div className="text-900 font-medium text-xl">{maintenancesUrgentes}</div>
</div>
<div className="flex align-items-center justify-content-center bg-red-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
<i className="pi pi-exclamation-triangle text-red-500 text-xl"></i>
</div>
</div>
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Taux Réalisation</span>
<div className="text-900 font-medium text-xl">{tauxRealisation.toFixed(1)}%</div>
</div>
<div className="flex align-items-center justify-content-center bg-cyan-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
<i className="pi pi-check-circle text-cyan-500 text-xl"></i>
</div>
</div>
</Card>
</div>
{/* Graphique et Timeline */}
<div className="col-12 lg:col-6">
<Card>
<h6>Répartition par Type</h6>
<Chart type="doughnut" data={chartData} options={chartOptions} className="w-full md:w-30rem" />
</Card>
</div>
<div className="col-12 lg:col-6">
<Card>
<h6>Prochaines Maintenances</h6>
<Timeline
value={timelineEvents}
align="alternate"
className="customized-timeline"
marker={(item) => <span className={`flex w-2rem h-2rem align-items-center justify-content-center text-white border-circle z-1 shadow-1`} style={{ backgroundColor: item.color }}>
<i className={item.icon}></i>
</span>}
content={(item) => (
<Card title={item.materiel} subTitle={item.date}>
<p className="text-sm">
<strong>Type:</strong> {item.type}<br/>
<strong>Technicien:</strong> {item.technicien}
</p>
</Card>
)}
/>
</Card>
</div>
{/* Tableau des maintenances */}
<div className="col-12">
<Card>
<div className="flex justify-content-between align-items-center mb-4">
<h6>Liste des Maintenances ({maintenances.length})</h6>
<Badge value={`${maintenances.filter(m => m.statut === 'EN_COURS').length} en cours`} severity="warning" />
</div>
<DataTable
value={maintenances}
loading={loading}
responsiveLayout="scroll"
paginator
rows={10}
rowsPerPageOptions={[5, 10, 25]}
emptyMessage="Aucune maintenance trouvée"
sortMode="multiple"
>
<Column field="materiel" header="Matériel" sortable />
<Column field="type" header="Type" body={typeBodyTemplate} sortable />
<Column field="statut" header="Statut" body={statutBodyTemplate} sortable />
<Column field="priorite" header="Priorité" body={prioriteBodyTemplate} sortable />
<Column field="datePrevue" header="Date prévue" body={(rowData) => rowData.datePrevue.toLocaleDateString('fr-FR')} sortable />
<Column field="technicien" header="Technicien" sortable />
<Column field="coutEstime" header="Coût" body={coutBodyTemplate} sortable />
<Column field="dureeEstimee" header="Durée" body={dureeBodyTemplate} sortable />
<Column header="Actions" body={actionBodyTemplate} style={{ width: '120px' }} />
</DataTable>
</Card>
</div>
</div>
);
};
export default DashboardMaintenance;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,834 @@
'use client';
import React, { useContext, useRef, useState, useEffect, useCallback, useMemo } from 'react';
import { Button } from 'primereact/button';
import { Column } from 'primereact/column';
import { DataTable } from 'primereact/datatable';
import { Avatar } from 'primereact/avatar';
import { Toast } from 'primereact/toast';
import { Dialog } from 'primereact/dialog';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { confirmDialog, ConfirmDialog } from 'primereact/confirmdialog';
import { useRouter, useSearchParams } from 'next/navigation';
import { ProgressSpinner } from 'primereact/progressspinner';
import { Message } from 'primereact/message';
import { LayoutContext } from '../../../layout/context/layoutcontext';
import { useDashboard, ChantierActif } from '../../../hooks/useDashboard';
import { useChantierActions } from '../../../hooks/useChantierActions';
import {
ChantierStatusBadge,
ChantierProgressBar,
ChantierUrgencyIndicator
} from '../../../components/chantiers';
import ActionButtonGroup from '../../../components/chantiers/ActionButtonGroup';
import { ActionButtonType } from '../../../components/chantiers/ActionButtonStyles';
import CFASymbol from '../../../components/ui/CFASymbol';
const Dashboard = () => {
console.log('🏗️ Dashboard: Composant chargé');
const { layoutConfig } = useContext(LayoutContext);
const toast = useRef<Toast>(null);
const router = useRouter();
const searchParams = useSearchParams();
const [selectedChantier, setSelectedChantier] = useState<ChantierActif | null>(null);
const [showQuickView, setShowQuickView] = useState(false);
const [authProcessed, setAuthProcessed] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
const [authInProgress, setAuthInProgress] = useState(false);
// Flag global pour éviter les appels multiples (React 18 StrictMode)
const authProcessingRef = useRef(false);
// Mémoriser le code traité pour éviter les retraitements
const processedCodeRef = useRef<string | null>(null);
const currentCode = searchParams.get('code');
const currentState = searchParams.get('state');
console.log('🏗️ Dashboard: SearchParams:', {
code: currentCode?.substring(0, 20) + '...',
state: currentState,
authProcessed,
processedCode: processedCodeRef.current?.substring(0, 20) + '...',
authInProgress: authInProgress
});
// Réinitialiser authProcessed si on a un nouveau code d'autorisation
useEffect(() => {
if (currentCode && authProcessed && !authInProgress && processedCodeRef.current !== currentCode) {
console.log('🔄 Dashboard: Nouveau code détecté, réinitialisation authProcessed');
setAuthProcessed(false);
processedCodeRef.current = null;
}
}, [currentCode, authProcessed, authInProgress]);
// Fonction pour nettoyer l'URL des paramètres d'authentification
const cleanAuthParams = useCallback(() => {
const url = new URL(window.location.href);
if (url.searchParams.has('code') || url.searchParams.has('state')) {
url.search = '';
window.history.replaceState({}, '', url.toString());
}
}, []);
// Hooks pour les données et actions du dashboard
const {
metrics,
chantiersActifs,
loading,
error,
refresh
} = useDashboard();
const chantierActions = useChantierActions({
toast,
onRefresh: refresh
});
// Optimisations avec useMemo pour les calculs coûteux
const formattedMetrics = useMemo(() => {
if (!metrics) return null;
return {
chantiersActifs: metrics.chantiersActifs || 0,
chiffreAffaires: new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
notation: 'compact',
maximumFractionDigits: 1
}).format(metrics.chiffreAffaires || 0),
chantiersEnRetard: metrics.chantiersEnRetard || 0,
tauxReussite: `${metrics.tauxReussite || 0}%`
};
}, [metrics]);
const chantiersActifsCount = useMemo(() => {
return chantiersActifs?.length || 0;
}, [chantiersActifs]);
// Templates DataTable optimisés avec useCallback
const chantierBodyTemplate = useCallback((rowData: ChantierActif) => {
return (
<div className="flex align-items-center">
<ChantierUrgencyIndicator chantier={rowData} className="mr-2" />
<div className="mr-2">
<i className="pi pi-building text-primary"></i>
</div>
<div>
<div className="font-medium">{rowData.nom}</div>
<div className="text-500 text-sm">ID: {rowData.id?.substring(0, 8)}</div>
</div>
</div>
);
}, []);
const clientBodyTemplate = useCallback((rowData: ChantierActif) => {
const clientName = typeof rowData.client === 'string' ? rowData.client : rowData.client?.nom || 'N/A';
return (
<div className="flex align-items-center">
<Avatar label={clientName.charAt(0)} size="normal" shape="circle" className="mr-2" />
<span>{clientName}</span>
</div>
);
}, []);
const statutBodyTemplate = useCallback((rowData: ChantierActif) => {
return <ChantierStatusBadge statut={rowData.statut} />;
}, []);
const avancementBodyTemplate = useCallback((rowData: ChantierActif) => {
return (
<div className="flex align-items-center justify-content-center" style={{ minWidth: '90px' }}>
<ChantierProgressBar
value={rowData.avancement || 0}
showCompletionIcon={false}
showPercentage={false}
showValue={false}
size="small"
style={{ width: '75px' }}
/>
<span className="ml-2 text-xs font-semibold text-gray-600">
{rowData.avancement || 0}%
</span>
</div>
);
}, []);
const budgetBodyTemplate = useCallback((rowData: ChantierActif) => {
if (!rowData.budget) return <span className="text-500">-</span>;
return (
<span className="font-semibold flex align-items-center">
{new Intl.NumberFormat('fr-FR', {
style: 'decimal',
notation: 'compact',
maximumFractionDigits: 1
}).format(rowData.budget)}
<CFASymbol size="small" className="ml-1" />
</span>
);
}, []);
const handleQuickView = useCallback((chantier: ChantierActif) => {
setSelectedChantier(chantier);
setShowQuickView(true);
// Le hook gère déjà les détails supplémentaires
chantierActions.handleQuickView(chantier);
}, [chantierActions]);
// Nettoyer les paramètres d'authentification au montage
useEffect(() => {
cleanAuthParams();
}, [cleanAuthParams]);
// Traiter l'authentification Keycloak si nécessaire
useEffect(() => {
// Si l'authentification est déjà terminée, ne rien faire
if (authProcessed) {
return;
}
const processAuth = async () => {
// Protection absolue contre les boucles
if (authInProgress || authProcessingRef.current) {
console.log('🛑 Dashboard: Processus d\'authentification déjà en cours, arrêt');
return;
}
// Vérifier si on a déjà des tokens valides
const hasTokens = localStorage.getItem('accessToken');
if (hasTokens) {
console.log('✅ Tokens déjà présents, arrêt du processus d\'authentification');
setAuthProcessed(true);
return;
}
const code = currentCode;
const state = currentState;
const error = searchParams.get('error');
if (error) {
setAuthError(`Erreur d'authentification: ${error}`);
setAuthProcessed(true);
return;
}
// Vérifier si ce code a déjà été traité
if (code && !authProcessed && !authInProgress && !authProcessingRef.current && processedCodeRef.current !== code) {
try {
console.log('🔐 Traitement du code d\'autorisation Keycloak...', { code: code.substring(0, 20) + '...', state });
// Marquer l'authentification comme en cours pour éviter les appels multiples
authProcessingRef.current = true;
processedCodeRef.current = code;
setAuthInProgress(true);
// Nettoyer les anciens tokens avant l'échange
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('idToken');
console.log('📡 Appel API /api/auth/token...');
const response = await fetch('/api/auth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code, state }),
});
console.log('📡 Réponse API /api/auth/token:', {
status: response.status,
ok: response.ok,
statusText: response.statusText
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Échec de l\'échange de token:', {
status: response.status,
error: errorText
});
// Gestion spécifique des codes expirés, invalides ou code verifier manquant
if (errorText.includes('invalid_grant') ||
errorText.includes('Code not valid') ||
errorText.includes('Code verifier manquant')) {
console.log('🔄 Problème d\'authentification détecté:', errorText);
// Vérifier si on n'est pas déjà en boucle
const retryCount = parseInt(localStorage.getItem('auth_retry_count') || '0');
if (retryCount >= 3) {
console.error('🚫 Trop de tentatives d\'authentification. Arrêt pour éviter la boucle infinie.');
localStorage.removeItem('auth_retry_count');
setAuthError('Erreur d\'authentification persistante. Veuillez rafraîchir la page.');
return;
}
localStorage.setItem('auth_retry_count', (retryCount + 1).toString());
console.log(`🔄 Tentative ${retryCount + 1}/3 - Redirection vers nouvelle authentification...`);
// Nettoyer l'URL et rediriger vers une nouvelle authentification
const url = new URL(window.location.href);
url.search = '';
window.history.replaceState({}, '', url.toString());
// Attendre un peu pour éviter les boucles infinies
setTimeout(() => {
window.location.href = '/api/auth/login';
}, 2000);
return;
}
throw new Error(`Échec de l'échange de token: ${errorText}`);
}
const tokens = await response.json();
console.log('✅ Tokens reçus dans le dashboard:', {
hasAccessToken: !!tokens.access_token,
hasRefreshToken: !!tokens.refresh_token,
hasIdToken: !!tokens.id_token
});
// Réinitialiser le compteur de tentatives d'authentification
localStorage.removeItem('auth_retry_count');
// Stocker les tokens
if (tokens.access_token) {
localStorage.setItem('accessToken', tokens.access_token);
localStorage.setItem('refreshToken', tokens.refresh_token);
localStorage.setItem('idToken', tokens.id_token);
// Stocker aussi dans un cookie pour le middleware
document.cookie = `keycloak-token=${tokens.access_token}; path=/; max-age=3600; SameSite=Lax`;
console.log('✅ Tokens stockés avec succès');
}
setAuthProcessed(true);
setAuthInProgress(false);
authProcessingRef.current = false;
// Vérifier s'il y a une URL de retour sauvegardée
const returnUrl = localStorage.getItem('returnUrl');
if (returnUrl && returnUrl !== '/dashboard') {
console.log('🔄 Dashboard: Redirection vers la page d\'origine:', returnUrl);
localStorage.removeItem('returnUrl');
window.location.href = returnUrl;
return;
}
// Nettoyer l'URL IMMÉDIATEMENT et arrêter tout traitement futur
console.log('🧹 Dashboard: Nettoyage de l\'URL...');
window.history.replaceState({}, document.title, '/dashboard');
// Charger les données du dashboard
console.log('🔄 Dashboard: Chargement des données...');
refresh();
// Arrêter définitivement le processus d'authentification
return;
} catch (error) {
console.error('❌ Erreur lors du traitement de l\'authentification:', error);
// ARRÊTER LA BOUCLE : Ne pas rediriger automatiquement, juste marquer comme traité
console.log('🛑 Dashboard: Erreur d\'authentification, arrêt du processus pour éviter la boucle');
setAuthError(`Erreur lors de l'authentification: ${error.message}`);
setAuthProcessed(true);
setAuthInProgress(false);
authProcessingRef.current = false;
}
} else {
setAuthProcessed(true);
setAuthInProgress(false);
authProcessingRef.current = false;
}
};
processAuth();
}, [currentCode, currentState, authProcessed, authInProgress, refresh]);
const actionBodyTemplate = useCallback((rowData: ChantierActif) => {
const actions: ActionButtonType[] = ['VIEW', 'PHASES', 'PLANNING', 'STATS', 'MENU'];
const handleActionClick = (action: ActionButtonType | string, chantier: ChantierActif) => {
switch (action) {
case 'VIEW':
handleQuickView(chantier);
break;
case 'PHASES':
router.push(`/chantiers/${chantier.id}/phases`);
break;
case 'PLANNING':
router.push(`/planning?chantier=${chantier.id}`);
break;
case 'STATS':
chantierActions.handleViewStats(chantier);
break;
case 'MENU':
// Le menu sera géré directement par ChantierMenuActions
break;
// Actions du menu "Plus d'actions"
case 'suspend':
chantierActions.handleSuspendChantier(chantier);
break;
case 'close':
chantierActions.handleCloseChantier(chantier);
break;
case 'notify-client':
chantierActions.handleNotifyClient(chantier);
break;
case 'generate-report':
chantierActions.handleGenerateReport(chantier);
break;
case 'generate-invoice':
chantierActions.handleGenerateInvoice(chantier);
break;
case 'create-amendment':
chantierActions.handleCreateAmendment(chantier);
break;
default:
break;
}
};
return (
<div className="flex justify-content-center">
<ActionButtonGroup
chantier={rowData}
actions={actions}
onAction={handleActionClick}
size="sm"
spacing="sm"
/>
</div>
);
}, [handleQuickView, router, chantierActions]);
// Afficher le chargement pendant le traitement de l'authentification
if (!authProcessed) {
return (
<div className="grid">
<div className="col-12">
<div className="card">
<div className="text-center">
<ProgressSpinner style={{ width: '50px', height: '50px' }} />
<h5 className="mt-3">Authentification en cours...</h5>
<p className="text-600">Traitement des informations de connexion</p>
</div>
</div>
</div>
</div>
);
}
// Afficher l'erreur d'authentification
if (authError) {
return (
<div className="grid">
<div className="col-12">
<div className="card">
<div className="text-center">
<i className="pi pi-exclamation-triangle text-red-500" style={{ fontSize: '4rem' }}></i>
<h5 className="text-red-500">Erreur d'authentification</h5>
<p className="text-600 mb-4">{authError}</p>
<Button
label="Retour à la connexion"
icon="pi pi-sign-in"
onClick={() => window.location.href = '/api/auth/login'}
className="p-button-outlined"
/>
</div>
</div>
</div>
</div>
);
}
if (loading) {
return (
<div className="grid">
<div className="col-12">
<div className="card">
<div className="text-center">
<ProgressSpinner style={{ width: '50px', height: '50px' }} />
<h5 className="mt-3">Chargement des données...</h5>
<p className="text-600">Récupération des informations du dashboard</p>
</div>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="grid">
<div className="col-12">
<div className="card">
<div className="text-center">
<i className="pi pi-exclamation-triangle text-orange-500" style={{ fontSize: '4rem' }}></i>
<h5>Erreur de chargement</h5>
<p>{error}</p>
<Button
label="Réessayer"
icon="pi pi-refresh"
onClick={refresh}
className="p-button-text p-button-rounded"
/>
</div>
</div>
</div>
</div>
);
}
return (
<div className="grid">
<Toast ref={toast} />
<ConfirmDialog />
{/* En-tête du dashboard */}
<div className="col-12">
<div className="card">
<div className="flex justify-content-between align-items-center">
<h5>Dashboard BTPXpress</h5>
<Button
label="Actualiser"
icon="pi pi-refresh"
onClick={refresh}
className="p-button-outlined p-button-sm"
/>
</div>
</div>
</div>
{/* Métriques KPI - Style Atlantis */}
<div className="col-12 lg:col-3 md:col-6">
<div className="card mb-0">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Chantiers Actifs</span>
<div className="text-900 font-medium text-xl">
{loading ? '...' : (formattedMetrics?.chantiersActifs || 0)}
</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-building text-blue-500 text-xl"></i>
</div>
</div>
</div>
</div>
<div className="col-12 lg:col-3 md:col-6">
<div className="card mb-0">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">CA Réalisé</span>
<div className="text-900 font-medium text-xl">
<div className="flex align-items-center">
{loading ? '...' : (formattedMetrics?.chiffreAffaires || '0')}
{!loading && <CFASymbol size="small" className="ml-1" />}
</div>
</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-euro text-green-500 text-xl"></i>
</div>
</div>
</div>
</div>
<div className="col-12 lg:col-3 md:col-6">
<div className="card mb-0">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">En Retard</span>
<div className="text-900 font-medium text-xl">
{loading ? '...' : (formattedMetrics?.chantiersEnRetard || 0)}
</div>
</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-exclamation-triangle text-orange-500 text-xl"></i>
</div>
</div>
</div>
</div>
<div className="col-12 lg:col-3 md:col-6">
<div className="card mb-0">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Taux Réussite</span>
<div className="text-900 font-medium text-xl">
{loading ? '...' : (formattedMetrics?.tauxReussite || '0%')}
</div>
</div>
<div className="flex align-items-center justify-content-center bg-cyan-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
<i className="pi pi-chart-line text-cyan-500 text-xl"></i>
</div>
</div>
</div>
</div>
{/* Tableau des chantiers actifs */}
<div className="col-12">
<div className="card">
<div className="flex justify-content-between align-items-center mb-5">
<h5>Chantiers Actifs ({chantiersActifsCount})</h5>
<Button
icon="pi pi-refresh"
className="p-button-text p-button-rounded"
onClick={refresh}
loading={loading}
/>
</div>
<DataTable
value={chantiersActifs || []}
paginator
rows={10}
className="p-datatable-customers"
emptyMessage="Aucun chantier actif trouvé"
loading={loading}
responsiveLayout="scroll"
>
<Column
field="nom"
header="Chantier"
body={chantierBodyTemplate}
sortable
/>
<Column
field="client"
header="Client"
body={clientBodyTemplate}
sortable
/>
<Column
field="statut"
header="Statut"
body={statutBodyTemplate}
sortable
/>
<Column
field="avancement"
header="Avancement"
body={avancementBodyTemplate}
style={{ width: '9rem', textAlign: 'center' }}
sortable
/>
<Column
field="budget"
header="Budget"
body={budgetBodyTemplate}
sortable
/>
<Column
header="Actions"
body={actionBodyTemplate}
style={{ width: '10rem', maxWidth: '10rem', textAlign: 'center' }}
frozen
alignFrozen="right"
/>
</DataTable>
</div>
</div>
{/* Dialog Vue Rapide */}
<Dialog
header={`Vue rapide : ${selectedChantier?.nom || ''}`}
visible={showQuickView}
onHide={() => setShowQuickView(false)}
style={{ width: '60vw' }}
breakpoints={{ '960px': '75vw', '641px': '100vw' }}
>
{selectedChantier && (() => {
// Calculs statistiques depuis les données existantes
const dateDebut = selectedChantier.dateDebut ? new Date(selectedChantier.dateDebut) : null;
const dateFinPrevue = selectedChantier.dateFinPrevue ? new Date(selectedChantier.dateFinPrevue) : null;
const aujourd_hui = new Date();
const joursEcoules = dateDebut ?
Math.floor((aujourd_hui.getTime() - dateDebut.getTime()) / (1000 * 60 * 60 * 24)) : 0;
const joursRestants = dateFinPrevue ?
Math.max(0, Math.floor((dateFinPrevue.getTime() - aujourd_hui.getTime()) / (1000 * 60 * 60 * 24))) : 0;
const dureeTotal = dateDebut && dateFinPrevue ?
Math.floor((dateFinPrevue.getTime() - dateDebut.getTime()) / (1000 * 60 * 60 * 24)) : 0;
const tauxDepense = selectedChantier.budget > 0 ?
Math.round((selectedChantier.coutReel / selectedChantier.budget) * 100) : 0;
const ecartBudget = selectedChantier.budget - selectedChantier.coutReel;
const retard = dateFinPrevue && aujourd_hui > dateFinPrevue && selectedChantier.statut !== 'TERMINE';
const joursRetard = retard ?
Math.floor((aujourd_hui.getTime() - dateFinPrevue.getTime()) / (1000 * 60 * 60 * 24)) : 0;
return (
<div className="grid">
{/* Informations principales */}
<div className="col-12 md:col-6">
<div className="field">
<label className="font-bold">Client</label>
<p>{typeof selectedChantier.client === 'string' ? selectedChantier.client : selectedChantier.client?.nom}</p>
</div>
<div className="field">
<label className="font-bold">Statut</label>
<div className="flex align-items-center gap-2">
<Tag value={selectedChantier.statut} severity={selectedChantier.statut === 'EN_COURS' ? 'success' : 'info'} />
{retard && (
<Tag value={`${joursRetard} jours de retard`} severity="danger" icon="pi pi-exclamation-triangle" />
)}
</div>
</div>
<div className="field">
<label className="font-bold">Avancement</label>
<div className="flex align-items-center gap-2">
<ProgressBar
value={selectedChantier.avancement || 0}
showValue
style={{ flex: 1 }}
/>
{selectedChantier.avancement === 100 && (
<i className="pi pi-check-circle text-green-500 text-xl" />
)}
</div>
</div>
<div className="field">
<label className="font-bold">Dates</label>
<div className="text-sm">
<div className="mb-1">
<i className="pi pi-calendar-plus text-green-500 mr-2"></i>
Début: {dateDebut ? dateDebut.toLocaleDateString('fr-FR') : 'Non définie'}
</div>
<div>
<i className="pi pi-calendar-times text-orange-500 mr-2"></i>
Fin prévue: {dateFinPrevue ? dateFinPrevue.toLocaleDateString('fr-FR') : 'Non définie'}
</div>
</div>
</div>
</div>
{/* Statistiques financières */}
<div className="col-12 md:col-6">
<div className="field">
<label className="font-bold">Budget</label>
<div className="text-sm">
<div className="mb-1">
<span className="font-semibold">Prévu: </span>
{selectedChantier.budget ? (
<span className="flex align-items-center">
{new Intl.NumberFormat('fr-FR', { style: 'decimal' }).format(selectedChantier.budget)}
<CFASymbol size="small" className="ml-1" />
</span>
) : 'Non défini'}
</div>
<div className="mb-1">
<span className="font-semibold">Dépensé: </span>
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(selectedChantier.coutReel)}
<span className="ml-2 text-sm">({tauxDepense}%)</span>
</div>
<div>
<span className="font-semibold">Reste: </span>
<span className={`flex align-items-center ${ecartBudget >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{new Intl.NumberFormat('fr-FR', { style: 'decimal' }).format(Math.abs(ecartBudget))}
<CFASymbol size="small" className="ml-1" />
</span>
</div>
</div>
</div>
<div className="field">
<label className="font-bold">Durée</label>
<div className="text-sm">
<div className="mb-1">
<i className="pi pi-clock mr-2"></i>
Durée totale: {dureeTotal} jours
</div>
<div className="mb-1">
<i className="pi pi-history mr-2 text-blue-500"></i>
Jours écoulés: {joursEcoules} jours
</div>
<div>
<i className="pi pi-hourglass mr-2 text-orange-500"></i>
Jours restants: {joursRestants > 0 ? `${joursRestants} jours` : 'Terminé'}
</div>
</div>
</div>
{/* Indicateurs de performance */}
<div className="field">
<label className="font-bold">Performance</label>
<div className="flex gap-3">
<div className="text-center">
<div className="text-2xl font-bold text-blue-500">
{selectedChantier.avancement || 0}%
</div>
<div className="text-xs text-500">Avancement</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-500">
{tauxDepense}%
</div>
<div className="text-xs text-500">Budget utilisé</div>
</div>
{joursEcoules > 0 && (
<div className="text-center">
<div className="text-2xl font-bold text-purple-500">
{Math.round((selectedChantier.avancement || 0) / joursEcoules * 10) / 10}%
</div>
<div className="text-xs text-500">% par jour</div>
</div>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="col-12">
<div className="flex justify-content-end gap-2">
<Button
label="Voir détails"
icon="pi pi-external-link"
className="p-button-text p-button-rounded"
onClick={() => {
setShowQuickView(false);
router.push(`/chantiers/${selectedChantier.id}`);
}}
/>
<Button
label="Gérer les phases"
icon="pi pi-sitemap"
className="p-button-text p-button-rounded"
style={{ color: '#10B981' }}
onClick={() => {
setShowQuickView(false);
router.push(`/chantiers/${selectedChantier.id}/phases`);
}}
/>
</div>
</div>
</div>
);
})()}
</Dialog>
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,265 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { Card } from 'primereact/card';
import { Button } from 'primereact/button';
import { InputText } from 'primereact/inputtext';
import { Badge } from 'primereact/badge';
import { Dialog } from 'primereact/dialog';
import { InputNumber } from 'primereact/inputnumber';
import { InputTextarea } from 'primereact/inputtextarea';
import { Toast } from 'primereact/toast';
import { Toolbar } from 'primereact/toolbar';
import PhasesTable from '../../../../components/phases/PhasesTable';
import usePhasesManager from '../../../../hooks/usePhasesManager';
import { PhaseChantier } from '../../../../types/btp-extended';
const DashboardPhasesPage = () => {
const toast = useRef<Toast>(null);
const {
phases,
loading,
selectedPhase,
setSelectedPhase,
loadPhases,
startPhase,
deletePhase,
suspendPhase,
resumePhase,
completePhase,
updateProgress,
getStatistics,
setToastRef
} = usePhasesManager();
const [showAvancementDialog, setShowAvancementDialog] = useState(false);
const [showSuspendreDialog, setShowSuspendreDialog] = useState(false);
const [avancement, setAvancement] = useState<number>(0);
const [motifSuspension, setMotifSuspension] = useState('');
const [globalFilter, setGlobalFilter] = useState('');
// Initialisation
useEffect(() => {
setToastRef(toast.current);
loadPhases();
}, [setToastRef, loadPhases]);
// Gestionnaires d'événements
const handleUpdateAvancement = async () => {
if (!selectedPhase) return;
try {
await updateProgress(selectedPhase.id!, avancement);
setShowAvancementDialog(false);
} catch (error) {
// Erreur déjà gérée dans le hook
}
};
const handleSuspendre = async () => {
if (!selectedPhase) return;
try {
await suspendPhase(selectedPhase.id!, motifSuspension);
setShowSuspendreDialog(false);
setMotifSuspension('');
} catch (error) {
// Erreur déjà gérée dans le hook
}
};
const handlePhaseProgress = (phase: PhaseChantier) => {
setSelectedPhase(phase);
setAvancement(phase.pourcentageAvancement || 0);
setShowAvancementDialog(true);
};
const handlePhaseSuspend = (phase: PhaseChantier) => {
setSelectedPhase(phase);
setShowSuspendreDialog(true);
};
// Récupération des statistiques
const stats = getStatistics();
// Toolbar
const toolbarStartTemplate = (
<div className="flex align-items-center gap-2">
<h5 className="m-0 text-color">Tableau de bord des phases</h5>
<Badge value={phases.length} />
</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>
<Button
label="Actualiser"
icon="pi pi-refresh"
onClick={loadPhases}
loading={loading}
/>
</div>
);
return (
<div className="grid">
<Toast ref={toast} />
<div className="col-12">
<h4>Tableau de bord des phases de chantier</h4>
</div>
{/* Statistiques */}
<div className="col-12 md:col-6 lg:col-3">
<Card className="mb-0">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Phases en cours</span>
<div className="text-900 font-medium text-xl">{stats.enCours}</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-play text-blue-500 text-xl" />
</div>
</div>
</Card>
</div>
<div className="col-12 md:col-6 lg:col-3">
<Card className="mb-0">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Phases en retard</span>
<div className="text-900 font-medium text-xl">{stats.enRetard}</div>
</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-exclamation-triangle text-orange-500 text-xl" />
</div>
</div>
</Card>
</div>
<div className="col-12 md:col-6 lg:col-3">
<Card className="mb-0">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Phases critiques</span>
<div className="text-900 font-medium text-xl">{stats.critiques}</div>
</div>
<div className="flex align-items-center justify-content-center bg-red-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
<i className="pi pi-exclamation-circle text-red-500 text-xl" />
</div>
</div>
</Card>
</div>
<div className="col-12 md:col-6 lg:col-3">
<Card className="mb-0">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Avancement moyen</span>
<div className="text-900 font-medium text-xl">{stats.avancementMoyen}%</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-percentage text-green-500 text-xl" />
</div>
</div>
</Card>
</div>
{/* Table des phases avec le composant réutilisable */}
<div className="col-12">
<Card>
<Toolbar
start={toolbarStartTemplate}
end={toolbarEndTemplate}
className="mb-4"
/>
<PhasesTable
phases={phases}
loading={loading}
showStats={false}
showChantierColumn={true}
showSubPhases={false}
showBudget={false}
showExpansion={false}
actions={['view', 'progress', 'start', 'delete']}
onRefresh={loadPhases}
onPhaseStart={startPhase}
onPhaseProgress={handlePhaseProgress}
onPhaseDelete={deletePhase}
rows={10}
globalFilter={globalFilter}
showGlobalFilter={true}
/>
</Card>
</div>
{/* Dialog avancement */}
<Dialog
header="Mettre à jour l'avancement"
visible={showAvancementDialog}
style={{ width: '450px' }}
onHide={() => setShowAvancementDialog(false)}
footer={
<div>
<Button label="Annuler" icon="pi pi-times" className="p-button-text" onClick={() => setShowAvancementDialog(false)} />
<Button label="Mettre à jour" icon="pi pi-check" onClick={handleUpdateAvancement} />
</div>
}
>
<div className="p-fluid">
<div className="field">
<label htmlFor="avancement">Pourcentage d'avancement</label>
<InputNumber
id="avancement"
value={avancement}
onValueChange={(e) => setAvancement(e.value || 0)}
suffix="%"
min={0}
max={100}
/>
</div>
</div>
</Dialog>
{/* Dialog suspension */}
<Dialog
header="Suspendre la phase"
visible={showSuspendreDialog}
style={{ width: '450px' }}
onHide={() => setShowSuspendreDialog(false)}
footer={
<div>
<Button label="Annuler" icon="pi pi-times" className="p-button-text" onClick={() => setShowSuspendreDialog(false)} />
<Button label="Suspendre" icon="pi pi-pause" className="p-button-danger" onClick={handleSuspendre} />
</div>
}
>
<div className="p-fluid">
<div className="field">
<label htmlFor="motif">Motif de suspension</label>
<InputTextarea
id="motif"
value={motifSuspension}
onChange={(e) => setMotifSuspension(e.target.value)}
rows={3}
required
/>
</div>
</div>
</Dialog>
</div>
);
};
export default DashboardPhasesPage;

View File

@@ -0,0 +1,712 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { Card } from 'primereact/card';
import FullCalendar from '@fullcalendar/react';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Tag } from 'primereact/tag';
import { Button } from 'primereact/button';
import { Toast } from 'primereact/toast';
import { Dropdown } from 'primereact/dropdown';
import { Calendar } from 'primereact/calendar';
import { Badge } from 'primereact/badge';
import { Timeline } from 'primereact/timeline';
import { Divider } from 'primereact/divider';
import { useRouter } from 'next/navigation';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import frLocale from '@fullcalendar/core/locales/fr';
import RoleProtectedPage from '@/components/RoleProtectedPage';
interface PlanningEvent {
id: string;
title: string;
start: Date;
end: Date;
type: string;
chantier: string;
ressources: string[];
statut: string;
priorite: string;
description: string;
responsable: string;
color?: string;
}
interface ConflitPlanning {
id: string;
type: string;
ressource: string;
evenements: string[];
dateDebut: Date;
dateFin: Date;
severite: string;
}
const DashboardPlanningContent = () => {
const toast = useRef<Toast>(null);
const router = useRouter();
const [events, setEvents] = useState<PlanningEvent[]>([]);
const [conflits, setConflits] = useState<ConflitPlanning[]>([]);
const [loading, setLoading] = useState(true);
const [selectedView, setSelectedView] = useState('month');
const [selectedType, setSelectedType] = useState('');
const [selectedChantier, setSelectedChantier] = useState('');
const [dateRange, setDateRange] = useState<Date[]>([]);
const [calendarOptions, setCalendarOptions] = useState({});
const [timelineEvents, setTimelineEvents] = useState([]);
const viewOptions = [
{ label: 'Mois', value: 'month' },
{ label: 'Semaine', value: 'week' },
{ label: 'Jour', value: 'day' },
{ label: 'Liste', value: 'list' }
];
const typeOptions = [
{ label: 'Tous les types', value: '' },
{ label: 'Chantier', value: 'CHANTIER' },
{ label: 'Maintenance', value: 'MAINTENANCE' },
{ label: 'Formation', value: 'FORMATION' },
{ label: 'Réunion', value: 'REUNION' },
{ label: 'Livraison', value: 'LIVRAISON' }
];
const chantierOptions = [
{ label: 'Tous les chantiers', value: '' },
{ label: 'Résidence Les Jardins', value: 'jardins' },
{ label: 'Centre Commercial Atlantis', value: 'atlantis' },
{ label: 'Rénovation Hôtel Luxe', value: 'hotel' },
{ label: 'Usine Pharmaceutique', value: 'usine' }
];
useEffect(() => {
loadPlanning();
initCalendar();
initTimeline();
}, [selectedView, selectedType, selectedChantier, dateRange]);
const loadPlanning = async () => {
try {
setLoading(true);
// TODO: Remplacer par un vrai appel API
// const response = await planningService.getDashboardData({
// view: selectedView,
// type: selectedType,
// chantier: selectedChantier,
// dateRange
// });
// Données simulées pour la démonstration
const mockEvents: PlanningEvent[] = [
{
id: '1',
title: 'Coulage dalle béton',
start: new Date('2025-01-02T08:00:00'),
end: new Date('2025-01-02T17:00:00'),
type: 'CHANTIER',
chantier: 'Résidence Les Jardins',
ressources: ['Équipe Gros Œuvre', 'Bétonnière S36X', 'Grue LTM 1050'],
statut: 'PLANIFIE',
priorite: 'HAUTE',
description: 'Coulage de la dalle du niveau R+1',
responsable: 'Jean Dupont',
color: '#10b981'
},
{
id: '2',
title: 'Maintenance préventive',
start: new Date('2025-01-03T09:00:00'),
end: new Date('2025-01-03T12:00:00'),
type: 'MAINTENANCE',
chantier: 'Atelier Central',
ressources: ['Pelleteuse CAT 320D'],
statut: 'PLANIFIE',
priorite: 'MOYENNE',
description: 'Révision système hydraulique',
responsable: 'Marie Martin',
color: '#f59e0b'
},
{
id: '3',
title: 'Formation CACES',
start: new Date('2025-01-06T08:00:00'),
end: new Date('2025-01-08T17:00:00'),
type: 'FORMATION',
chantier: 'Centre de Formation',
ressources: ['Pierre Leroy', 'Luc Bernard'],
statut: 'CONFIRME',
priorite: 'MOYENNE',
description: 'Formation CACES R482 - Engins de chantier',
responsable: 'Sophie Dubois',
color: '#3b82f6'
},
{
id: '4',
title: 'Livraison matériaux',
start: new Date('2025-01-07T14:00:00'),
end: new Date('2025-01-07T16:00:00'),
type: 'LIVRAISON',
chantier: 'Centre Commercial Atlantis',
ressources: ['Camion benne Volvo'],
statut: 'PLANIFIE',
priorite: 'HAUTE',
description: 'Livraison acier pour structure',
responsable: 'Marc Rousseau',
color: '#8b5cf6'
},
{
id: '5',
title: 'Réunion chantier',
start: new Date('2025-01-09T10:00:00'),
end: new Date('2025-01-09T11:30:00'),
type: 'REUNION',
chantier: 'Résidence Les Jardins',
ressources: ['Équipe complète'],
statut: 'CONFIRME',
priorite: 'MOYENNE',
description: 'Point hebdomadaire équipe',
responsable: 'Jean Dupont',
color: '#06b6d4'
}
];
const mockConflits: ConflitPlanning[] = [
{
id: '1',
type: 'RESSOURCE_DOUBLE',
ressource: 'Grue LTM 1050',
evenements: ['Coulage dalle béton', 'Montage charpente'],
dateDebut: new Date('2025-01-02T08:00:00'),
dateFin: new Date('2025-01-02T17:00:00'),
severite: 'HAUTE'
},
{
id: '2',
type: 'EMPLOYE_INDISPONIBLE',
ressource: 'Pierre Leroy',
evenements: ['Formation CACES'],
dateDebut: new Date('2025-01-06T08:00:00'),
dateFin: new Date('2025-01-08T17:00:00'),
severite: 'MOYENNE'
}
];
// Filtrer selon les critères sélectionnés
let filteredEvents = mockEvents;
if (selectedType) {
filteredEvents = filteredEvents.filter(e => e.type === selectedType);
}
if (selectedChantier) {
filteredEvents = filteredEvents.filter(e => e.chantier.toLowerCase().includes(selectedChantier));
}
setEvents(filteredEvents);
setConflits(mockConflits);
} catch (error) {
console.error('Erreur lors du chargement du planning:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger les données du planning'
});
} finally {
setLoading(false);
}
};
const initCalendar = () => {
const calendarEvents = events.map(event => ({
id: event.id,
title: event.title,
start: event.start,
end: event.end,
backgroundColor: event.color,
borderColor: event.color,
textColor: '#ffffff',
extendedProps: {
type: event.type,
chantier: event.chantier,
responsable: event.responsable,
description: event.description,
ressources: event.ressources
}
}));
const options = {
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay'
},
initialView: selectedView === 'month' ? 'dayGridMonth' :
selectedView === 'week' ? 'timeGridWeek' : 'timeGridDay',
locale: frLocale,
events: calendarEvents,
editable: true,
selectable: true,
selectMirror: true,
dayMaxEvents: true,
weekends: true,
eventClick: (info: any) => {
const event = events.find(e => e.id === info.event.id);
if (event) {
handleEventClick(event);
}
},
select: (info: any) => {
handleDateSelect(info.start, info.end);
},
eventDrop: (info: any) => {
handleEventDrop(info);
},
eventResize: (info: any) => {
handleEventResize(info);
}
};
setCalendarOptions(options);
};
const initTimeline = () => {
const upcomingEvents = events
.filter(e => e.start > new Date())
.sort((a, b) => a.start.getTime() - b.start.getTime())
.slice(0, 5)
.map(e => ({
status: e.priorite === 'HAUTE' ? 'danger' : e.priorite === 'MOYENNE' ? 'warning' : 'info',
date: e.start.toLocaleDateString('fr-FR'),
time: e.start.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
icon: getTypeIcon(e.type),
color: e.color,
title: e.title,
chantier: e.chantier,
responsable: e.responsable
}));
setTimelineEvents(upcomingEvents);
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'CHANTIER': return 'pi pi-building';
case 'MAINTENANCE': return 'pi pi-wrench';
case 'FORMATION': return 'pi pi-graduation-cap';
case 'REUNION': return 'pi pi-users';
case 'LIVRAISON': return 'pi pi-truck';
default: return 'pi pi-calendar';
}
};
const getStatutSeverity = (statut: string) => {
switch (statut) {
case 'PLANIFIE': return 'info';
case 'CONFIRME': return 'success';
case 'EN_COURS': return 'warning';
case 'TERMINE': return 'success';
case 'ANNULE': return 'danger';
default: return 'info';
}
};
const getPrioriteSeverity = (priorite: string) => {
switch (priorite) {
case 'HAUTE': return 'danger';
case 'MOYENNE': return 'warning';
case 'BASSE': return 'info';
default: return 'info';
}
};
const getSeveriteSeverity = (severite: string) => {
switch (severite) {
case 'HAUTE': return 'danger';
case 'MOYENNE': return 'warning';
case 'BASSE': return 'info';
default: return 'info';
}
};
const handleEventClick = (event: PlanningEvent) => {
toast.current?.show({
severity: 'info',
summary: event.title,
detail: `${event.chantier} - ${event.responsable}`,
life: 3000
});
};
const handleDateSelect = (start: Date, end: Date) => {
router.push(`/planning/nouveau?start=${start.toISOString()}&end=${end.toISOString()}`);
};
const handleEventDrop = (info: any) => {
toast.current?.show({
severity: 'success',
summary: 'Événement déplacé',
detail: `${info.event.title} déplacé au ${info.event.start.toLocaleDateString('fr-FR')}`,
life: 3000
});
};
const handleEventResize = (info: any) => {
toast.current?.show({
severity: 'success',
summary: 'Événement redimensionné',
detail: `Durée de ${info.event.title} modifiée`,
life: 3000
});
};
const statutBodyTemplate = (rowData: PlanningEvent) => (
<Tag value={rowData.statut} severity={getStatutSeverity(rowData.statut)} />
);
const prioriteBodyTemplate = (rowData: PlanningEvent) => (
<Tag value={rowData.priorite} severity={getPrioriteSeverity(rowData.priorite)} />
);
const typeBodyTemplate = (rowData: PlanningEvent) => (
<div className="flex align-items-center">
<i className={`${getTypeIcon(rowData.type)} mr-2`}></i>
<span>{rowData.type}</span>
</div>
);
const ressourcesBodyTemplate = (rowData: PlanningEvent) => (
<div className="flex flex-wrap gap-1">
{rowData.ressources.slice(0, 2).map((ressource, index) => (
<Tag key={index} value={ressource} severity="secondary" className="text-xs" />
))}
{rowData.ressources.length > 2 && (
<Tag value={`+${rowData.ressources.length - 2}`} severity="info" className="text-xs" />
)}
</div>
);
const conflitTypeBodyTemplate = (rowData: ConflitPlanning) => (
<div className="flex align-items-center">
<i className="pi pi-exclamation-triangle text-orange-500 mr-2"></i>
<span>{rowData.type.replace('_', ' ')}</span>
</div>
);
const conflitSeveriteBodyTemplate = (rowData: ConflitPlanning) => (
<Tag value={rowData.severite} severity={getSeveriteSeverity(rowData.severite)} />
);
const actionBodyTemplate = (rowData: PlanningEvent) => (
<div className="flex gap-2">
<Button
icon="pi pi-eye"
className="p-button-text p-button-sm"
tooltip="Voir détails"
onClick={() => router.push(`/planning/${rowData.id}`)}
/>
<Button
icon="pi pi-pencil"
className="p-button-text p-button-sm"
tooltip="Modifier"
onClick={() => router.push(`/planning/${rowData.id}/edit`)}
/>
<Button
icon="pi pi-copy"
className="p-button-text p-button-sm"
tooltip="Dupliquer"
onClick={() => router.push(`/planning/nouveau?template=${rowData.id}`)}
/>
</div>
);
const conflitActionBodyTemplate = (rowData: ConflitPlanning) => (
<div className="flex gap-2">
<Button
icon="pi pi-check"
className="p-button-text p-button-sm p-button-success"
tooltip="Résoudre"
onClick={() => {
toast.current?.show({
severity: 'success',
summary: 'Conflit résolu',
detail: 'Le conflit a été résolu avec succès',
life: 3000
});
}}
/>
<Button
icon="pi pi-times"
className="p-button-text p-button-sm p-button-danger"
tooltip="Ignorer"
onClick={() => {
toast.current?.show({
severity: 'info',
summary: 'Conflit ignoré',
detail: 'Le conflit a été marqué comme ignoré',
life: 3000
});
}}
/>
</div>
);
// Calculs des métriques
const evenementsAujourdhui = events.filter(e =>
e.start.toDateString() === new Date().toDateString()
).length;
const evenementsSemaine = events.filter(e => {
const today = new Date();
const weekStart = new Date(today.setDate(today.getDate() - today.getDay()));
const weekEnd = new Date(today.setDate(today.getDate() - today.getDay() + 6));
return e.start >= weekStart && e.start <= weekEnd;
}).length;
const conflitsActifs = conflits.filter(c => c.severite === 'HAUTE').length;
return (
<div className="grid">
<Toast ref={toast} />
{/* En-tête avec filtres */}
<div className="col-12">
<Card>
<div className="flex justify-content-between align-items-center mb-4">
<h2 className="text-2xl font-bold m-0">Dashboard Planning</h2>
<Button
label="Nouvel événement"
icon="pi pi-plus"
onClick={() => router.push('/planning/nouveau')}
/>
</div>
<div className="flex flex-wrap gap-3 align-items-center">
<div className="field">
<label htmlFor="view" className="font-semibold">Vue</label>
<Dropdown
id="view"
value={selectedView}
options={viewOptions}
onChange={(e) => setSelectedView(e.value)}
className="w-full md:w-10rem"
/>
</div>
<div className="field">
<label htmlFor="type" className="font-semibold">Type</label>
<Dropdown
id="type"
value={selectedType}
options={typeOptions}
onChange={(e) => setSelectedType(e.value)}
className="w-full md:w-14rem"
/>
</div>
<div className="field">
<label htmlFor="chantier" className="font-semibold">Chantier</label>
<Dropdown
id="chantier"
value={selectedChantier}
options={chantierOptions}
onChange={(e) => setSelectedChantier(e.value)}
className="w-full md:w-16rem"
/>
</div>
<div className="field">
<label htmlFor="dateRange" className="font-semibold">Période</label>
<Calendar
id="dateRange"
value={dateRange}
onChange={(e) => setDateRange(e.value as Date[])}
selectionMode="range"
readOnlyInput
hideOnRangeSelection
className="w-full md:w-14rem"
placeholder="Sélectionner une période"
/>
</div>
<Button
icon="pi pi-refresh"
className="p-button-outlined"
onClick={loadPlanning}
loading={loading}
tooltip="Actualiser"
/>
</div>
</Card>
</div>
{/* Métriques principales */}
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Aujourd'hui</span>
<div className="text-900 font-medium text-xl">{evenementsAujourdhui}</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-calendar text-blue-500 text-xl"></i>
</div>
</div>
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Cette semaine</span>
<div className="text-900 font-medium text-xl">{evenementsSemaine}</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-calendar-plus text-green-500 text-xl"></i>
</div>
</div>
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Conflits actifs</span>
<div className="text-900 font-medium text-xl">{conflitsActifs}</div>
</div>
<div className="flex align-items-center justify-content-center bg-red-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
<i className="pi pi-exclamation-triangle text-red-500 text-xl"></i>
</div>
</div>
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Total événements</span>
<div className="text-900 font-medium text-xl">{events.length}</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-list text-purple-500 text-xl"></i>
</div>
</div>
</Card>
</div>
{/* Calendrier */}
{selectedView !== 'list' && (
<div className="col-12 lg:col-8">
<Card>
<h6>Calendrier</h6>
<FullCalendar {...calendarOptions} />
</Card>
</div>
)}
{/* Timeline des prochains événements */}
<div className={`col-12 ${selectedView !== 'list' ? 'lg:col-4' : ''}`}>
<Card>
<h6>Prochains Événements</h6>
<Timeline
value={timelineEvents}
align="alternate"
className="customized-timeline"
marker={(item) => <span className={`flex w-2rem h-2rem align-items-center justify-content-center text-white border-circle z-1 shadow-1`} style={{ backgroundColor: item.color }}>
<i className={item.icon}></i>
</span>}
content={(item) => (
<Card title={item.title} subTitle={`${item.date} à ${item.time}`}>
<p className="text-sm">
<strong>Chantier:</strong> {item.chantier}<br/>
<strong>Responsable:</strong> {item.responsable}
</p>
</Card>
)}
/>
</Card>
</div>
{/* Conflits de planning */}
{conflits.length > 0 && (
<div className="col-12">
<Card>
<div className="flex justify-content-between align-items-center mb-4">
<h6>Conflits de Planning</h6>
<Badge value={`${conflitsActifs} critiques`} severity="danger" />
</div>
<DataTable
value={conflits}
responsiveLayout="scroll"
emptyMessage="Aucun conflit détecté"
>
<Column field="type" header="Type" body={conflitTypeBodyTemplate} />
<Column field="ressource" header="Ressource" />
<Column field="evenements" header="Événements" body={(rowData) => rowData.evenements.join(', ')} />
<Column field="dateDebut" header="Date début" body={(rowData) => rowData.dateDebut.toLocaleDateString('fr-FR')} />
<Column field="dateFin" header="Date fin" body={(rowData) => rowData.dateFin.toLocaleDateString('fr-FR')} />
<Column field="severite" header="Sévérité" body={conflitSeveriteBodyTemplate} />
<Column header="Actions" body={conflitActionBodyTemplate} style={{ width: '100px' }} />
</DataTable>
</Card>
</div>
)}
{/* Liste des événements */}
<div className="col-12">
<Card>
<div className="flex justify-content-between align-items-center mb-4">
<h6>Liste des Événements ({events.length})</h6>
<Badge value={`${events.filter(e => e.statut === 'CONFIRME').length} confirmés`} severity="success" />
</div>
<DataTable
value={events}
loading={loading}
responsiveLayout="scroll"
paginator
rows={10}
rowsPerPageOptions={[5, 10, 25]}
emptyMessage="Aucun événement trouvé"
sortMode="multiple"
>
<Column field="title" header="Titre" sortable />
<Column field="type" header="Type" body={typeBodyTemplate} sortable />
<Column field="chantier" header="Chantier" sortable />
<Column field="start" header="Début" body={(rowData) => rowData.start.toLocaleString('fr-FR')} sortable />
<Column field="end" header="Fin" body={(rowData) => rowData.end.toLocaleString('fr-FR')} sortable />
<Column field="statut" header="Statut" body={statutBodyTemplate} sortable />
<Column field="priorite" header="Priorité" body={prioriteBodyTemplate} sortable />
<Column field="ressources" header="Ressources" body={ressourcesBodyTemplate} />
<Column field="responsable" header="Responsable" sortable />
<Column header="Actions" body={actionBodyTemplate} style={{ width: '120px' }} />
</DataTable>
</Card>
</div>
</div>
);
};
const DashboardPlanning = () => {
return (
<RoleProtectedPage
requiredPage="PLANNING"
fallbackMessage="Vous devez avoir un rôle de gestionnaire ou supérieur pour accéder au planning."
>
<DashboardPlanningContent />
</RoleProtectedPage>
);
};
export default DashboardPlanning;

View File

@@ -0,0 +1,720 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { Card } from 'primereact/card';
import { Chart } from 'primereact/chart';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { Button } from 'primereact/button';
import { Toast } from 'primereact/toast';
import { Dropdown } from 'primereact/dropdown';
import { Calendar } from 'primereact/calendar';
import { Badge } from 'primereact/badge';
import { Knob } from 'primereact/knob';
import { useRouter } from 'next/navigation';
interface RessourceDashboard {
id: string;
nom: string;
type: string;
categorie: string;
statut: string;
disponibilite: number;
utilisation: number;
localisation: string;
chantierActuel?: string;
dateFinUtilisation?: Date;
coutJournalier: number;
revenus: number;
maintenancesPrevues: number;
heuresUtilisation: number;
capaciteMax: number;
etatSante: number;
}
interface EmployeRessource {
id: string;
nom: string;
prenom: string;
metier: string;
competences: string[];
disponibilite: number;
chantierActuel?: string;
tauxHoraire: number;
heuresTravaillees: number;
certifications: string[];
experience: number;
}
const DashboardRessources = () => {
const toast = useRef<Toast>(null);
const router = useRouter();
const [materiels, setMateriels] = useState<RessourceDashboard[]>([]);
const [employes, setEmployes] = useState<EmployeRessource[]>([]);
const [loading, setLoading] = useState(true);
const [selectedType, setSelectedType] = useState('');
const [selectedCategorie, setSelectedCategorie] = useState('');
const [selectedView, setSelectedView] = useState('materiel');
const [chartData, setChartData] = useState({});
const [chartOptions, setChartOptions] = useState({});
const typeOptions = [
{ label: 'Tous les types', value: '' },
{ label: 'Engins de terrassement', value: 'TERRASSEMENT' },
{ label: 'Grues et levage', value: 'LEVAGE' },
{ label: 'Transport', value: 'TRANSPORT' },
{ label: 'Outillage', value: 'OUTILLAGE' },
{ label: 'Sécurité', value: 'SECURITE' }
];
const categorieOptions = [
{ label: 'Toutes les catégories', value: '' },
{ label: 'Lourd', value: 'LOURD' },
{ label: 'Léger', value: 'LEGER' },
{ label: 'Spécialisé', value: 'SPECIALISE' },
{ label: 'Consommable', value: 'CONSOMMABLE' }
];
const viewOptions = [
{ label: 'Matériel', value: 'materiel' },
{ label: 'Employés', value: 'employes' },
{ label: 'Vue globale', value: 'global' }
];
useEffect(() => {
loadRessources();
initCharts();
}, [selectedType, selectedCategorie, selectedView]);
const loadRessources = async () => {
try {
setLoading(true);
// TODO: Remplacer par de vrais appels API
// const [materielResponse, employeResponse] = await Promise.all([
// materielService.getDashboardData(),
// employeService.getDashboardData()
// ]);
// Données simulées pour la démonstration
const mockMateriels: RessourceDashboard[] = [
{
id: '1',
nom: 'Grue mobile Liebherr LTM 1050',
type: 'LEVAGE',
categorie: 'LOURD',
statut: 'EN_SERVICE',
disponibilite: 85,
utilisation: 75,
localisation: 'Chantier Résidence Les Jardins',
chantierActuel: 'Résidence Les Jardins',
dateFinUtilisation: new Date('2025-02-15'),
coutJournalier: 850,
revenus: 25500,
maintenancesPrevues: 2,
heuresUtilisation: 1250,
capaciteMax: 50,
etatSante: 90
},
{
id: '2',
nom: 'Pelleteuse CAT 320D',
type: 'TERRASSEMENT',
categorie: 'LOURD',
statut: 'MAINTENANCE',
disponibilite: 0,
utilisation: 0,
localisation: 'Atelier Central',
coutJournalier: 650,
revenus: 19500,
maintenancesPrevues: 1,
heuresUtilisation: 980,
capaciteMax: 32,
etatSante: 65
},
{
id: '3',
nom: 'Camion benne Volvo FMX',
type: 'TRANSPORT',
categorie: 'LOURD',
statut: 'DISPONIBLE',
disponibilite: 100,
utilisation: 45,
localisation: 'Dépôt Central',
coutJournalier: 450,
revenus: 13500,
maintenancesPrevues: 1,
heuresUtilisation: 750,
capaciteMax: 25,
etatSante: 85
},
{
id: '4',
nom: 'Bétonnière Schwing S36X',
type: 'TRANSPORT',
categorie: 'SPECIALISE',
statut: 'EN_SERVICE',
disponibilite: 70,
utilisation: 90,
localisation: 'Chantier Centre Commercial',
chantierActuel: 'Centre Commercial Atlantis',
dateFinUtilisation: new Date('2025-01-30'),
coutJournalier: 750,
revenus: 22500,
maintenancesPrevues: 3,
heuresUtilisation: 1100,
capaciteMax: 36,
etatSante: 80
}
];
const mockEmployes: EmployeRessource[] = [
{
id: '1',
nom: 'Dupont',
prenom: 'Jean',
metier: 'Chef de chantier',
competences: ['Gestion équipe', 'Planning', 'Sécurité'],
disponibilite: 100,
chantierActuel: 'Résidence Les Jardins',
tauxHoraire: 45,
heuresTravaillees: 1680,
certifications: ['CACES R482', 'SST'],
experience: 15
},
{
id: '2',
nom: 'Martin',
prenom: 'Marie',
metier: 'Conducteur d\'engins',
competences: ['Pelleteuse', 'Bulldozer', 'Compacteur'],
disponibilite: 85,
chantierActuel: 'Centre Commercial Atlantis',
tauxHoraire: 35,
heuresTravaillees: 1520,
certifications: ['CACES R482', 'CACES R372'],
experience: 8
},
{
id: '3',
nom: 'Leroy',
prenom: 'Pierre',
metier: 'Grutier',
competences: ['Grue mobile', 'Grue tour', 'Levage'],
disponibilite: 90,
chantierActuel: 'Résidence Les Jardins',
tauxHoraire: 40,
heuresTravaillees: 1600,
certifications: ['CACES R483', 'CACES R487'],
experience: 12
},
{
id: '4',
nom: 'Bernard',
prenom: 'Luc',
metier: 'Maçon',
competences: ['Béton', 'Coffrage', 'Ferraillage'],
disponibilite: 100,
tauxHoraire: 28,
heuresTravaillees: 1750,
certifications: ['CQP Maçon', 'SST'],
experience: 10
}
];
// Filtrer selon les critères sélectionnés
let filteredMateriels = mockMateriels;
if (selectedType) {
filteredMateriels = filteredMateriels.filter(m => m.type === selectedType);
}
if (selectedCategorie) {
filteredMateriels = filteredMateriels.filter(m => m.categorie === selectedCategorie);
}
setMateriels(filteredMateriels);
setEmployes(mockEmployes);
} catch (error) {
console.error('Erreur lors du chargement des ressources:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger les données des ressources'
});
} finally {
setLoading(false);
}
};
const initCharts = () => {
const documentStyle = getComputedStyle(document.documentElement);
if (selectedView === 'materiel') {
// Graphique d'utilisation du matériel
const utilisationData = {
labels: ['En service', 'Disponible', 'Maintenance', 'Hors service'],
datasets: [
{
data: [
materiels.filter(m => m.statut === 'EN_SERVICE').length,
materiels.filter(m => m.statut === 'DISPONIBLE').length,
materiels.filter(m => m.statut === 'MAINTENANCE').length,
materiels.filter(m => m.statut === 'HORS_SERVICE').length
],
backgroundColor: [
documentStyle.getPropertyValue('--green-500'),
documentStyle.getPropertyValue('--blue-500'),
documentStyle.getPropertyValue('--orange-500'),
documentStyle.getPropertyValue('--red-500')
]
}
]
};
setChartData(utilisationData);
} else if (selectedView === 'employes') {
// Graphique de répartition par métier
const metiers = [...new Set(employes.map(e => e.metier))];
const metierData = {
labels: metiers,
datasets: [
{
data: metiers.map(metier => employes.filter(e => e.metier === metier).length),
backgroundColor: [
documentStyle.getPropertyValue('--blue-500'),
documentStyle.getPropertyValue('--green-500'),
documentStyle.getPropertyValue('--orange-500'),
documentStyle.getPropertyValue('--purple-500')
]
}
]
};
setChartData(metierData);
}
const options = {
plugins: {
legend: {
labels: {
usePointStyle: true,
color: documentStyle.getPropertyValue('--text-color')
}
}
}
};
setChartOptions(options);
};
const getStatutSeverity = (statut: string) => {
switch (statut) {
case 'EN_SERVICE': return 'success';
case 'DISPONIBLE': return 'info';
case 'MAINTENANCE': return 'warning';
case 'HORS_SERVICE': return 'danger';
default: return 'info';
}
};
const statutBodyTemplate = (rowData: RessourceDashboard) => (
<Tag value={rowData.statut} severity={getStatutSeverity(rowData.statut)} />
);
const utilisationBodyTemplate = (rowData: RessourceDashboard) => (
<div className="flex align-items-center">
<ProgressBar
value={rowData.utilisation}
style={{ width: '100px', marginRight: '8px' }}
showValue={false}
/>
<span className="text-sm font-semibold">{rowData.utilisation}%</span>
</div>
);
const disponibiliteBodyTemplate = (rowData: RessourceDashboard) => {
const couleur = rowData.disponibilite > 80 ? 'text-green-500' :
rowData.disponibilite > 50 ? 'text-orange-500' : 'text-red-500';
return (
<span className={`font-semibold ${couleur}`}>
{rowData.disponibilite}%
</span>
);
};
const revenusBodyTemplate = (rowData: RessourceDashboard) => (
<div>
<div className="font-semibold">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(rowData.revenus)}
</div>
<div className="text-sm text-500">
{rowData.coutJournalier}/jour
</div>
</div>
);
const etatSanteBodyTemplate = (rowData: RessourceDashboard) => (
<div className="flex align-items-center justify-content-center">
<Knob
value={rowData.etatSante}
size={50}
readOnly
valueColor={rowData.etatSante > 80 ? '#10b981' : rowData.etatSante > 60 ? '#f59e0b' : '#ef4444'}
rangeColor="#e5e7eb"
textColor="#374151"
/>
</div>
);
const competencesBodyTemplate = (rowData: EmployeRessource) => (
<div className="flex flex-wrap gap-1">
{rowData.competences.slice(0, 2).map((comp, index) => (
<Tag key={index} value={comp} severity="info" className="text-xs" />
))}
{rowData.competences.length > 2 && (
<Tag value={`+${rowData.competences.length - 2}`} severity="secondary" className="text-xs" />
)}
</div>
);
const experienceBodyTemplate = (rowData: EmployeRessource) => (
<div className="text-center">
<div className="text-lg font-bold">{rowData.experience}</div>
<div className="text-xs text-500">ans</div>
</div>
);
const actionBodyTemplate = (rowData: any) => (
<div className="flex gap-2">
<Button
icon="pi pi-eye"
className="p-button-text p-button-sm"
tooltip="Voir détails"
onClick={() => {
if (selectedView === 'materiel') {
router.push(`/materiels/${rowData.id}`);
} else {
router.push(`/employes/${rowData.id}`);
}
}}
/>
<Button
icon="pi pi-calendar"
className="p-button-text p-button-sm"
tooltip="Planning"
onClick={() => router.push(`/planning?ressource=${rowData.id}`)}
/>
</div>
);
// Calculs des métriques
const tauxUtilisationMoyen = materiels.length > 0 ?
materiels.reduce((sum, m) => sum + m.utilisation, 0) / materiels.length : 0;
const revenusTotal = materiels.reduce((sum, m) => sum + m.revenus, 0);
const materielDisponible = materiels.filter(m => m.statut === 'DISPONIBLE').length;
const employesDisponibles = employes.filter(e => !e.chantierActuel).length;
return (
<div className="grid">
<Toast ref={toast} />
{/* En-tête avec filtres */}
<div className="col-12">
<Card>
<div className="flex justify-content-between align-items-center mb-4">
<h2 className="text-2xl font-bold m-0">Dashboard Ressources</h2>
<div className="flex gap-2">
<Button
label="Nouveau matériel"
icon="pi pi-plus"
onClick={() => router.push('/materiels/nouveau')}
className="p-button-outlined"
/>
<Button
label="Nouvel employé"
icon="pi pi-user-plus"
onClick={() => router.push('/employes/nouveau')}
/>
</div>
</div>
<div className="flex flex-wrap gap-3 align-items-center">
<div className="field">
<label htmlFor="view" className="font-semibold">Vue</label>
<Dropdown
id="view"
value={selectedView}
options={viewOptions}
onChange={(e) => setSelectedView(e.value)}
className="w-full md:w-12rem"
/>
</div>
{selectedView === 'materiel' && (
<>
<div className="field">
<label htmlFor="type" className="font-semibold">Type</label>
<Dropdown
id="type"
value={selectedType}
options={typeOptions}
onChange={(e) => setSelectedType(e.value)}
className="w-full md:w-14rem"
/>
</div>
<div className="field">
<label htmlFor="categorie" className="font-semibold">Catégorie</label>
<Dropdown
id="categorie"
value={selectedCategorie}
options={categorieOptions}
onChange={(e) => setSelectedCategorie(e.value)}
className="w-full md:w-14rem"
/>
</div>
</>
)}
<Button
icon="pi pi-refresh"
className="p-button-outlined"
onClick={loadRessources}
loading={loading}
tooltip="Actualiser"
/>
</div>
</Card>
</div>
{/* Métriques principales */}
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">
{selectedView === 'materiel' ? 'Matériels' : 'Employés'}
</span>
<div className="text-900 font-medium text-xl">
{selectedView === 'materiel' ? materiels.length : employes.length}
</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 ${selectedView === 'materiel' ? 'pi-cog' : 'pi-users'} text-blue-500 text-xl`}></i>
</div>
</div>
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Disponibles</span>
<div className="text-900 font-medium text-xl">
{selectedView === 'materiel' ? materielDisponible : employesDisponibles}
</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 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">
{selectedView === 'materiel' ? 'Revenus' : 'Heures totales'}
</span>
<div className="text-900 font-medium text-xl">
{selectedView === 'materiel' ?
new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', notation: 'compact' }).format(revenusTotal) :
employes.reduce((sum, e) => sum + e.heuresTravaillees, 0).toLocaleString()
}
</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 ${selectedView === 'materiel' ? 'pi-euro' : 'pi-clock'} text-purple-500 text-xl`}></i>
</div>
</div>
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Taux Utilisation</span>
<div className="text-900 font-medium text-xl">
{selectedView === 'materiel' ?
`${tauxUtilisationMoyen.toFixed(1)}%` :
`${((employes.filter(e => e.chantierActuel).length / employes.length) * 100).toFixed(1)}%`
}
</div>
</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-chart-line text-orange-500 text-xl"></i>
</div>
</div>
</Card>
</div>
{/* Graphique */}
<div className="col-12 lg:col-6">
<Card>
<h6>
{selectedView === 'materiel' ? 'Répartition par Statut' : 'Répartition par Métier'}
</h6>
<Chart type="doughnut" data={chartData} options={chartOptions} className="w-full md:w-30rem" />
</Card>
</div>
{/* Indicateurs de performance */}
<div className="col-12 lg:col-6">
<Card>
<h6>Indicateurs de Performance</h6>
<div className="grid">
{selectedView === 'materiel' ? (
<>
<div className="col-6">
<div className="text-center">
<div className="text-2xl font-bold text-green-500">
{materiels.filter(m => m.etatSante > 80).length}
</div>
<div className="text-sm text-500">Bon état</div>
</div>
</div>
<div className="col-6">
<div className="text-center">
<div className="text-2xl font-bold text-orange-500">
{materiels.reduce((sum, m) => sum + m.maintenancesPrevues, 0)}
</div>
<div className="text-sm text-500">Maintenances prévues</div>
</div>
</div>
<div className="col-6">
<div className="text-center">
<div className="text-2xl font-bold text-blue-500">
{(materiels.reduce((sum, m) => sum + m.heuresUtilisation, 0) / materiels.length).toFixed(0)}
</div>
<div className="text-sm text-500">Heures moy./matériel</div>
</div>
</div>
<div className="col-6">
<div className="text-center">
<div className="text-2xl font-bold text-purple-500">
{(revenusTotal / materiels.length).toFixed(0)}
</div>
<div className="text-sm text-500">Revenus moy./matériel</div>
</div>
</div>
</>
) : (
<>
<div className="col-6">
<div className="text-center">
<div className="text-2xl font-bold text-green-500">
{employes.filter(e => e.certifications.length > 1).length}
</div>
<div className="text-sm text-500">Multi-certifiés</div>
</div>
</div>
<div className="col-6">
<div className="text-center">
<div className="text-2xl font-bold text-blue-500">
{(employes.reduce((sum, e) => sum + e.experience, 0) / employes.length).toFixed(1)}
</div>
<div className="text-sm text-500">Expérience moyenne</div>
</div>
</div>
<div className="col-6">
<div className="text-center">
<div className="text-2xl font-bold text-purple-500">
{(employes.reduce((sum, e) => sum + e.tauxHoraire, 0) / employes.length).toFixed(0)}
</div>
<div className="text-sm text-500">Taux horaire moyen</div>
</div>
</div>
<div className="col-6">
<div className="text-center">
<div className="text-2xl font-bold text-orange-500">
{employes.reduce((sum, e) => sum + e.competences.length, 0)}
</div>
<div className="text-sm text-500">Compétences totales</div>
</div>
</div>
</>
)}
</div>
</Card>
</div>
{/* Tableau des ressources */}
<div className="col-12">
<Card>
<div className="flex justify-content-between align-items-center mb-4">
<h6>
{selectedView === 'materiel' ?
`Liste du Matériel (${materiels.length})` :
`Liste des Employés (${employes.length})`
}
</h6>
<Badge
value={selectedView === 'materiel' ?
`${materiels.filter(m => m.statut === 'EN_SERVICE').length} en service` :
`${employes.filter(e => e.chantierActuel).length} en mission`
}
severity="success"
/>
</div>
<DataTable
value={selectedView === 'materiel' ? materiels : employes}
loading={loading}
responsiveLayout="scroll"
paginator
rows={10}
rowsPerPageOptions={[5, 10, 25]}
emptyMessage={`Aucun${selectedView === 'materiel' ? ' matériel' : ' employé'} trouvé`}
sortMode="multiple"
>
{selectedView === 'materiel' ? (
<>
<Column field="nom" header="Matériel" sortable />
<Column field="type" header="Type" sortable />
<Column field="statut" header="Statut" body={statutBodyTemplate} sortable />
<Column field="utilisation" header="Utilisation" body={utilisationBodyTemplate} sortable />
<Column field="disponibilite" header="Disponibilité" body={disponibiliteBodyTemplate} sortable />
<Column field="revenus" header="Revenus" body={revenusBodyTemplate} sortable />
<Column field="etatSante" header="État" body={etatSanteBodyTemplate} style={{ width: '100px' }} />
<Column field="localisation" header="Localisation" sortable />
<Column header="Actions" body={actionBodyTemplate} style={{ width: '100px' }} />
</>
) : (
<>
<Column field="nom" header="Nom" body={(rowData) => `${rowData.prenom} ${rowData.nom}`} sortable />
<Column field="metier" header="Métier" sortable />
<Column field="competences" header="Compétences" body={competencesBodyTemplate} />
<Column field="experience" header="Expérience" body={experienceBodyTemplate} sortable />
<Column field="disponibilite" header="Disponibilité" body={disponibiliteBodyTemplate} sortable />
<Column field="chantierActuel" header="Chantier actuel" sortable />
<Column field="tauxHoraire" header="Taux horaire" body={(rowData) => `${rowData.tauxHoraire}`} sortable />
<Column header="Actions" body={actionBodyTemplate} style={{ width: '100px' }} />
</>
)}
</DataTable>
</Card>
</div>
</div>
);
};
export default DashboardRessources;

View File

@@ -0,0 +1,650 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { Card } from 'primereact/card';
import { Chart } from 'primereact/chart';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Tag } from 'primereact/tag';
import { Button } from 'primereact/button';
import { Toast } from 'primereact/toast';
import { Calendar } from 'primereact/calendar';
import { Badge } from 'primereact/badge';
import { Timeline } from 'primereact/timeline';
import { ProgressBar } from 'primereact/progressbar';
import { Divider } from 'primereact/divider';
import { Panel } from 'primereact/panel';
import { useRouter } from 'next/navigation';
interface ResumeQuotidien {
date: Date;
chantiers: {
actifs: number;
nouveaux: number;
termines: number;
retards: number;
};
employes: {
presents: number;
absents: number;
enMission: number;
disponibles: number;
};
materiels: {
utilises: number;
disponibles: number;
enMaintenance: number;
horsService: number;
};
finances: {
chiffreAffaires: number;
depenses: number;
benefice: number;
facturesEmises: number;
};
alertes: {
critiques: number;
elevees: number;
moyennes: number;
total: number;
};
evenements: EvenementQuotidien[];
objectifs: ObjectifQuotidien[];
}
interface EvenementQuotidien {
id: string;
heure: string;
type: string;
titre: string;
description: string;
statut: string;
importance: string;
}
interface ObjectifQuotidien {
id: string;
titre: string;
description: string;
progression: number;
echeance: Date;
responsable: string;
statut: string;
}
const DashboardResumeQuotidien = () => {
const toast = useRef<Toast>(null);
const router = useRouter();
const [resume, setResume] = useState<ResumeQuotidien | null>(null);
const [loading, setLoading] = useState(true);
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [chartData, setChartData] = useState({});
const [chartOptions, setChartOptions] = useState({});
useEffect(() => {
loadResumeQuotidien();
initCharts();
}, [selectedDate]);
const loadResumeQuotidien = async () => {
try {
setLoading(true);
// TODO: Remplacer par un vrai appel API
// const response = await dashboardService.getResumeQuotidien(selectedDate);
// Données simulées pour la démonstration
const mockResume: ResumeQuotidien = {
date: selectedDate,
chantiers: {
actifs: 8,
nouveaux: 2,
termines: 1,
retards: 3
},
employes: {
presents: 45,
absents: 5,
enMission: 38,
disponibles: 7
},
materiels: {
utilises: 28,
disponibles: 12,
enMaintenance: 4,
horsService: 2
},
finances: {
chiffreAffaires: 125000,
depenses: 89000,
benefice: 36000,
facturesEmises: 8
},
alertes: {
critiques: 2,
elevees: 5,
moyennes: 12,
total: 19
},
evenements: [
{
id: '1',
heure: '08:00',
type: 'REUNION',
titre: 'Briefing équipe Résidence Les Jardins',
description: 'Point sécurité et planning de la journée',
statut: 'TERMINE',
importance: 'HAUTE'
},
{
id: '2',
heure: '09:30',
type: 'LIVRAISON',
titre: 'Réception matériaux Centre Commercial',
description: 'Livraison acier pour structure',
statut: 'EN_COURS',
importance: 'HAUTE'
},
{
id: '3',
heure: '11:00',
type: 'MAINTENANCE',
titre: 'Révision pelleteuse CAT 320D',
description: 'Maintenance préventive système hydraulique',
statut: 'PLANIFIE',
importance: 'MOYENNE'
},
{
id: '4',
heure: '14:00',
titre: 'Coulage dalle béton',
type: 'CHANTIER',
description: 'Coulage niveau R+1 Résidence Les Jardins',
statut: 'PLANIFIE',
importance: 'HAUTE'
},
{
id: '5',
heure: '16:30',
type: 'REUNION',
titre: 'Point client Hôtel Luxe',
description: 'Validation avancement travaux',
statut: 'PLANIFIE',
importance: 'MOYENNE'
}
],
objectifs: [
{
id: '1',
titre: 'Finaliser gros œuvre Résidence',
description: 'Terminer le gros œuvre du bâtiment A',
progression: 85,
echeance: new Date('2025-01-15'),
responsable: 'Jean Dupont',
statut: 'EN_COURS'
},
{
id: '2',
titre: 'Livraison Centre Commercial',
description: 'Respecter la date de livraison prévue',
progression: 60,
echeance: new Date('2025-02-28'),
responsable: 'Marie Martin',
statut: 'EN_COURS'
},
{
id: '3',
titre: 'Formation équipe sécurité',
description: 'Former 100% de l\'équipe aux nouvelles normes',
progression: 40,
echeance: new Date('2025-01-31'),
responsable: 'Pierre Leroy',
statut: 'EN_COURS'
}
]
};
setResume(mockResume);
} catch (error) {
console.error('Erreur lors du chargement du résumé quotidien:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger le résumé quotidien'
});
} finally {
setLoading(false);
}
};
const initCharts = () => {
if (!resume) return;
const documentStyle = getComputedStyle(document.documentElement);
// Graphique de répartition des chantiers
const chantiersData = {
labels: ['Actifs', 'Nouveaux', 'Terminés', 'En retard'],
datasets: [
{
data: [
resume.chantiers.actifs,
resume.chantiers.nouveaux,
resume.chantiers.termines,
resume.chantiers.retards
],
backgroundColor: [
documentStyle.getPropertyValue('--green-500'),
documentStyle.getPropertyValue('--blue-500'),
documentStyle.getPropertyValue('--purple-500'),
documentStyle.getPropertyValue('--red-500')
]
}
]
};
const options = {
plugins: {
legend: {
labels: {
usePointStyle: true,
color: documentStyle.getPropertyValue('--text-color')
}
}
}
};
setChartData(chantiersData);
setChartOptions(options);
};
const getEvenementIcon = (type: string) => {
switch (type) {
case 'REUNION': return 'pi pi-users';
case 'LIVRAISON': return 'pi pi-truck';
case 'MAINTENANCE': return 'pi pi-wrench';
case 'CHANTIER': return 'pi pi-building';
default: return 'pi pi-calendar';
}
};
const getEvenementColor = (importance: string) => {
switch (importance) {
case 'HAUTE': return '#dc2626';
case 'MOYENNE': return '#d97706';
case 'BASSE': return '#059669';
default: return '#6b7280';
}
};
const getStatutSeverity = (statut: string) => {
switch (statut) {
case 'TERMINE': return 'success';
case 'EN_COURS': return 'warning';
case 'PLANIFIE': return 'info';
case 'ANNULE': return 'danger';
default: return 'secondary';
}
};
const evenementStatutTemplate = (rowData: EvenementQuotidien) => (
<Tag value={rowData.statut} severity={getStatutSeverity(rowData.statut)} />
);
const evenementTypeTemplate = (rowData: EvenementQuotidien) => (
<div className="flex align-items-center">
<i className={`${getEvenementIcon(rowData.type)} mr-2`} style={{ color: getEvenementColor(rowData.importance) }}></i>
<span>{rowData.type}</span>
</div>
);
const objectifProgressionTemplate = (rowData: ObjectifQuotidien) => (
<div className="flex align-items-center">
<ProgressBar
value={rowData.progression}
style={{ width: '100px', marginRight: '8px' }}
showValue={false}
/>
<span className="text-sm font-semibold">{rowData.progression}%</span>
</div>
);
const objectifEcheanceTemplate = (rowData: ObjectifQuotidien) => {
const now = new Date();
const echeance = rowData.echeance;
const diffDays = Math.ceil((echeance.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
let severity = 'info';
if (diffDays < 0) severity = 'danger';
else if (diffDays < 7) severity = 'warning';
else if (diffDays < 30) severity = 'info';
return (
<div>
<div className="text-sm">{echeance.toLocaleDateString('fr-FR')}</div>
<Tag
value={diffDays < 0 ? 'Échue' : `${diffDays} jours`}
severity={severity}
className="text-xs mt-1"
/>
</div>
);
};
if (!resume) {
return <div>Chargement...</div>;
}
return (
<div className="grid">
<Toast ref={toast} />
{/* En-tête avec sélection de date */}
<div className="col-12">
<Card>
<div className="flex justify-content-between align-items-center mb-4">
<h2 className="text-2xl font-bold m-0">Résumé Quotidien</h2>
<div className="flex gap-3 align-items-center">
<Calendar
value={selectedDate}
onChange={(e) => setSelectedDate(e.value as Date)}
dateFormat="dd/mm/yy"
showIcon
placeholder="Sélectionner une date"
/>
<Button
icon="pi pi-refresh"
className="p-button-outlined"
onClick={loadResumeQuotidien}
loading={loading}
tooltip="Actualiser"
/>
<Button
label="Exporter PDF"
icon="pi pi-file-pdf"
className="p-button-outlined"
onClick={() => {
toast.current?.show({
severity: 'info',
summary: 'Export en cours',
detail: 'Génération du rapport PDF...',
life: 3000
});
}}
/>
</div>
</div>
<div className="text-center">
<h3 className="text-xl font-semibold text-primary">
{selectedDate.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</h3>
</div>
</Card>
</div>
{/* Métriques principales */}
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Chantiers Actifs</span>
<div className="text-900 font-medium text-xl">{resume.chantiers.actifs}</div>
<div className="text-sm text-500">
{resume.chantiers.nouveaux} nouveaux, {resume.chantiers.retards} en retard
</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-building text-blue-500 text-xl"></i>
</div>
</div>
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Employés Présents</span>
<div className="text-900 font-medium text-xl">{resume.employes.presents}</div>
<div className="text-sm text-500">
{resume.employes.enMission} en mission, {resume.employes.disponibles} disponibles
</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-users text-green-500 text-xl"></i>
</div>
</div>
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Matériels Utilisés</span>
<div className="text-900 font-medium text-xl">{resume.materiels.utilises}</div>
<div className="text-sm text-500">
{resume.materiels.disponibles} disponibles, {resume.materiels.enMaintenance} en maintenance
</div>
</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-cog text-orange-500 text-xl"></i>
</div>
</div>
</Card>
</div>
<div className="col-12 lg:col-3 md:col-6">
<Card className="h-full">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">CA du Jour</span>
<div className="text-900 font-medium text-xl">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', notation: 'compact' }).format(resume.finances.chiffreAffaires)}
</div>
<div className="text-sm text-500">
Bénéfice: {new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', notation: 'compact' }).format(resume.finances.benefice)}
</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-euro text-purple-500 text-xl"></i>
</div>
</div>
</Card>
</div>
{/* Alertes du jour */}
{resume.alertes.total > 0 && (
<div className="col-12">
<Card>
<div className="flex justify-content-between align-items-center mb-3">
<h6>Alertes du Jour</h6>
<Badge value={`${resume.alertes.total} alertes`} severity="warning" />
</div>
<div className="grid">
<div className="col-12 md:col-4">
<div className="text-center p-3 border-round bg-red-50">
<div className="text-2xl font-bold text-red-500">{resume.alertes.critiques}</div>
<div className="text-sm text-red-600">Critiques</div>
</div>
</div>
<div className="col-12 md:col-4">
<div className="text-center p-3 border-round bg-orange-50">
<div className="text-2xl font-bold text-orange-500">{resume.alertes.elevees}</div>
<div className="text-sm text-orange-600">Élevées</div>
</div>
</div>
<div className="col-12 md:col-4">
<div className="text-center p-3 border-round bg-yellow-50">
<div className="text-2xl font-bold text-yellow-500">{resume.alertes.moyennes}</div>
<div className="text-sm text-yellow-600">Moyennes</div>
</div>
</div>
</div>
<div className="text-center mt-3">
<Button
label="Voir toutes les alertes"
icon="pi pi-external-link"
className="p-button-outlined"
onClick={() => router.push('/dashboard/alertes')}
/>
</div>
</Card>
</div>
)}
{/* Graphique et indicateurs */}
<div className="col-12 lg:col-6">
<Card>
<h6>Répartition des Chantiers</h6>
<Chart type="doughnut" data={chartData} options={chartOptions} className="w-full md:w-30rem" />
</Card>
</div>
<div className="col-12 lg:col-6">
<Card>
<h6>Indicateurs Financiers</h6>
<div className="grid">
<div className="col-6">
<div className="text-center">
<div className="text-2xl font-bold text-green-500">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', notation: 'compact' }).format(resume.finances.chiffreAffaires)}
</div>
<div className="text-sm text-500">Chiffre d'affaires</div>
</div>
</div>
<div className="col-6">
<div className="text-center">
<div className="text-2xl font-bold text-red-500">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', notation: 'compact' }).format(resume.finances.depenses)}
</div>
<div className="text-sm text-500">Dépenses</div>
</div>
</div>
<div className="col-6">
<div className="text-center">
<div className="text-2xl font-bold text-blue-500">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', notation: 'compact' }).format(resume.finances.benefice)}
</div>
<div className="text-sm text-500">Bénéfice</div>
</div>
</div>
<div className="col-6">
<div className="text-center">
<div className="text-2xl font-bold text-purple-500">{resume.finances.facturesEmises}</div>
<div className="text-sm text-500">Factures émises</div>
</div>
</div>
</div>
</Card>
</div>
{/* Planning de la journée */}
<div className="col-12 lg:col-8">
<Card>
<div className="flex justify-content-between align-items-center mb-4">
<h6>Planning de la Journée</h6>
<Badge value={`${resume.evenements.length} événements`} />
</div>
<DataTable
value={resume.evenements}
responsiveLayout="scroll"
emptyMessage="Aucun événement planifié"
>
<Column field="heure" header="Heure" style={{ width: '80px' }} />
<Column field="type" header="Type" body={evenementTypeTemplate} />
<Column field="titre" header="Titre" />
<Column field="description" header="Description" />
<Column field="statut" header="Statut" body={evenementStatutTemplate} />
</DataTable>
</Card>
</div>
{/* Objectifs en cours */}
<div className="col-12 lg:col-4">
<Card>
<div className="flex justify-content-between align-items-center mb-4">
<h6>Objectifs en Cours</h6>
<Badge value={`${resume.objectifs.length} objectifs`} severity="info" />
</div>
<div className="space-y-3">
{resume.objectifs.map((objectif, index) => (
<Panel key={index} header={objectif.titre} className="mb-3">
<p className="text-sm text-500 mb-3">{objectif.description}</p>
<div className="flex justify-content-between align-items-center mb-2">
<span className="text-sm font-medium">Progression</span>
<span className="text-sm font-bold">{objectif.progression}%</span>
</div>
<ProgressBar value={objectif.progression} className="mb-3" />
<div className="flex justify-content-between text-sm text-500">
<span>Responsable: {objectif.responsable}</span>
<span>Échéance: {objectif.echeance.toLocaleDateString('fr-FR')}</span>
</div>
</Panel>
))}
</div>
<div className="text-center mt-3">
<Button
label="Voir tous les objectifs"
icon="pi pi-external-link"
className="p-button-outlined"
onClick={() => router.push('/objectifs')}
/>
</div>
</Card>
</div>
{/* Actions rapides */}
<div className="col-12">
<Card>
<h6>Actions Rapides</h6>
<div className="flex flex-wrap gap-3">
<Button
label="Nouveau chantier"
icon="pi pi-plus"
onClick={() => router.push('/chantiers/nouveau')}
/>
<Button
label="Planifier événement"
icon="pi pi-calendar-plus"
className="p-button-outlined"
onClick={() => router.push('/planning/nouveau')}
/>
<Button
label="Créer facture"
icon="pi pi-file-plus"
className="p-button-outlined"
onClick={() => router.push('/factures/nouvelle')}
/>
<Button
label="Rapport incident"
icon="pi pi-exclamation-triangle"
className="p-button-outlined p-button-warning"
onClick={() => router.push('/incidents/nouveau')}
/>
<Button
label="Vue d'ensemble"
icon="pi pi-chart-line"
className="p-button-outlined p-button-info"
onClick={() => router.push('/dashboard')}
/>
</div>
</Card>
</div>
</div>
);
};
export default DashboardResumeQuotidien;

View File

@@ -0,0 +1,522 @@
'use client';
import React, { useState, 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 { InputText } from 'primereact/inputtext';
import { Dialog } from 'primereact/dialog';
import { InputNumber } from 'primereact/inputnumber';
import { InputTextarea } from 'primereact/inputtextarea';
import { Dropdown } from 'primereact/dropdown';
import { Toast } from 'primereact/toast';
import { Menu } from 'primereact/menu';
import { Badge } from 'primereact/badge';
import { useStocks } from '../../../../hooks/useStocks';
import { Stock, StockAlert, StockStats } from '../../../../types/stocks';
const DashboardStocksPage = () => {
const { stocks, loading, refresh, entreeStock, sortieStock, inventaire, reserverStock } = useStocks();
const [globalFilter, setGlobalFilter] = useState('');
const [selectedStock, setSelectedStock] = useState<Stock | null>(null);
const [showEntreeDialog, setShowEntreeDialog] = useState(false);
const [showSortieDialog, setShowSortieDialog] = useState(false);
const [showInventaireDialog, setShowInventaireDialog] = useState(false);
const [showReservationDialog, setShowReservationDialog] = useState(false);
const [quantite, setQuantite] = useState<number>(0);
const [coutUnitaire, setCoutUnitaire] = useState<number>(0);
const [motif, setMotif] = useState('');
const [quantiteReelle, setQuantiteReelle] = useState<number>(0);
const [observations, setObservations] = useState('');
const toast = useRef<Toast>(null);
const menuRef = useRef<Menu>(null);
const statutColors: Record<StatutStock, string> = {
[StatutStock.ACTIF]: 'success',
[StatutStock.INACTIF]: 'warning',
[StatutStock.OBSOLETE]: 'warning',
[StatutStock.SUPPRIME]: 'danger',
[StatutStock.EN_COMMANDE]: 'info',
[StatutStock.EN_TRANSIT]: 'info',
[StatutStock.EN_CONTROLE]: 'warning',
[StatutStock.QUARANTAINE]: 'danger',
[StatutStock.DEFECTUEUX]: 'danger',
[StatutStock.PERDU]: 'danger',
[StatutStock.RESERVE]: 'info',
[StatutStock.EN_REPARATION]: 'warning'
};
const categorieLabels: Record<CategorieStock, string> = {
[CategorieStock.MATERIAUX_CONSTRUCTION]: 'Matériaux',
[CategorieStock.OUTILLAGE]: 'Outillage',
[CategorieStock.QUINCAILLERIE]: 'Quincaillerie',
[CategorieStock.EQUIPEMENTS_SECURITE]: 'Sécurité',
[CategorieStock.EQUIPEMENTS_TECHNIQUES]: 'Technique',
[CategorieStock.CONSOMMABLES]: 'Consommables',
[CategorieStock.VEHICULES_ENGINS]: 'Véhicules',
[CategorieStock.FOURNITURES_BUREAU]: 'Bureau',
[CategorieStock.PRODUITS_CHIMIQUES]: 'Chimiques',
[CategorieStock.PIECES_DETACHEES]: 'Pièces',
[CategorieStock.EQUIPEMENTS_MESURE]: 'Mesure',
[CategorieStock.MOBILIER]: 'Mobilier',
[CategorieStock.AUTRE]: 'Autre'
};
const statutTemplate = (stock: Stock) => {
return <Tag value={stock.statut} severity={statutColors[stock.statut]} />;
};
const categorieTemplate = (stock: Stock) => {
return <Tag value={categorieLabels[stock.categorie]} className="p-tag-secondary" />;
};
const quantiteTemplate = (stock: Stock) => {
const quantiteDisponible = stock.quantiteStock - (stock.quantiteReservee || 0);
const severity = stock.quantiteStock === 0 ? 'danger' :
stock.quantiteMinimum && stock.quantiteStock < stock.quantiteMinimum ? 'warning' :
'success';
return (
<div className="flex align-items-center gap-2">
<Badge value={stock.quantiteStock.toString()} severity={severity} />
{stock.quantiteReservee && stock.quantiteReservee > 0 && (
<span className="text-sm text-500">({quantiteDisponible} dispo)</span>
)}
</div>
);
};
const valeurTemplate = (stock: Stock) => {
const valeur = stock.quantiteStock * (stock.coutMoyenPondere || 0);
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(valeur);
};
const emplacementTemplate = (stock: Stock) => {
const parts = [];
if (stock.codeZone) parts.push(stock.codeZone);
if (stock.codeAllee) parts.push(stock.codeAllee);
if (stock.codeEtagere) parts.push(stock.codeEtagere);
return parts.join('-') || stock.emplacementStockage || '-';
};
const alertesTemplate = (stock: Stock) => {
const alertes = [];
if (stock.quantiteStock === 0) {
alertes.push(<Tag key="rupture" value="Rupture" severity="danger" className="mr-1" />);
} else if (stock.quantiteMinimum && stock.quantiteStock < stock.quantiteMinimum) {
alertes.push(<Tag key="minimum" value="Stock faible" severity="warning" className="mr-1" />);
}
if (stock.datePeremption && new Date(stock.datePeremption) < new Date()) {
alertes.push(<Tag key="perime" value="Périmé" severity="danger" className="mr-1" />);
}
if (stock.articleDangereux) {
alertes.push(<Tag key="danger" value="Dangereux" severity="danger" className="mr-1" />);
}
return <div className="flex flex-wrap">{alertes}</div>;
};
const actionTemplate = (stock: Stock) => {
const items = [
{
label: 'Entrée de stock',
icon: 'pi pi-plus',
command: () => {
setSelectedStock(stock);
setQuantite(0);
setCoutUnitaire(stock.coutDerniereEntree || 0);
setMotif('');
setShowEntreeDialog(true);
}
},
{
label: 'Sortie de stock',
icon: 'pi pi-minus',
command: () => {
setSelectedStock(stock);
setQuantite(0);
setMotif('');
setShowSortieDialog(true);
}
},
{
label: 'Inventaire',
icon: 'pi pi-check-square',
command: () => {
setSelectedStock(stock);
setQuantiteReelle(stock.quantiteStock);
setObservations('');
setShowInventaireDialog(true);
}
},
{
label: 'Réserver',
icon: 'pi pi-lock',
command: () => {
setSelectedStock(stock);
setQuantite(0);
setShowReservationDialog(true);
}
}
];
return (
<>
<Button
icon="pi pi-ellipsis-v"
className="p-button-text p-button-rounded"
onClick={(e) => {
setSelectedStock(stock);
menuRef.current?.toggle(e);
}}
/>
<Menu
ref={menuRef}
model={items}
popup
/>
</>
);
};
const handleEntree = async () => {
if (!selectedStock) return;
try {
await entreeStock({
stockId: selectedStock.id!,
quantite,
coutUnitaire,
motif
});
toast.current?.show({ severity: 'success', summary: 'Succès', detail: 'Entrée de stock effectuée' });
setShowEntreeDialog(false);
} catch (error) {
toast.current?.show({ severity: 'error', summary: 'Erreur', detail: 'Erreur lors de l\'entrée de stock' });
}
};
const handleSortie = async () => {
if (!selectedStock) return;
try {
await sortieStock({
stockId: selectedStock.id!,
quantite,
motif
});
toast.current?.show({ severity: 'success', summary: 'Succès', detail: 'Sortie de stock effectuée' });
setShowSortieDialog(false);
} catch (error) {
toast.current?.show({ severity: 'error', summary: 'Erreur', detail: 'Erreur lors de la sortie de stock' });
}
};
const handleInventaire = async () => {
if (!selectedStock) return;
try {
await inventaire(selectedStock.id!, quantiteReelle, observations);
toast.current?.show({ severity: 'success', summary: 'Succès', detail: 'Inventaire effectué' });
setShowInventaireDialog(false);
} catch (error) {
toast.current?.show({ severity: 'error', summary: 'Erreur', detail: 'Erreur lors de l\'inventaire' });
}
};
const handleReservation = async () => {
if (!selectedStock) return;
try {
await reserverStock(selectedStock.id!, quantite);
toast.current?.show({ severity: 'success', summary: 'Succès', detail: 'Réservation effectuée' });
setShowReservationDialog(false);
} catch (error) {
toast.current?.show({ severity: 'error', summary: 'Erreur', detail: 'Erreur lors de la réservation' });
}
};
// Statistiques
const stocksEnRupture = stocks.filter(s => s.quantiteStock === 0).length;
const stocksSousMinimum = stocks.filter(s => s.quantiteMinimum && s.quantiteStock < s.quantiteMinimum).length;
const valeurTotale = stocks.reduce((sum, s) => sum + (s.quantiteStock * (s.coutMoyenPondere || 0)), 0);
const articlesActifs = stocks.filter(s => s.statut === StatutStock.ACTIF).length;
const header = (
<div className="flex justify-content-between align-items-center">
<h5 className="m-0">Gestion des stocks</h5>
<span className="p-input-icon-left">
<i className="pi pi-search" />
<InputText
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder="Rechercher..."
/>
</span>
</div>
);
return (
<div className="grid">
<Toast ref={toast} />
<div className="col-12">
<h4>Tableau de bord des stocks</h4>
</div>
{/* Statistiques */}
<div className="col-12 md:col-6 lg:col-3">
<Card className="mb-0">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Articles actifs</span>
<div className="text-900 font-medium text-xl">{articlesActifs}</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-box text-blue-500 text-xl" />
</div>
</div>
</Card>
</div>
<div className="col-12 md:col-6 lg:col-3">
<Card className="mb-0">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">En rupture</span>
<div className="text-900 font-medium text-xl">{stocksEnRupture}</div>
</div>
<div className="flex align-items-center justify-content-center bg-red-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
<i className="pi pi-exclamation-circle text-red-500 text-xl" />
</div>
</div>
</Card>
</div>
<div className="col-12 md:col-6 lg:col-3">
<Card className="mb-0">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Stock faible</span>
<div className="text-900 font-medium text-xl">{stocksSousMinimum}</div>
</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-exclamation-triangle text-orange-500 text-xl" />
</div>
</div>
</Card>
</div>
<div className="col-12 md:col-6 lg:col-3">
<Card className="mb-0">
<div className="flex justify-content-between mb-3">
<div>
<span className="block text-500 font-medium mb-3">Valeur totale</span>
<div className="text-900 font-medium text-xl">
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(valeurTotale)}
</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-euro text-green-500 text-xl" />
</div>
</div>
</Card>
</div>
{/* Table des stocks */}
<div className="col-12">
<Card header={header}>
<DataTable
value={stocks}
loading={loading}
paginator
rows={10}
rowsPerPageOptions={[10, 25, 50]}
className="p-datatable-gridlines"
emptyMessage="Aucun article trouvé"
globalFilter={globalFilter}
globalFilterFields={['reference', 'designation', 'categorie', 'marque']}
>
<Column field="reference" header="Référence" sortable style={{ width: '10%' }} />
<Column field="designation" header="Désignation" sortable />
<Column header="Catégorie" body={categorieTemplate} sortable />
<Column header="Quantité" body={quantiteTemplate} sortable />
<Column field="uniteMesure" header="Unité" />
<Column header="Valeur" body={valeurTemplate} sortable />
<Column header="Emplacement" body={emplacementTemplate} />
<Column header="Statut" body={statutTemplate} />
<Column header="Alertes" body={alertesTemplate} />
<Column header="Actions" body={actionTemplate} />
</DataTable>
</Card>
</div>
{/* Dialog entrée */}
<Dialog
header="Entrée de stock"
visible={showEntreeDialog}
style={{ width: '450px' }}
onHide={() => setShowEntreeDialog(false)}
footer={
<div>
<Button label="Annuler" icon="pi pi-times" className="p-button-text" onClick={() => setShowEntreeDialog(false)} />
<Button label="Valider" icon="pi pi-check" onClick={handleEntree} />
</div>
}
>
<div className="p-fluid">
<div className="field">
<label htmlFor="quantite">Quantité</label>
<InputNumber
id="quantite"
value={quantite}
onValueChange={(e) => setQuantite(e.value || 0)}
min={0}
showButtons
/>
</div>
<div className="field">
<label htmlFor="cout">Coût unitaire HT</label>
<InputNumber
id="cout"
value={coutUnitaire}
onValueChange={(e) => setCoutUnitaire(e.value || 0)}
mode="currency"
currency="EUR"
locale="fr-FR"
/>
</div>
<div className="field">
<label htmlFor="motif">Motif</label>
<InputTextarea
id="motif"
value={motif}
onChange={(e) => setMotif(e.target.value)}
rows={3}
/>
</div>
</div>
</Dialog>
{/* Dialog sortie */}
<Dialog
header="Sortie de stock"
visible={showSortieDialog}
style={{ width: '450px' }}
onHide={() => setShowSortieDialog(false)}
footer={
<div>
<Button label="Annuler" icon="pi pi-times" className="p-button-text" onClick={() => setShowSortieDialog(false)} />
<Button label="Valider" icon="pi pi-check" onClick={handleSortie} />
</div>
}
>
<div className="p-fluid">
<div className="field">
<label htmlFor="quantite">Quantité</label>
<InputNumber
id="quantite"
value={quantite}
onValueChange={(e) => setQuantite(e.value || 0)}
min={0}
max={selectedStock?.quantiteStock}
showButtons
/>
{selectedStock && (
<small>Stock disponible: {selectedStock.quantiteStock - (selectedStock.quantiteReservee || 0)}</small>
)}
</div>
<div className="field">
<label htmlFor="motif">Motif</label>
<InputTextarea
id="motif"
value={motif}
onChange={(e) => setMotif(e.target.value)}
rows={3}
required
/>
</div>
</div>
</Dialog>
{/* Dialog inventaire */}
<Dialog
header="Inventaire"
visible={showInventaireDialog}
style={{ width: '450px' }}
onHide={() => setShowInventaireDialog(false)}
footer={
<div>
<Button label="Annuler" icon="pi pi-times" className="p-button-text" onClick={() => setShowInventaireDialog(false)} />
<Button label="Valider" icon="pi pi-check" onClick={handleInventaire} />
</div>
}
>
<div className="p-fluid">
<div className="field">
<label htmlFor="quantiteReelle">Quantité réelle comptée</label>
<InputNumber
id="quantiteReelle"
value={quantiteReelle}
onValueChange={(e) => setQuantiteReelle(e.value || 0)}
min={0}
showButtons
/>
{selectedStock && (
<small>Stock théorique: {selectedStock.quantiteStock}</small>
)}
</div>
<div className="field">
<label htmlFor="observations">Observations</label>
<InputTextarea
id="observations"
value={observations}
onChange={(e) => setObservations(e.target.value)}
rows={3}
/>
</div>
</div>
</Dialog>
{/* Dialog réservation */}
<Dialog
header="Réservation de stock"
visible={showReservationDialog}
style={{ width: '450px' }}
onHide={() => setShowReservationDialog(false)}
footer={
<div>
<Button label="Annuler" icon="pi pi-times" className="p-button-text" onClick={() => setShowReservationDialog(false)} />
<Button label="Réserver" icon="pi pi-check" onClick={handleReservation} />
</div>
}
>
<div className="p-fluid">
<div className="field">
<label htmlFor="quantite">Quantité à réserver</label>
<InputNumber
id="quantite"
value={quantite}
onValueChange={(e) => setQuantite(e.value || 0)}
min={0}
max={selectedStock ? selectedStock.quantiteStock - (selectedStock.quantiteReservee || 0) : 0}
showButtons
/>
{selectedStock && (
<small>Disponible à réserver: {selectedStock.quantiteStock - (selectedStock.quantiteReservee || 0)}</small>
)}
</div>
</div>
</Dialog>
</div>
);
};
export default DashboardStocksPage;

View File

@@ -0,0 +1,470 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { Card } from 'primereact/card';
import { Chart } from 'primereact/chart';
import { Badge } from 'primereact/badge';
import { Tag } from 'primereact/tag';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Button } from 'primereact/button';
import { ProgressBar } from 'primereact/progressbar';
import { Knob } from 'primereact/knob';
import { Timeline } from 'primereact/timeline';
import { Divider } from 'primereact/divider';
import { Toast } from 'primereact/toast';
/**
* Dashboard Temps Réel BTP Express
* Surveillance en temps réel des chantiers, équipes et performances
*/
const DashboardTempsReel = () => {
const [donneesTR, setDonneesTR] = useState<any>({});
const [derniereMiseAJour, setDerniereMiseAJour] = useState<Date>(new Date());
const [alertesActives, setAlertesActives] = useState<any[]>([]);
const [evolutionCA, setEvolutionCA] = useState<any>({});
const [interventionsUrgentes, setInterventionsUrgentes] = useState<any[]>([]);
const toast = useRef<Toast>(null);
useEffect(() => {
// Simulation données temps réel
chargerDonneesTempsReel();
// Mise à jour automatique toutes les 30 secondes
const interval = setInterval(() => {
chargerDonneesTempsReel();
}, 30000);
return () => clearInterval(interval);
}, []);
const chargerDonneesTempsReel = () => {
// TODO: Remplacer par des appels API réels pour les données temps réel
// const donnees = await dashboardService.getDonneesTempsReel();
// Initialisation avec des données vides plutôt que des données fictives
const donnees = {
chantiersEnCours: {
total: 0,
nouveauxAujourdhui: 0,
terminesAujourdhui: 0,
enRetard: 0
},
equipes: {
totalActives: 0,
surSite: 0,
disponibles: 0,
enDeplacementProchain: 0
},
materiel: {
enUtilisation: 0,
disponible: 0,
enMaintenance: 0,
alertesStock: 0
},
financier: {
caJournalier: 0,
objectifJour: 0,
margeJour: 0,
facturations: 0
},
securite: {
accidentsJour: 0,
joursDepuisDernierAccident: 0,
controlsSecurite: 0,
epiOk: 0
}
};
setDonneesTR(donnees);
setDerniereMiseAJour(new Date());
// Alertes vides jusqu'à ce que l'API fournisse les vraies données
setAlertesActives([]);
// Graphique avec données vides
setEvolutionCA({
labels: ['08h', '10h', '12h', '14h', '16h', '18h'],
datasets: [
{
label: 'CA Réalisé',
data: new Array(6).fill(0),
borderColor: '#4BC0C0',
backgroundColor: 'rgba(75, 192, 192, 0.1)',
tension: 0.4,
fill: true
},
{
label: 'Objectif',
data: new Array(6).fill(0),
borderColor: '#FF6B6B',
backgroundColor: 'rgba(255, 107, 107, 0.1)',
borderDash: [5, 5],
tension: 0.4,
fill: false
}
]
});
// Interventions vides jusqu'à ce que l'API fournisse les vraies données
setInterventionsUrgentes([]);
};
const chartOptions = {
responsive: true,
plugins: {
legend: {
position: 'top' as const,
},
title: {
display: true,
text: 'Évolution CA Journalier'
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: (value: any) => `${value}`
}
}
}
};
const getAlerteSeverityConfig = (severity: string) => {
switch (severity) {
case 'high':
return { color: 'danger', icon: 'pi-exclamation-triangle' };
case 'medium':
return { color: 'warning', icon: 'pi-info-circle' };
case 'low':
return { color: 'info', icon: 'pi-check-circle' };
default:
return { color: 'secondary', icon: 'pi-info' };
}
};
const getUrgenceConfig = (urgence: string) => {
switch (urgence) {
case 'CRITIQUE':
return { color: 'danger', label: 'CRITIQUE' };
case 'HAUTE':
return { color: 'warning', label: 'HAUTE' };
case 'MOYENNE':
return { color: 'info', label: 'MOYENNE' };
default:
return { color: 'secondary', label: urgence };
}
};
const actualiserDonnees = () => {
chargerDonneesTempsReel();
toast.current?.show({
severity: 'success',
summary: 'Données actualisées',
detail: 'Mise à jour terminée',
life: 3000
});
};
return (
<div className="grid">
<Toast ref={toast} />
{/* En-tête avec actualisation */}
<div className="col-12">
<div className="flex justify-content-between align-items-center mb-4">
<h2 className="text-2xl font-bold m-0">
<i className="pi pi-radar mr-2 text-primary" />
Dashboard Temps Réel BTP
</h2>
<div className="flex align-items-center gap-3">
<span className="text-sm text-color-secondary">
Dernière MAJ: {derniereMiseAJour.toLocaleTimeString('fr-FR')}
</span>
<Button
icon="pi pi-refresh"
label="Actualiser"
size="small"
onClick={actualiserDonnees}
/>
</div>
</div>
</div>
{/* KPIs Temps Réel */}
<div className="col-12 md:col-6 lg:col-3">
<Card className="h-full">
<div className="flex justify-content-between align-items-start">
<div>
<div className="text-2xl font-bold text-blue-500">
{donneesTR.chantiersEnCours?.total || 0}
</div>
<div className="text-color-secondary mb-2">Chantiers actifs</div>
<div className="flex gap-2">
<Badge value={`+${donneesTR.chantiersEnCours?.nouveauxAujourdhui || 0}`} severity="success" />
<Badge value={`${donneesTR.chantiersEnCours?.enRetard || 0} retard`} severity="danger" />
</div>
</div>
<i className="pi pi-map text-blue-500 text-3xl" />
</div>
</Card>
</div>
<div className="col-12 md:col-6 lg:col-3">
<Card className="h-full">
<div className="flex justify-content-between align-items-start">
<div>
<div className="text-2xl font-bold text-green-500">
{donneesTR.equipes?.surSite || 0}
</div>
<div className="text-color-secondary mb-2">Personnes sur site</div>
<div className="flex gap-2">
<Badge value={`${donneesTR.equipes?.totalActives || 0} équipes`} severity="info" />
<Badge value={`${donneesTR.equipes?.disponibles || 0} dispo`} severity="success" />
</div>
</div>
<i className="pi pi-users text-green-500 text-3xl" />
</div>
</Card>
</div>
<div className="col-12 md:col-6 lg:col-3">
<Card className="h-full">
<div className="flex justify-content-between align-items-start">
<div>
<div className="text-2xl font-bold text-purple-500">
{new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
notation: 'compact'
}).format(donneesTR.financier?.caJournalier || 0)}
</div>
<div className="text-color-secondary mb-2">CA Journalier</div>
<ProgressBar
value={((donneesTR.financier?.caJournalier || 0) / (donneesTR.financier?.objectifJour || 1)) * 100}
style={{ height: '8px' }}
color="#8B5CF6"
/>
</div>
<i className="pi pi-euro text-purple-500 text-3xl" />
</div>
</Card>
</div>
<div className="col-12 md:col-6 lg:col-3">
<Card className="h-full">
<div className="flex justify-content-between align-items-start">
<div>
<div className="text-center">
<Knob
value={donneesTR.securite?.epiOk || 0}
size={60}
strokeWidth={8}
valueColor="#10B981"
rangeColor="#E5E7EB"
/>
</div>
<div className="text-color-secondary text-center mt-2">EPI Conformes</div>
<div className="text-center">
<Badge value={`${donneesTR.securite?.joursDepuisDernierAccident || 0} jours`} severity="success" />
</div>
</div>
</div>
</Card>
</div>
{/* Graphique évolution CA */}
<div className="col-12 lg:col-8">
<Card title="Évolution CA Temps Réel">
<Chart type="line" data={evolutionCA} options={chartOptions} height="300px" />
</Card>
</div>
{/* Alertes en cours */}
<div className="col-12 lg:col-4">
<Card title="Alertes Actives" className="h-full">
<div className="flex flex-column gap-3">
{alertesActives.map((alerte) => {
const config = getAlerteSeverityConfig(alerte.severity);
return (
<div key={alerte.id} className="border-1 border-round p-3 surface-border">
<div className="flex align-items-start gap-2">
<i className={`pi ${config.icon} text-${config.color} mt-1`} />
<div className="flex-1">
<div className="text-sm font-medium">{alerte.message}</div>
<div className="text-xs text-color-secondary mt-1">
{alerte.heure.toLocaleTimeString('fr-FR')}
</div>
<Tag
value={alerte.type}
severity={config.color}
className="mt-2"
/>
</div>
</div>
</div>
);
})}
{alertesActives.length === 0 && (
<div className="text-center text-color-secondary p-4">
<i className="pi pi-check-circle text-green-500 text-3xl mb-2" />
<div>Aucune alerte active</div>
</div>
)}
</div>
</Card>
</div>
{/* Interventions urgentes */}
<div className="col-12">
<Card title="Interventions Urgentes en Cours">
<DataTable
value={interventionsUrgentes}
emptyMessage="Aucune intervention urgente"
responsiveLayout="scroll"
>
<Column
header="Type"
body={(rowData) => (
<Tag
value={rowData.type.replace('_', ' ')}
severity="warning"
icon="pi pi-exclamation-triangle"
/>
)}
/>
<Column field="chantier" header="Chantier" />
<Column field="description" header="Description" />
<Column
header="Urgence"
body={(rowData) => {
const config = getUrgenceConfig(rowData.urgence);
return <Tag value={config.label} severity={config.color} />;
}}
/>
<Column
header="Temps écoulé"
body={(rowData) => (
<Badge value={rowData.tempsEcoule} severity="danger" />
)}
/>
<Column field="technicien" header="Technicien assigné" />
<Column
body={(rowData) => (
<div className="flex gap-2">
<Button
icon="pi pi-phone"
size="small"
severity="success"
tooltip="Appeler"
/>
<Button
icon="pi pi-map-marker"
size="small"
severity="info"
tooltip="Localiser"
/>
<Button
icon="pi pi-check"
size="small"
severity="warning"
tooltip="Marquer résolu"
/>
</div>
)}
/>
</DataTable>
</Card>
</div>
{/* Matériel et ressources */}
<div className="col-12 md:col-6">
<Card title="État Matériel">
<div className="grid">
<div className="col-6">
<div className="text-center">
<div className="text-xl font-bold text-green-500">
{donneesTR.materiel?.enUtilisation || 0}
</div>
<div className="text-sm text-color-secondary">En utilisation</div>
</div>
</div>
<div className="col-6">
<div className="text-center">
<div className="text-xl font-bold text-blue-500">
{donneesTR.materiel?.disponible || 0}
</div>
<div className="text-sm text-color-secondary">Disponible</div>
</div>
</div>
<div className="col-6">
<div className="text-center">
<div className="text-xl font-bold text-orange-500">
{donneesTR.materiel?.enMaintenance || 0}
</div>
<div className="text-sm text-color-secondary">En maintenance</div>
</div>
</div>
<div className="col-6">
<div className="text-center">
<div className="text-xl font-bold text-red-500">
{donneesTR.materiel?.alertesStock || 0}
</div>
<div className="text-sm text-color-secondary">Alertes stock</div>
</div>
</div>
</div>
</Card>
</div>
{/* Performances financières */}
<div className="col-12 md:col-6">
<Card title="Performances Financières">
<div className="grid">
<div className="col-12">
<div className="flex justify-content-between align-items-center mb-3">
<span>Objectif journalier</span>
<span className="font-bold">
{((donneesTR.financier?.caJournalier || 0) / (donneesTR.financier?.objectifJour || 1) * 100).toFixed(1)}%
</span>
</div>
<ProgressBar
value={((donneesTR.financier?.caJournalier || 0) / (donneesTR.financier?.objectifJour || 1)) * 100}
style={{ height: '12px' }}
/>
</div>
<div className="col-6">
<div className="text-center">
<div className="text-xl font-bold text-green-500">
{donneesTR.financier?.margeJour || 0}%
</div>
<div className="text-sm text-color-secondary">Marge journalière</div>
</div>
</div>
<div className="col-6">
<div className="text-center">
<div className="text-xl font-bold text-cyan-500">
{donneesTR.financier?.facturations || 0}
</div>
<div className="text-sm text-color-secondary">Facturations</div>
</div>
</div>
</div>
</Card>
</div>
</div>
);
};
export default DashboardTempsReel;