Initial commit
This commit is contained in:
241
app/(main)/dashboard/__tests__/page.test.tsx
Normal file
241
app/(main)/dashboard/__tests__/page.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
698
app/(main)/dashboard/alertes/page.tsx
Normal file
698
app/(main)/dashboard/alertes/page.tsx
Normal 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;
|
||||
545
app/(main)/dashboard/chantiers/page.tsx
Normal file
545
app/(main)/dashboard/chantiers/page.tsx
Normal 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;
|
||||
538
app/(main)/dashboard/maintenance/page.tsx
Normal file
538
app/(main)/dashboard/maintenance/page.tsx
Normal 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;
|
||||
1073
app/(main)/dashboard/page-broken.tsx
Normal file
1073
app/(main)/dashboard/page-broken.tsx
Normal file
File diff suppressed because it is too large
Load Diff
834
app/(main)/dashboard/page.tsx
Normal file
834
app/(main)/dashboard/page.tsx
Normal 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;
|
||||
265
app/(main)/dashboard/phases/page.tsx
Normal file
265
app/(main)/dashboard/phases/page.tsx
Normal 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;
|
||||
712
app/(main)/dashboard/planning/page.tsx
Normal file
712
app/(main)/dashboard/planning/page.tsx
Normal 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;
|
||||
720
app/(main)/dashboard/ressources/page.tsx
Normal file
720
app/(main)/dashboard/ressources/page.tsx
Normal 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;
|
||||
650
app/(main)/dashboard/resume-quotidien/page.tsx
Normal file
650
app/(main)/dashboard/resume-quotidien/page.tsx
Normal 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;
|
||||
522
app/(main)/dashboard/stocks/page.tsx
Normal file
522
app/(main)/dashboard/stocks/page.tsx
Normal 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;
|
||||
470
app/(main)/dashboard/temps-reel/page.tsx
Normal file
470
app/(main)/dashboard/temps-reel/page.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user