Initial commit
This commit is contained in:
22
components/ClientProviders.tsx
Normal file
22
components/ClientProviders.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { LayoutProvider } from '../layout/context/layoutcontext';
|
||||
import { PrimeReactProvider } from 'primereact/api';
|
||||
import { AuthProvider } from '../contexts/AuthContext';
|
||||
import { DevAuthProvider } from './auth/DevAuthProvider';
|
||||
import { useServerStatusInit } from '../hooks/useServerStatusInit';
|
||||
|
||||
export function ClientProviders({ children }: { children: React.ReactNode }) {
|
||||
// Initialiser le monitoring serveur SSE globalement
|
||||
// useServerStatusInit(); // Temporairement désactivé - endpoint non disponible
|
||||
|
||||
return (
|
||||
<PrimeReactProvider>
|
||||
<DevAuthProvider>
|
||||
<AuthProvider>
|
||||
<LayoutProvider>{children}</LayoutProvider>
|
||||
</AuthProvider>
|
||||
</DevAuthProvider>
|
||||
</PrimeReactProvider>
|
||||
);
|
||||
}
|
||||
121
components/ConnectionStatus.tsx
Normal file
121
components/ConnectionStatus.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import { getServerStatusService, ServerStatusEvent } from '../services/serverStatusService';
|
||||
|
||||
interface ConnectionStatusProps {
|
||||
showToasts?: boolean;
|
||||
showIndicator?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ConnectionStatus: React.FC<ConnectionStatusProps> = ({
|
||||
showToasts = true,
|
||||
showIndicator = true,
|
||||
className = ''
|
||||
}) => {
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
const [lastEvent, setLastEvent] = useState<ServerStatusEvent | null>(null);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const toast = useRef<Toast>(null);
|
||||
const lastStatusRef = useRef<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
const handleServerStatusChange = (online: boolean, event?: ServerStatusEvent) => {
|
||||
const wasOnline = lastStatusRef.current;
|
||||
|
||||
if (wasOnline !== null && wasOnline !== online && showToasts) {
|
||||
if (online) {
|
||||
toast.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Serveur backend reconnecté',
|
||||
detail: 'Le serveur backend est de nouveau accessible',
|
||||
life: 4000
|
||||
});
|
||||
} else {
|
||||
toast.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Serveur backend inaccessible',
|
||||
detail: 'Le serveur backend ne répond pas. Vérifiez qu\'il est démarré (mvn quarkus:dev).',
|
||||
sticky: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setIsOnline(online);
|
||||
setLastEvent(event || null);
|
||||
lastStatusRef.current = online;
|
||||
};
|
||||
|
||||
// Obtenir l'instance du service côté client
|
||||
const service = getServerStatusService();
|
||||
if (!service) return;
|
||||
|
||||
// S'abonner aux changements de statut via SSE
|
||||
unsubscribe = service.onStatusChange(handleServerStatusChange);
|
||||
|
||||
// Statut initial
|
||||
const currentStatus = service.getCurrentStatus();
|
||||
setIsOnline(currentStatus);
|
||||
lastStatusRef.current = currentStatus;
|
||||
|
||||
return () => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}, [showToasts]);
|
||||
|
||||
const getStatusText = () => {
|
||||
if (isChecking) return 'Vérification...';
|
||||
return isOnline ? 'Serveur OK' : 'Serveur KO';
|
||||
};
|
||||
|
||||
const getStatusTooltip = () => {
|
||||
const baseStatus = isOnline ? 'Serveur backend accessible' : 'Serveur backend indisponible';
|
||||
const sseInfo = 'Monitoring via Server-Sent Events';
|
||||
const eventInfo = lastEvent ? `Dernière mise à jour: ${new Date(lastEvent.timestamp).toLocaleTimeString()}` : '';
|
||||
return `${baseStatus} - ${sseInfo}${eventInfo ? ` - ${eventInfo}` : ''}`;
|
||||
};
|
||||
|
||||
if (!showIndicator && showToasts) {
|
||||
return <Toast ref={toast} />;
|
||||
}
|
||||
|
||||
if (!showIndicator) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex align-items-center gap-2 ${className}`}>
|
||||
<Toast ref={toast} />
|
||||
|
||||
<div className="flex align-items-center gap-2">
|
||||
{isChecking ? (
|
||||
<ProgressSpinner
|
||||
style={{ width: '20px', height: '20px' }}
|
||||
strokeWidth="4"
|
||||
/>
|
||||
) : (
|
||||
<i
|
||||
className={`pi ${isOnline ? 'pi-circle-fill text-green-500' : 'pi-circle-fill text-red-500'}`}
|
||||
style={{ fontSize: '12px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span
|
||||
className={`text-sm font-medium ${isOnline ? 'text-green-700' : 'text-red-700'}`}
|
||||
title={getStatusTooltip()}
|
||||
>
|
||||
{getStatusText()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionStatus;
|
||||
53
components/ConnectionStatusSimple.tsx
Normal file
53
components/ConnectionStatusSimple.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Toast } from 'primereact/toast';
|
||||
|
||||
interface ConnectionStatusProps {
|
||||
showToasts?: boolean;
|
||||
showIndicator?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ConnectionStatusSimple: React.FC<ConnectionStatusProps> = ({
|
||||
showToasts = true,
|
||||
showIndicator = true,
|
||||
className = ''
|
||||
}) => {
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
const toast = useRef<Toast>(null);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('ConnectionStatusSimple mounted - no API calls for now');
|
||||
|
||||
// Pas d'API call pour le moment, juste pour tester le composant
|
||||
setIsOnline(true);
|
||||
}, []);
|
||||
|
||||
if (!showIndicator && showToasts) {
|
||||
return <Toast ref={toast} />;
|
||||
}
|
||||
|
||||
if (!showIndicator) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex align-items-center gap-2 ${className}`}>
|
||||
<Toast ref={toast} />
|
||||
<div className="flex align-items-center gap-2">
|
||||
<i
|
||||
className={`pi ${isOnline ? 'pi-circle-fill text-green-500' : 'pi-circle-fill text-red-500'}`}
|
||||
style={{ fontSize: '12px' }}
|
||||
/>
|
||||
<span
|
||||
className={`text-sm font-medium ${isOnline ? 'text-green-700' : 'text-red-700'}`}
|
||||
>
|
||||
{isOnline ? 'Serveur OK' : 'Serveur KO'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionStatusSimple;
|
||||
110
components/GlobalErrorHandler.tsx
Normal file
110
components/GlobalErrorHandler.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { apiService } from '../services/api';
|
||||
|
||||
interface GlobalErrorHandlerProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const GlobalErrorHandler: React.FC<GlobalErrorHandlerProps> = ({ children }) => {
|
||||
const toast = useRef<Toast>(null);
|
||||
const lastErrorTime = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Gestionnaire global d'erreurs non capturées
|
||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
const error = event.reason;
|
||||
|
||||
// Éviter de spammer les notifications (max 1 par seconde)
|
||||
const now = Date.now();
|
||||
if (now - lastErrorTime.current < 1000) {
|
||||
return;
|
||||
}
|
||||
lastErrorTime.current = now;
|
||||
|
||||
let message = 'Une erreur inattendue s\'est produite';
|
||||
let severity: 'error' | 'warn' | 'info' = 'error';
|
||||
|
||||
if (error?.statusCode === 'NETWORK_ERROR' || error?.statusCode === 'SERVER_UNAVAILABLE') {
|
||||
message = error.userMessage || 'Serveur indisponible';
|
||||
severity = 'error';
|
||||
|
||||
toast.current?.show({
|
||||
severity,
|
||||
summary: 'Serveur backend inaccessible',
|
||||
detail: message,
|
||||
sticky: true,
|
||||
content: (props) => (
|
||||
<div className="flex flex-column align-items-start" style={{ flex: '1' }}>
|
||||
<div className="flex align-items-center gap-2">
|
||||
<i className="pi pi-times-circle text-red-500"></i>
|
||||
<span className="font-semibold text-900">{props.summary}</span>
|
||||
</div>
|
||||
<div className="font-medium text-700 my-2">{message}</div>
|
||||
<div className="text-600 text-sm">
|
||||
<div>• Démarrez le serveur backend : <code>mvn quarkus:dev</code></div>
|
||||
<div>• Vérifiez que le port 8080 est libre</div>
|
||||
<div>• Contrôlez votre connexion internet</div>
|
||||
<div>• Testez l'accès : <code>http://localhost:8080</code></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
} else if (error?.statusCode === 'TIMEOUT') {
|
||||
message = error.userMessage || 'Délai d\'attente dépassé';
|
||||
severity = 'warn';
|
||||
|
||||
toast.current?.show({
|
||||
severity,
|
||||
summary: 'Délai dépassé',
|
||||
detail: message,
|
||||
life: 6000
|
||||
});
|
||||
} else if (error?.userMessage) {
|
||||
toast.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Erreur',
|
||||
detail: error.userMessage,
|
||||
life: 5000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Gestionnaire de statut serveur
|
||||
const handleServerStatusChange = (isOnline: boolean) => {
|
||||
if (isOnline) {
|
||||
// Nettoyer les messages d'erreur précédents
|
||||
toast.current?.clear();
|
||||
|
||||
toast.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Serveur backend reconnecté',
|
||||
detail: 'Le serveur backend est de nouveau accessible',
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Enregistrer les listeners
|
||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
const unsubscribe = apiService.onServerStatusChange(handleServerStatusChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toast ref={toast} position="top-right" />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalErrorHandler;
|
||||
132
components/ProtectedLayout.tsx
Normal file
132
components/ProtectedLayout.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import LoadingSpinner from './ui/LoadingSpinner';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface ProtectedLayoutProps {
|
||||
children: React.ReactNode;
|
||||
requiredRoles?: string[];
|
||||
requiredPermissions?: string[];
|
||||
}
|
||||
|
||||
const ProtectedLayout: React.FC<ProtectedLayoutProps> = ({
|
||||
children,
|
||||
requiredRoles = [],
|
||||
requiredPermissions = []
|
||||
}) => {
|
||||
const { isAuthenticated, isLoading, user, hasRole, hasPermission } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
// Ne pas rediriger si on est en train de traiter un code d'autorisation
|
||||
const currentUrl = window.location.href;
|
||||
const hasAuthCode = currentUrl.includes('code=') && currentUrl.includes('/dashboard');
|
||||
|
||||
if (!hasAuthCode) {
|
||||
// Rediriger vers la page de connexion avec l'URL de retour
|
||||
const currentPath = window.location.pathname + window.location.search;
|
||||
window.location.href = `/api/auth/login?redirect=${encodeURIComponent(currentPath)}`;
|
||||
} else {
|
||||
console.log('🔄 ProtectedLayout: Redirection ignorée car authentification en cours...');
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
// Vérifier les rôles requis
|
||||
if (requiredRoles.length > 0) {
|
||||
const hasRequiredRole = requiredRoles.some(role => hasRole(role));
|
||||
if (!hasRequiredRole) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier les permissions requises
|
||||
if (requiredPermissions.length > 0) {
|
||||
const hasRequiredPermission = requiredPermissions.some(permission => hasPermission(permission));
|
||||
if (!hasRequiredPermission) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, user, requiredRoles, requiredPermissions, hasRole, hasPermission, router]);
|
||||
|
||||
// Affichage pendant le chargement
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<LoadingSpinner size="large" />
|
||||
<p className="mt-4 text-gray-600">Chargement...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Si pas authentifié, vérifier s'il y a un code d'autorisation en cours
|
||||
if (!isAuthenticated) {
|
||||
// Si on a un code d'autorisation, laisser le composant se charger pour traiter l'authentification
|
||||
if (typeof window !== 'undefined') {
|
||||
const currentUrl = window.location.href;
|
||||
const hasAuthCode = currentUrl.includes('code=') && currentUrl.includes('/dashboard');
|
||||
|
||||
if (hasAuthCode) {
|
||||
console.log('🔓 ProtectedLayout: Autorisant le rendu car code d\'autorisation présent');
|
||||
// Laisser le composant se charger pour traiter l'authentification
|
||||
} else {
|
||||
// Pas de code d'autorisation, afficher le message de redirection
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<LoadingSpinner size="large" />
|
||||
<p className="mt-4 text-gray-600">Redirection vers la connexion...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Si authentifié mais pas les bonnes permissions, ne rien afficher (redirection en cours)
|
||||
if (user) {
|
||||
if (requiredRoles.length > 0) {
|
||||
const hasRequiredRole = requiredRoles.some(role => hasRole(role));
|
||||
if (!hasRequiredRole) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<LoadingSpinner size="large" />
|
||||
<p className="mt-4 text-gray-600">Vérification des permissions...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (requiredPermissions.length > 0) {
|
||||
const hasRequiredPermission = requiredPermissions.some(permission => hasPermission(permission));
|
||||
if (!hasRequiredPermission) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<LoadingSpinner size="large" />
|
||||
<p className="mt-4 text-gray-600">Vérification des permissions...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Afficher le contenu si tout est OK
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default ProtectedLayout;
|
||||
76
components/RoleProtectedPage.tsx
Normal file
76
components/RoleProtectedPage.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Button } from 'primereact/button';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useUserRoles, PAGE_ROLES, PageRole } from '@/hooks/useUserRoles';
|
||||
|
||||
interface RoleProtectedPageProps {
|
||||
children: React.ReactNode;
|
||||
requiredPage: PageRole;
|
||||
fallbackMessage?: string;
|
||||
}
|
||||
|
||||
const RoleProtectedPage: React.FC<RoleProtectedPageProps> = ({
|
||||
children,
|
||||
requiredPage,
|
||||
fallbackMessage
|
||||
}) => {
|
||||
const { canAccess, isLoading, roles } = useUserRoles();
|
||||
const router = useRouter();
|
||||
|
||||
const requiredRoles = PAGE_ROLES[requiredPage];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-content-center align-items-center" style={{ minHeight: '400px' }}>
|
||||
<div className="text-center">
|
||||
<i className="pi pi-spin pi-spinner text-4xl text-primary mb-3"></i>
|
||||
<p className="text-lg text-600">Vérification des autorisations...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canAccess(requiredRoles)) {
|
||||
return (
|
||||
<div className="flex justify-content-center align-items-center" style={{ minHeight: '400px' }}>
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="text-center">
|
||||
<i className="pi pi-lock text-6xl text-orange-500 mb-4"></i>
|
||||
<h2 className="text-2xl font-bold text-900 mb-3">Accès restreint</h2>
|
||||
<p className="text-600 mb-4 line-height-3">
|
||||
{fallbackMessage ||
|
||||
`Vous n'avez pas les autorisations nécessaires pour accéder à cette page.
|
||||
Les rôles requis sont : ${requiredRoles.join(', ')}.`}
|
||||
</p>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-500">
|
||||
<strong>Vos rôles actuels :</strong> {roles.length > 0 ? roles.join(', ') : 'Aucun rôle assigné'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-content-center">
|
||||
<Button
|
||||
label="Retour au dashboard"
|
||||
icon="pi pi-home"
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="p-button-outlined"
|
||||
/>
|
||||
<Button
|
||||
label="Contacter l'admin"
|
||||
icon="pi pi-envelope"
|
||||
onClick={() => router.push('/contact')}
|
||||
severity="secondary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default RoleProtectedPage;
|
||||
80
components/auth/DevAuthProvider.tsx
Normal file
80
components/auth/DevAuthProvider.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
interface DevAuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
user: any;
|
||||
login: () => void;
|
||||
logout: () => void;
|
||||
hasRole: (role: string) => boolean;
|
||||
}
|
||||
|
||||
const DevAuthContext = createContext<DevAuthContextType | null>(null);
|
||||
|
||||
export const useDevAuth = () => {
|
||||
const context = useContext(DevAuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useDevAuth must be used within DevAuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface DevAuthProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DevAuthProvider: React.FC<DevAuthProviderProps> = ({ children }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [user, setUser] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
// En mode développement, simuler un utilisateur connecté
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
setIsAuthenticated(true);
|
||||
setUser({
|
||||
id: 'dev-user-1',
|
||||
username: 'admin',
|
||||
email: 'admin@btpxpress.dev',
|
||||
firstName: 'Admin',
|
||||
lastName: 'BTPXpress',
|
||||
roles: ['admin', 'manager', 'user'],
|
||||
permissions: ['*']
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = () => {
|
||||
setIsAuthenticated(true);
|
||||
setUser({
|
||||
id: 'dev-user-1',
|
||||
username: 'admin',
|
||||
email: 'admin@btpxpress.dev',
|
||||
firstName: 'Admin',
|
||||
lastName: 'BTPXpress',
|
||||
roles: ['admin', 'manager', 'user'],
|
||||
permissions: ['*']
|
||||
});
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const hasRole = (role: string) => {
|
||||
return user?.roles?.includes(role) || user?.roles?.includes('admin') || false;
|
||||
};
|
||||
|
||||
return (
|
||||
<DevAuthContext.Provider value={{
|
||||
isAuthenticated,
|
||||
user,
|
||||
login,
|
||||
logout,
|
||||
hasRole
|
||||
}}>
|
||||
{children}
|
||||
</DevAuthContext.Provider>
|
||||
);
|
||||
};
|
||||
237
components/auth/ProtectedRoute.tsx
Normal file
237
components/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
'use client';
|
||||
|
||||
import React, { ReactNode, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { KEYCLOAK_REDIRECTS } from '@/config/keycloak';
|
||||
import LoadingSpinner from '@/components/ui/LoadingSpinner';
|
||||
|
||||
// Props pour le composant de route protégée
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode;
|
||||
requiredRoles?: string[];
|
||||
requiredPermissions?: string[];
|
||||
requireAnyRole?: boolean; // Si true, l'utilisateur doit avoir au moins un des rôles requis
|
||||
requireAllRoles?: boolean; // Si true, l'utilisateur doit avoir tous les rôles requis
|
||||
requireAnyPermission?: boolean; // Si true, l'utilisateur doit avoir au moins une des permissions requises
|
||||
requireAllPermissions?: boolean; // Si true, l'utilisateur doit avoir toutes les permissions requises
|
||||
fallbackComponent?: ReactNode;
|
||||
redirectTo?: string;
|
||||
showUnauthorized?: boolean;
|
||||
}
|
||||
|
||||
// Composant de route protégée
|
||||
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
children,
|
||||
requiredRoles = [],
|
||||
requiredPermissions = [],
|
||||
requireAnyRole = true,
|
||||
requireAllRoles = false,
|
||||
requireAnyPermission = true,
|
||||
requireAllPermissions = false,
|
||||
fallbackComponent,
|
||||
redirectTo,
|
||||
showUnauthorized = true,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
user,
|
||||
hasRole,
|
||||
hasAnyRole,
|
||||
hasPermission,
|
||||
login
|
||||
} = useAuth();
|
||||
|
||||
// Vérification de l'authentification et des autorisations
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
// Si l'utilisateur n'est pas authentifié
|
||||
if (!isAuthenticated) {
|
||||
if (redirectTo) {
|
||||
router.push(redirectTo);
|
||||
} else {
|
||||
// Rediriger vers la page de connexion
|
||||
login();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Si l'utilisateur est authentifié mais n'a pas les rôles requis
|
||||
if (requiredRoles.length > 0 && user) {
|
||||
let hasRequiredRoles = false;
|
||||
|
||||
if (requireAllRoles) {
|
||||
// L'utilisateur doit avoir tous les rôles requis
|
||||
hasRequiredRoles = requiredRoles.every(role => hasRole(role));
|
||||
} else if (requireAnyRole) {
|
||||
// L'utilisateur doit avoir au moins un des rôles requis
|
||||
hasRequiredRoles = hasAnyRole(requiredRoles);
|
||||
}
|
||||
|
||||
if (!hasRequiredRoles) {
|
||||
if (redirectTo) {
|
||||
router.push(redirectTo);
|
||||
} else {
|
||||
router.push(KEYCLOAK_REDIRECTS.FORBIDDEN);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Si l'utilisateur est authentifié mais n'a pas les permissions requises
|
||||
if (requiredPermissions.length > 0 && user) {
|
||||
let hasRequiredPermissions = false;
|
||||
|
||||
if (requireAllPermissions) {
|
||||
// L'utilisateur doit avoir toutes les permissions requises
|
||||
hasRequiredPermissions = requiredPermissions.every(permission => hasPermission(permission));
|
||||
} else if (requireAnyPermission) {
|
||||
// L'utilisateur doit avoir au moins une des permissions requises
|
||||
hasRequiredPermissions = requiredPermissions.some(permission => hasPermission(permission));
|
||||
}
|
||||
|
||||
if (!hasRequiredPermissions) {
|
||||
if (redirectTo) {
|
||||
router.push(redirectTo);
|
||||
} else {
|
||||
router.push(KEYCLOAK_REDIRECTS.FORBIDDEN);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
user,
|
||||
requiredRoles,
|
||||
requiredPermissions,
|
||||
requireAnyRole,
|
||||
requireAllRoles,
|
||||
requireAnyPermission,
|
||||
requireAllPermissions,
|
||||
redirectTo,
|
||||
router,
|
||||
hasRole,
|
||||
hasAnyRole,
|
||||
hasPermission,
|
||||
login,
|
||||
]);
|
||||
|
||||
// Affichage pendant le chargement
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Si l'utilisateur n'est pas authentifié
|
||||
if (!isAuthenticated) {
|
||||
if (fallbackComponent) {
|
||||
return <>{fallbackComponent}</>;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Authentification requise
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Vous devez être connecté pour accéder à cette page.
|
||||
</p>
|
||||
<button
|
||||
onClick={login}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Se connecter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Vérification des rôles
|
||||
if (requiredRoles.length > 0 && user) {
|
||||
let hasRequiredRoles = false;
|
||||
|
||||
if (requireAllRoles) {
|
||||
hasRequiredRoles = requiredRoles.every(role => hasRole(role));
|
||||
} else if (requireAnyRole) {
|
||||
hasRequiredRoles = hasAnyRole(requiredRoles);
|
||||
}
|
||||
|
||||
if (!hasRequiredRoles) {
|
||||
if (fallbackComponent) {
|
||||
return <>{fallbackComponent}</>;
|
||||
}
|
||||
if (showUnauthorized) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-red-600 mb-4">
|
||||
Accès non autorisé
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Vous n'avez pas les permissions nécessaires pour accéder à cette page.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Rôles requis: {requiredRoles.join(', ')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Vos rôles: {user.roles.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Vérification des permissions
|
||||
if (requiredPermissions.length > 0 && user) {
|
||||
let hasRequiredPermissions = false;
|
||||
|
||||
if (requireAllPermissions) {
|
||||
hasRequiredPermissions = requiredPermissions.every(permission => hasPermission(permission));
|
||||
} else if (requireAnyPermission) {
|
||||
hasRequiredPermissions = requiredPermissions.some(permission => hasPermission(permission));
|
||||
}
|
||||
|
||||
if (!hasRequiredPermissions) {
|
||||
if (fallbackComponent) {
|
||||
return <>{fallbackComponent}</>;
|
||||
}
|
||||
if (showUnauthorized) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-red-600 mb-4">
|
||||
Permissions insuffisantes
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Vous n'avez pas les permissions nécessaires pour accéder à cette page.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Permissions requises: {requiredPermissions.join(', ')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Vos permissions: {user.permissions.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Si toutes les vérifications passent, afficher le contenu
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
107
components/chantiers/ActionButton.tsx
Normal file
107
components/chantiers/ActionButton.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Bouton d'action distinctif avec couleurs et animations personnalisées
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tooltip } from 'primereact/tooltip';
|
||||
import {
|
||||
ACTION_BUTTON_THEMES,
|
||||
BUTTON_SIZES,
|
||||
BUTTON_VARIANTS,
|
||||
ActionButtonType
|
||||
} from './ActionButtonStyles';
|
||||
import ChantierMenuActions from './ChantierMenuActions';
|
||||
import { ChantierActif } from '../../hooks/useDashboard';
|
||||
|
||||
interface ActionButtonProps {
|
||||
type: ActionButtonType;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
showLabel?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
tooltipPosition?: 'top' | 'bottom' | 'left' | 'right';
|
||||
// Propriétés pour le menu
|
||||
chantier?: ChantierActif;
|
||||
onMenuAction?: (action: string, chantier: ChantierActif) => void;
|
||||
}
|
||||
|
||||
const ActionButton: React.FC<ActionButtonProps> = ({
|
||||
type,
|
||||
onClick,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
size = 'md',
|
||||
showLabel = false,
|
||||
className = '',
|
||||
style = {},
|
||||
tooltipPosition = 'top',
|
||||
chantier,
|
||||
onMenuAction
|
||||
}) => {
|
||||
const theme = ACTION_BUTTON_THEMES[type];
|
||||
|
||||
// ID unique pour le tooltip
|
||||
const buttonId = `action-btn-${type.toLowerCase()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Style minimal Atlantis Rounded Text - pas de styles custom
|
||||
const getButtonStyles = () => {
|
||||
return {
|
||||
...style // Seulement les styles passés en props
|
||||
};
|
||||
};
|
||||
|
||||
// Classes CSS strictes Atlantis Rounded Text
|
||||
const buttonClasses = [
|
||||
'p-button-text p-button-rounded p-button-sm', // Strict Atlantis + taille compacte
|
||||
disabled ? 'p-disabled' : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
// Rendu minimal Atlantis
|
||||
const buttonContent = (
|
||||
<>
|
||||
<i className={theme.icon} style={{ color: theme.colors.primary }} />
|
||||
{showLabel && <span className="ml-1">{theme.name}</span>}
|
||||
</>
|
||||
);
|
||||
|
||||
// Si c'est le bouton MENU et qu'on a un chantier, utiliser le composant menu
|
||||
if (type === 'MENU' && chantier && onMenuAction) {
|
||||
return (
|
||||
<ChantierMenuActions
|
||||
chantier={chantier}
|
||||
onAction={onMenuAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
id={buttonId}
|
||||
className={buttonClasses}
|
||||
style={getButtonStyles()}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
aria-label={theme.name}
|
||||
>
|
||||
{buttonContent}
|
||||
</Button>
|
||||
|
||||
<Tooltip
|
||||
target={`#${buttonId}`}
|
||||
content={theme.name}
|
||||
position={tooltipPosition}
|
||||
showDelay={300}
|
||||
hideDelay={100}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionButton;
|
||||
73
components/chantiers/ActionButtonGroup.tsx
Normal file
73
components/chantiers/ActionButtonGroup.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Groupe de boutons d'action avec animations et espacement optimisé
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ActionButton from './ActionButton';
|
||||
import { ActionButtonType } from './ActionButtonStyles';
|
||||
import { ChantierActif } from '../../hooks/useDashboard';
|
||||
|
||||
interface ActionButtonGroupProps {
|
||||
chantier: ChantierActif;
|
||||
actions: ActionButtonType[];
|
||||
onAction: (action: ActionButtonType, chantier: ChantierActif) => void;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
spacing?: 'none' | 'sm' | 'md' | 'lg';
|
||||
showLabels?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ActionButtonGroup: React.FC<ActionButtonGroupProps> = ({
|
||||
chantier,
|
||||
actions,
|
||||
onAction,
|
||||
size = 'md',
|
||||
orientation = 'horizontal',
|
||||
spacing = 'sm',
|
||||
showLabels = false,
|
||||
className = ''
|
||||
}) => {
|
||||
// Classes pour l'espacement - plus compact
|
||||
const spacingClasses = {
|
||||
none: 'gap-0',
|
||||
sm: 'gap-1',
|
||||
md: 'gap-1',
|
||||
lg: 'gap-2'
|
||||
};
|
||||
|
||||
// Classes pour l'orientation
|
||||
const orientationClasses = {
|
||||
horizontal: 'flex-row',
|
||||
vertical: 'flex-column'
|
||||
};
|
||||
|
||||
const containerClasses = [
|
||||
'flex',
|
||||
'align-items-center',
|
||||
orientationClasses[orientation],
|
||||
spacingClasses[spacing],
|
||||
'action-button-group',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{actions.map((actionType, index) => (
|
||||
<ActionButton
|
||||
key={`${actionType}-${chantier.id}-${index}`}
|
||||
type={actionType}
|
||||
size={size}
|
||||
showLabel={showLabels}
|
||||
onClick={() => onAction(actionType, chantier)}
|
||||
className="action-button-group-item"
|
||||
// Props pour le menu
|
||||
chantier={chantier}
|
||||
onMenuAction={actionType === 'MENU' ? onAction : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionButtonGroup;
|
||||
115
components/chantiers/ActionButtonStyles.ts
Normal file
115
components/chantiers/ActionButtonStyles.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Styles distinctifs pour chaque bouton d'action
|
||||
* Couleurs spécifiques et animations pour une UX premium
|
||||
*/
|
||||
|
||||
export const ACTION_BUTTON_THEMES = {
|
||||
VIEW: {
|
||||
name: 'Vue Rapide',
|
||||
icon: 'pi pi-eye',
|
||||
colors: {
|
||||
primary: '#3B82F6', // Bleu moderne
|
||||
light: '#DBEAFE', // Bleu très clair
|
||||
dark: '#1D4ED8', // Bleu foncé
|
||||
text: '#1E40AF' // Texte bleu
|
||||
},
|
||||
className: ''
|
||||
},
|
||||
PHASES: {
|
||||
name: 'Phases',
|
||||
icon: 'pi pi-sitemap',
|
||||
colors: {
|
||||
primary: '#10B981', // Vert émeraude
|
||||
light: '#D1FAE5', // Vert très clair
|
||||
dark: '#059669', // Vert foncé
|
||||
text: '#047857' // Texte vert
|
||||
},
|
||||
className: ''
|
||||
},
|
||||
PLANNING: {
|
||||
name: 'Planning',
|
||||
icon: 'pi pi-calendar',
|
||||
colors: {
|
||||
primary: '#8B5CF6', // Violet moderne
|
||||
light: '#EDE9FE', // Violet très clair
|
||||
dark: '#7C3AED', // Violet foncé
|
||||
text: '#6D28D9' // Texte violet
|
||||
},
|
||||
className: ''
|
||||
},
|
||||
STATS: {
|
||||
name: 'Statistiques',
|
||||
icon: 'pi pi-chart-bar',
|
||||
colors: {
|
||||
primary: '#06B6D4', // Cyan moderne
|
||||
light: '#CFFAFE', // Cyan très clair
|
||||
dark: '#0891B2', // Cyan foncé
|
||||
text: '#0E7490' // Texte cyan
|
||||
},
|
||||
className: ''
|
||||
},
|
||||
MENU: {
|
||||
name: 'Plus d\'actions',
|
||||
icon: 'pi pi-ellipsis-v',
|
||||
colors: {
|
||||
primary: '#6B7280', // Gris moderne
|
||||
light: '#F3F4F6', // Gris très clair
|
||||
dark: '#4B5563', // Gris foncé
|
||||
text: '#374151' // Texte gris
|
||||
},
|
||||
className: ''
|
||||
},
|
||||
EDIT: {
|
||||
name: 'Modifier',
|
||||
icon: 'pi pi-pencil',
|
||||
colors: {
|
||||
primary: '#F59E0B', // Orange/Ambre
|
||||
light: '#FEF3C7', // Orange très clair
|
||||
dark: '#D97706', // Orange foncé
|
||||
text: '#B45309' // Texte orange
|
||||
},
|
||||
className: ''
|
||||
},
|
||||
DELETE: {
|
||||
name: 'Supprimer',
|
||||
icon: 'pi pi-trash',
|
||||
colors: {
|
||||
primary: '#EF4444', // Rouge moderne
|
||||
light: '#FEE2E2', // Rouge très clair
|
||||
dark: '#DC2626', // Rouge foncé
|
||||
text: '#B91C1C' // Texte rouge
|
||||
},
|
||||
className: ''
|
||||
}
|
||||
} as const;
|
||||
|
||||
export type ActionButtonType = keyof typeof ACTION_BUTTON_THEMES;
|
||||
|
||||
// Tailles compactes pour DataTable optimisée
|
||||
export const BUTTON_SIZES = {
|
||||
xs: {
|
||||
padding: 'p-1',
|
||||
iconSize: 'text-xs',
|
||||
width: 'w-5 h-5' // Extra compact
|
||||
},
|
||||
sm: {
|
||||
padding: 'p-1',
|
||||
iconSize: 'text-sm',
|
||||
width: 'w-6 h-6' // Compact
|
||||
},
|
||||
md: {
|
||||
padding: 'p-2',
|
||||
iconSize: 'text-base',
|
||||
width: 'w-8 h-8'
|
||||
},
|
||||
lg: {
|
||||
padding: 'p-2',
|
||||
iconSize: 'text-lg',
|
||||
width: 'w-10 h-10'
|
||||
}
|
||||
} as const;
|
||||
|
||||
// Style Rounded Text uniquement - autres variantes supprimées
|
||||
export const BUTTON_VARIANTS = {
|
||||
roundedText: 'p-button-text p-button-rounded'
|
||||
} as const;
|
||||
215
components/chantiers/ChantierActions.tsx
Normal file
215
components/chantiers/ChantierActions.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Composant réutilisable pour les actions sur les chantiers
|
||||
*/
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Menu } from 'primereact/menu';
|
||||
import { MenuItem } from 'primereact/menuitem';
|
||||
import { ACTION_BUTTONS, COMMON_CLASSES, ActionButtonType } from './ChantierStyles';
|
||||
import { ChantierActif } from '../../hooks/useDashboard';
|
||||
|
||||
interface ChantierActionsProps {
|
||||
chantier: ChantierActif;
|
||||
onQuickView?: (chantier: ChantierActif) => void;
|
||||
onManagePhases?: (chantier: ChantierActif) => void;
|
||||
onViewPlanning?: (chantier: ChantierActif) => void;
|
||||
onViewStats?: (chantier: ChantierActif) => void;
|
||||
onMenuAction?: (action: string, chantier: ChantierActif) => void;
|
||||
showLabels?: boolean;
|
||||
size?: 'small' | 'normal' | 'large';
|
||||
layout?: 'horizontal' | 'vertical';
|
||||
buttons?: ActionButtonType[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ChantierActions: React.FC<ChantierActionsProps> = ({
|
||||
chantier,
|
||||
onQuickView,
|
||||
onManagePhases,
|
||||
onViewPlanning,
|
||||
onViewStats,
|
||||
onMenuAction,
|
||||
showLabels = false,
|
||||
size = 'normal',
|
||||
layout = 'horizontal',
|
||||
buttons = ['VIEW', 'PHASES', 'PLANNING', 'STATS', 'MENU'],
|
||||
className = ''
|
||||
}) => {
|
||||
const menuRef = useRef<Menu>(null);
|
||||
|
||||
const sizeClasses = {
|
||||
small: 'p-button-sm',
|
||||
normal: '',
|
||||
large: 'p-button-lg'
|
||||
};
|
||||
|
||||
const layoutClasses = {
|
||||
horizontal: COMMON_CLASSES.BUTTON_GROUP,
|
||||
vertical: 'flex flex-column gap-2'
|
||||
};
|
||||
|
||||
// Configuration du menu contextuel
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
label: 'Navigation',
|
||||
items: [
|
||||
{
|
||||
label: 'Détails complets',
|
||||
icon: 'pi pi-external-link',
|
||||
command: () => onMenuAction?.('details', chantier)
|
||||
},
|
||||
{
|
||||
label: 'Documents',
|
||||
icon: 'pi pi-file',
|
||||
command: () => onMenuAction?.('documents', chantier)
|
||||
},
|
||||
{
|
||||
label: 'Photos',
|
||||
icon: 'pi pi-images',
|
||||
command: () => onMenuAction?.('photos', chantier)
|
||||
}
|
||||
]
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: 'Ressources',
|
||||
items: [
|
||||
{
|
||||
label: 'Équipe assignée',
|
||||
icon: 'pi pi-users',
|
||||
command: () => onMenuAction?.('team', chantier)
|
||||
},
|
||||
{
|
||||
label: 'Matériel utilisé',
|
||||
icon: 'pi pi-cog',
|
||||
command: () => onMenuAction?.('equipment', chantier)
|
||||
}
|
||||
]
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: 'Rapports',
|
||||
items: [
|
||||
{
|
||||
label: 'Générer rapport',
|
||||
icon: 'pi pi-chart-bar',
|
||||
command: () => onMenuAction?.('report', chantier)
|
||||
},
|
||||
{
|
||||
label: 'Export PDF',
|
||||
icon: 'pi pi-file-pdf',
|
||||
command: () => onMenuAction?.('export-pdf', chantier)
|
||||
},
|
||||
{
|
||||
label: 'Export Excel',
|
||||
icon: 'pi pi-file-excel',
|
||||
command: () => onMenuAction?.('export-excel', chantier)
|
||||
}
|
||||
]
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: 'Actions',
|
||||
items: [
|
||||
{
|
||||
label: chantier.statut === 'SUSPENDU' ? 'Reprendre' : 'Suspendre',
|
||||
icon: chantier.statut === 'SUSPENDU' ? 'pi pi-play' : 'pi pi-pause',
|
||||
command: () => onMenuAction?.('toggle-suspend', chantier),
|
||||
className: 'text-orange-500'
|
||||
},
|
||||
{
|
||||
label: 'Clôturer',
|
||||
icon: 'pi pi-check-circle',
|
||||
command: () => onMenuAction?.('close', chantier),
|
||||
disabled: chantier.statut === 'TERMINE',
|
||||
className: 'text-green-500'
|
||||
},
|
||||
{
|
||||
label: 'Archiver',
|
||||
icon: 'pi pi-inbox',
|
||||
command: () => onMenuAction?.('archive', chantier),
|
||||
className: 'text-gray-500'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const handleMenuClick = (e: React.MouseEvent) => {
|
||||
menuRef.current?.toggle(e);
|
||||
};
|
||||
|
||||
const renderButton = (buttonType: ActionButtonType) => {
|
||||
const config = ACTION_BUTTONS[buttonType];
|
||||
const baseClassName = `${config.className} ${sizeClasses[size]}`;
|
||||
|
||||
const buttonProps = {
|
||||
icon: config.icon,
|
||||
tooltip: config.tooltip,
|
||||
tooltipOptions: { position: 'top' as const, showDelay: 500 },
|
||||
className: baseClassName,
|
||||
'aria-label': config.tooltip
|
||||
};
|
||||
|
||||
switch (buttonType) {
|
||||
case 'VIEW':
|
||||
return (
|
||||
<Button
|
||||
key="view"
|
||||
{...buttonProps}
|
||||
onClick={() => onQuickView?.(chantier)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'PHASES':
|
||||
return (
|
||||
<Button
|
||||
key="phases"
|
||||
{...buttonProps}
|
||||
onClick={() => onManagePhases?.(chantier)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'PLANNING':
|
||||
return (
|
||||
<Button
|
||||
key="planning"
|
||||
{...buttonProps}
|
||||
onClick={() => onViewPlanning?.(chantier)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'STATS':
|
||||
return (
|
||||
<Button
|
||||
key="stats"
|
||||
{...buttonProps}
|
||||
onClick={() => onViewStats?.(chantier)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'MENU':
|
||||
return (
|
||||
<Button
|
||||
key="menu"
|
||||
{...buttonProps}
|
||||
onClick={handleMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`${layoutClasses[layout]} ${className}`}>
|
||||
{buttons.map(buttonType => renderButton(buttonType))}
|
||||
</div>
|
||||
<Menu ref={menuRef} model={menuItems} popup />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChantierActions;
|
||||
67
components/chantiers/ChantierActionsSimple.tsx
Normal file
67
components/chantiers/ChantierActionsSimple.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Version simplifiée du composant ChantierActions pour debug
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
import { ChantierActif } from '../../hooks/useDashboard';
|
||||
|
||||
interface ChantierActionsSimpleProps {
|
||||
chantier: ChantierActif;
|
||||
onQuickView?: (chantier: ChantierActif) => void;
|
||||
onManagePhases?: (chantier: ChantierActif) => void;
|
||||
onViewPlanning?: (chantier: ChantierActif) => void;
|
||||
onViewStats?: (chantier: ChantierActif) => void;
|
||||
onMenuAction?: (action: string, chantier: ChantierActif) => void;
|
||||
}
|
||||
|
||||
const ChantierActionsSimple: React.FC<ChantierActionsSimpleProps> = ({
|
||||
chantier,
|
||||
onQuickView,
|
||||
onManagePhases,
|
||||
onViewPlanning,
|
||||
onViewStats,
|
||||
onMenuAction
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
icon="pi pi-eye"
|
||||
className="p-button-rounded p-button-text"
|
||||
tooltip="Vue rapide"
|
||||
onClick={() => onQuickView?.(chantier)}
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-sitemap"
|
||||
className="p-button-rounded p-button-text"
|
||||
tooltip="Gérer les phases"
|
||||
onClick={() => onManagePhases?.(chantier)}
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-calendar"
|
||||
className="p-button-rounded p-button-text"
|
||||
tooltip="Planning"
|
||||
onClick={() => onViewPlanning?.(chantier)}
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-chart-bar"
|
||||
className="p-button-rounded p-button-text"
|
||||
tooltip="Statistiques"
|
||||
onClick={() => onViewStats?.(chantier)}
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-ellipsis-v"
|
||||
className="p-button-rounded p-button-text"
|
||||
tooltip="Plus d'actions"
|
||||
onClick={() => onMenuAction?.('details', chantier)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChantierActionsSimple;
|
||||
100
components/chantiers/ChantierMenuActions.tsx
Normal file
100
components/chantiers/ChantierMenuActions.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Menu d'actions pour les chantiers - Actions prioritaires BTP
|
||||
*/
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Menu } from 'primereact/menu';
|
||||
import { MenuItem } from 'primereact/menuitem';
|
||||
import { ChantierActif } from '../../hooks/useDashboard';
|
||||
|
||||
interface ChantierMenuActionsProps {
|
||||
chantier: ChantierActif;
|
||||
onAction: (action: string, chantier: ChantierActif) => void;
|
||||
}
|
||||
|
||||
const ChantierMenuActions: React.FC<ChantierMenuActionsProps> = ({
|
||||
chantier,
|
||||
onAction
|
||||
}) => {
|
||||
const menuRef = useRef<Menu>(null);
|
||||
|
||||
// Actions prioritaires pour le workflow BTP
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
label: 'Gestion chantier',
|
||||
items: [
|
||||
{
|
||||
label: 'Suspendre le chantier',
|
||||
icon: 'pi pi-pause',
|
||||
command: () => onAction('suspend', chantier)
|
||||
},
|
||||
{
|
||||
label: 'Clôturer le chantier',
|
||||
icon: 'pi pi-check-circle',
|
||||
command: () => onAction('close', chantier)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: 'Communication',
|
||||
items: [
|
||||
{
|
||||
label: 'Notification client',
|
||||
icon: 'pi pi-send',
|
||||
command: () => onAction('notify-client', chantier)
|
||||
},
|
||||
{
|
||||
label: 'Rapport de synthèse',
|
||||
icon: 'pi pi-file-pdf',
|
||||
command: () => onAction('generate-report', chantier)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: 'Financier',
|
||||
items: [
|
||||
{
|
||||
label: 'Facture intermédiaire',
|
||||
icon: 'pi pi-euro',
|
||||
command: () => onAction('generate-invoice', chantier)
|
||||
},
|
||||
{
|
||||
label: 'Créer un avenant',
|
||||
icon: 'pi pi-file-plus',
|
||||
command: () => onAction('create-amendment', chantier)
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const toggleMenu = (event: React.MouseEvent) => {
|
||||
menuRef.current?.toggle(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
icon="pi pi-ellipsis-v"
|
||||
className="p-button-text p-button-rounded p-button-sm"
|
||||
onClick={toggleMenu}
|
||||
aria-label="Plus d'actions"
|
||||
style={{ color: '#6B7280' }}
|
||||
/>
|
||||
<Menu
|
||||
ref={menuRef}
|
||||
model={menuItems}
|
||||
popup
|
||||
style={{ minWidth: '250px' }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChantierMenuActions;
|
||||
70
components/chantiers/ChantierProgressBar.tsx
Normal file
70
components/chantiers/ChantierProgressBar.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Composant réutilisable pour afficher l'avancement d'un chantier
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { AVANCEMENT_CONFIG, COMMON_CLASSES } from './ChantierStyles';
|
||||
|
||||
interface ChantierProgressBarProps {
|
||||
value: number;
|
||||
showValue?: boolean;
|
||||
showPercentage?: boolean;
|
||||
size?: 'small' | 'normal' | 'large';
|
||||
showCompletionIcon?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const ChantierProgressBar: React.FC<ChantierProgressBarProps> = ({
|
||||
value,
|
||||
showValue = false,
|
||||
showPercentage = true,
|
||||
size = 'normal',
|
||||
showCompletionIcon = true,
|
||||
className = '',
|
||||
style = {}
|
||||
}) => {
|
||||
// Déterminer la configuration couleur selon l'avancement
|
||||
const getProgressConfig = () => {
|
||||
if (value < AVANCEMENT_CONFIG.CRITIQUE.threshold) return AVANCEMENT_CONFIG.CRITIQUE;
|
||||
if (value < AVANCEMENT_CONFIG.ATTENTION.threshold) return AVANCEMENT_CONFIG.ATTENTION;
|
||||
if (value < AVANCEMENT_CONFIG.PROGRES.threshold) return AVANCEMENT_CONFIG.PROGRES;
|
||||
return AVANCEMENT_CONFIG.TERMINE;
|
||||
};
|
||||
|
||||
const config = getProgressConfig();
|
||||
|
||||
const sizeConfig = {
|
||||
small: { height: '8px', fontSize: 'text-xs' },
|
||||
normal: { height: '16px', fontSize: 'text-sm' },
|
||||
large: { height: '20px', fontSize: 'text-base' }
|
||||
};
|
||||
|
||||
const currentSize = sizeConfig[size];
|
||||
|
||||
return (
|
||||
<div className={`${COMMON_CLASSES.PROGRESS_CONTAINER} ${className}`} style={style}>
|
||||
<div className="w-full">
|
||||
<ProgressBar
|
||||
value={value}
|
||||
showValue={showValue}
|
||||
style={{ height: currentSize.height }}
|
||||
className={`progress-${size}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showPercentage && (
|
||||
<span className={`ml-2 font-bold ${currentSize.fontSize} ${config.textColor}`}>
|
||||
{value}%
|
||||
</span>
|
||||
)}
|
||||
|
||||
{showCompletionIcon && value === 100 && (
|
||||
<i className="pi pi-check-circle text-green-500 text-xl ml-2" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChantierProgressBar;
|
||||
41
components/chantiers/ChantierStatusBadge.tsx
Normal file
41
components/chantiers/ChantierStatusBadge.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Composant réutilisable pour afficher le statut d'un chantier
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { CHANTIER_STATUTS, ChantierStatut } from './ChantierStyles';
|
||||
|
||||
interface ChantierStatusBadgeProps {
|
||||
statut: string;
|
||||
showIcon?: boolean;
|
||||
size?: 'small' | 'normal' | 'large';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ChantierStatusBadge: React.FC<ChantierStatusBadgeProps> = ({
|
||||
statut,
|
||||
showIcon = true,
|
||||
size = 'normal',
|
||||
className = ''
|
||||
}) => {
|
||||
const statutKey = statut as ChantierStatut;
|
||||
const config = CHANTIER_STATUTS[statutKey] || CHANTIER_STATUTS.PLANIFIE;
|
||||
|
||||
const sizeClasses = {
|
||||
small: 'text-xs px-2 py-1',
|
||||
normal: '',
|
||||
large: 'text-lg px-3 py-2'
|
||||
};
|
||||
|
||||
return (
|
||||
<Tag
|
||||
value={config.label}
|
||||
severity={config.severity}
|
||||
icon={showIcon ? config.icon : undefined}
|
||||
className={`${sizeClasses[size]} ${className}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChantierStatusBadge;
|
||||
111
components/chantiers/ChantierStyles.ts
Normal file
111
components/chantiers/ChantierStyles.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Constantes de style pour les composants Chantier
|
||||
* Facilite la maintenance et la cohérence visuelle
|
||||
*/
|
||||
|
||||
// Configuration des statuts avec couleurs et icônes
|
||||
export const CHANTIER_STATUTS = {
|
||||
EN_COURS: {
|
||||
label: 'En cours',
|
||||
severity: 'success' as const,
|
||||
icon: 'pi pi-play-circle',
|
||||
color: 'text-green-500',
|
||||
bgColor: 'bg-green-100'
|
||||
},
|
||||
PLANIFIE: {
|
||||
label: 'Planifié',
|
||||
severity: 'info' as const,
|
||||
icon: 'pi pi-calendar',
|
||||
color: 'text-blue-500',
|
||||
bgColor: 'bg-blue-100'
|
||||
},
|
||||
EN_RETARD: {
|
||||
label: 'En retard',
|
||||
severity: 'danger' as const,
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
color: 'text-red-500',
|
||||
bgColor: 'bg-red-100'
|
||||
},
|
||||
TERMINE: {
|
||||
label: 'Terminé',
|
||||
severity: 'success' as const,
|
||||
icon: 'pi pi-check-circle',
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-100'
|
||||
},
|
||||
SUSPENDU: {
|
||||
label: 'Suspendu',
|
||||
severity: 'warning' as const,
|
||||
icon: 'pi pi-pause-circle',
|
||||
color: 'text-orange-500',
|
||||
bgColor: 'bg-orange-100'
|
||||
}
|
||||
} as const;
|
||||
|
||||
// Configuration des niveaux d'avancement
|
||||
export const AVANCEMENT_CONFIG = {
|
||||
CRITIQUE: { threshold: 30, color: 'bg-red-500', textColor: 'text-red-600' },
|
||||
ATTENTION: { threshold: 60, color: 'bg-orange-500', textColor: 'text-orange-600' },
|
||||
PROGRES: { threshold: 90, color: 'bg-blue-500', textColor: 'text-blue-600' },
|
||||
TERMINE: { threshold: 100, color: 'bg-green-500', textColor: 'text-green-600' }
|
||||
} as const;
|
||||
|
||||
// Configuration des boutons d'action
|
||||
export const ACTION_BUTTONS = {
|
||||
VIEW: {
|
||||
icon: 'pi pi-eye',
|
||||
tooltip: 'Vue rapide',
|
||||
className: 'p-button-rounded p-button-text p-button-plain',
|
||||
color: 'text-blue-500'
|
||||
},
|
||||
PHASES: {
|
||||
icon: 'pi pi-sitemap',
|
||||
tooltip: 'Gérer les phases',
|
||||
className: 'p-button-rounded p-button-text p-button-plain',
|
||||
color: 'text-green-500'
|
||||
},
|
||||
PLANNING: {
|
||||
icon: 'pi pi-calendar',
|
||||
tooltip: 'Planning',
|
||||
className: 'p-button-rounded p-button-text p-button-plain',
|
||||
color: 'text-purple-500'
|
||||
},
|
||||
STATS: {
|
||||
icon: 'pi pi-chart-bar',
|
||||
tooltip: 'Statistiques',
|
||||
className: 'p-button-rounded p-button-text p-button-plain',
|
||||
color: 'text-cyan-500'
|
||||
},
|
||||
MENU: {
|
||||
icon: 'pi pi-ellipsis-v',
|
||||
tooltip: 'Plus d\'actions',
|
||||
className: 'p-button-rounded p-button-text p-button-plain',
|
||||
color: 'text-gray-600'
|
||||
}
|
||||
} as const;
|
||||
|
||||
// Configuration des indicateurs d'urgence
|
||||
export const URGENCE_INDICATORS = {
|
||||
RETARD: {
|
||||
icon: 'pi pi-exclamation-circle',
|
||||
color: 'text-red-500',
|
||||
tooltip: 'En retard'
|
||||
},
|
||||
BIENTOT_TERMINE: {
|
||||
icon: 'pi pi-flag-fill',
|
||||
color: 'text-green-500',
|
||||
tooltip: 'Bientôt terminé'
|
||||
}
|
||||
} as const;
|
||||
|
||||
// Classes CSS communes
|
||||
export const COMMON_CLASSES = {
|
||||
BUTTON_GROUP: 'flex gap-2',
|
||||
CARD_ICON: 'flex align-items-center justify-content-center border-round',
|
||||
PROGRESS_CONTAINER: 'flex align-items-center',
|
||||
STATUS_TAG: 'inline-flex align-items-center gap-2'
|
||||
} as const;
|
||||
|
||||
// Types pour TypeScript
|
||||
export type ChantierStatut = keyof typeof CHANTIER_STATUTS;
|
||||
export type ActionButtonType = keyof typeof ACTION_BUTTONS;
|
||||
52
components/chantiers/ChantierUrgencyIndicator.tsx
Normal file
52
components/chantiers/ChantierUrgencyIndicator.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Composant pour les indicateurs d'urgence des chantiers
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { URGENCE_INDICATORS } from './ChantierStyles';
|
||||
import { ChantierActif } from '../../hooks/useDashboard';
|
||||
|
||||
interface ChantierUrgencyIndicatorProps {
|
||||
chantier: ChantierActif;
|
||||
size?: 'small' | 'normal' | 'large';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ChantierUrgencyIndicator: React.FC<ChantierUrgencyIndicatorProps> = ({
|
||||
chantier,
|
||||
size = 'normal',
|
||||
className = ''
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
small: 'text-sm',
|
||||
normal: 'text-base',
|
||||
large: 'text-xl'
|
||||
};
|
||||
|
||||
const getUrgencyType = () => {
|
||||
if (chantier.statut === 'EN_RETARD') {
|
||||
return 'RETARD';
|
||||
}
|
||||
if (chantier.avancement >= 90) {
|
||||
return 'BIENTOT_TERMINE';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const urgencyType = getUrgencyType();
|
||||
|
||||
if (!urgencyType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const indicator = URGENCE_INDICATORS[urgencyType];
|
||||
|
||||
return (
|
||||
<i
|
||||
className={`${indicator.icon} ${indicator.color} ${sizeClasses[size]} ${className}`}
|
||||
title={indicator.tooltip}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChantierUrgencyIndicator;
|
||||
13
components/chantiers/index.ts
Normal file
13
components/chantiers/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Export des composants Chantier réutilisables
|
||||
*/
|
||||
|
||||
export { default as ChantierActions } from './ChantierActions';
|
||||
export { default as ChantierStatusBadge } from './ChantierStatusBadge';
|
||||
export { default as ChantierProgressBar } from './ChantierProgressBar';
|
||||
export { default as ChantierUrgencyIndicator } from './ChantierUrgencyIndicator';
|
||||
|
||||
export * from './ChantierStyles';
|
||||
|
||||
// Types communs
|
||||
export type { ChantierStatut, ActionButtonType } from './ChantierStyles';
|
||||
137
components/dashboard/AlertsWidget.tsx
Normal file
137
components/dashboard/AlertsWidget.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Composant widget d'alertes (factures en retard, devis expirant)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Message } from 'primereact/message';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Skeleton } from 'primereact/skeleton';
|
||||
import { FactureEnRetard, DevisEnAttente } from '../../types/btp';
|
||||
|
||||
interface AlertsWidgetProps {
|
||||
facturesEnRetard: FactureEnRetard[];
|
||||
devisEnAttente: DevisEnAttente[];
|
||||
loading?: boolean;
|
||||
onViewFacture?: (id: string) => void;
|
||||
onViewDevis?: (id: string) => void;
|
||||
}
|
||||
|
||||
const AlertsWidget: React.FC<AlertsWidgetProps> = ({
|
||||
facturesEnRetard,
|
||||
devisEnAttente,
|
||||
loading = false,
|
||||
onViewFacture,
|
||||
onViewDevis
|
||||
}) => {
|
||||
const getAlertSeverity = (jours: number) => {
|
||||
if (jours <= 3) return 'warn';
|
||||
if (jours <= 7) return 'info';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
const header = (
|
||||
<div className="flex align-items-center">
|
||||
<i className="pi pi-exclamation-triangle text-orange-500 mr-2" />
|
||||
<h5 className="m-0">Alertes</h5>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex align-items-center mb-3">
|
||||
<Skeleton width="1.5rem" height="1.5rem" className="mr-2" />
|
||||
<Skeleton width="4rem" height="1.5rem" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="p-3 border-1 border-200 border-round">
|
||||
<Skeleton width="100%" height="1rem" className="mb-2" />
|
||||
<Skeleton width="60%" height="0.8rem" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const hasAlerts = facturesEnRetard.length > 0 || devisEnAttente.length > 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex align-items-center mb-3">
|
||||
<i className="pi pi-exclamation-triangle text-orange-500 mr-2" />
|
||||
<h5 className="m-0">Alertes</h5>
|
||||
</div>
|
||||
|
||||
{!hasAlerts ? (
|
||||
<Message
|
||||
severity="success"
|
||||
text="Aucune alerte pour le moment"
|
||||
className="w-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Factures en retard */}
|
||||
{facturesEnRetard.map((facture) => (
|
||||
<div
|
||||
key={facture.id}
|
||||
className="flex align-items-center justify-content-between p-3 border-1 border-red-200 border-round bg-red-50"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-900 mb-1">
|
||||
Facture {facture.numero} en retard
|
||||
</div>
|
||||
<div className="text-500 text-sm">
|
||||
{facture.client} • {facture.montantTTC.toLocaleString()} € •
|
||||
{facture.joursRetard} jour{facture.joursRetard > 1 ? 's' : ''} de retard
|
||||
</div>
|
||||
</div>
|
||||
{onViewFacture && (
|
||||
<Button
|
||||
icon="pi pi-eye"
|
||||
className="p-button-text p-button-sm"
|
||||
onClick={() => onViewFacture(facture.id)}
|
||||
tooltip="Voir la facture"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Devis expirant bientôt */}
|
||||
{devisEnAttente.map((devis) => (
|
||||
<div
|
||||
key={devis.id}
|
||||
className={`flex align-items-center justify-content-between p-3 border-1 border-round ${
|
||||
devis.joursRestants <= 3
|
||||
? 'border-red-200 bg-red-50'
|
||||
: 'border-orange-200 bg-orange-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-900 mb-1">
|
||||
Devis {devis.numero} expire bientôt
|
||||
</div>
|
||||
<div className="text-500 text-sm">
|
||||
{devis.client} • {devis.montantTTC.toLocaleString()} € •
|
||||
{devis.joursRestants} jour{devis.joursRestants > 1 ? 's' : ''} restant{devis.joursRestants > 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
{onViewDevis && (
|
||||
<Button
|
||||
icon="pi pi-eye"
|
||||
className="p-button-text p-button-sm"
|
||||
onClick={() => onViewDevis(devis.id)}
|
||||
tooltip="Voir le devis"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertsWidget;
|
||||
158
components/dashboard/ChantiersList.tsx
Normal file
158
components/dashboard/ChantiersList.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Composant liste des chantiers récents
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Badge } from 'primereact/badge';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Skeleton } from 'primereact/skeleton';
|
||||
import { ChantierRecent, StatutChantier } from '../../types/btp';
|
||||
|
||||
interface ChantiersListProps {
|
||||
chantiers: ChantierRecent[];
|
||||
loading?: boolean;
|
||||
onViewAll?: () => void;
|
||||
}
|
||||
|
||||
const ChantiersList: React.FC<ChantiersListProps> = ({
|
||||
chantiers,
|
||||
loading = false,
|
||||
onViewAll
|
||||
}) => {
|
||||
const getStatutSeverity = (statut: StatutChantier) => {
|
||||
switch (statut) {
|
||||
case StatutChantier.EN_COURS:
|
||||
return 'info';
|
||||
case StatutChantier.PLANIFIE:
|
||||
return 'warning';
|
||||
case StatutChantier.TERMINE:
|
||||
return 'success';
|
||||
case StatutChantier.ANNULE:
|
||||
return 'danger';
|
||||
case StatutChantier.SUSPENDU:
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatutLabel = (statut: StatutChantier) => {
|
||||
switch (statut) {
|
||||
case StatutChantier.EN_COURS:
|
||||
return 'En cours';
|
||||
case StatutChantier.PLANIFIE:
|
||||
return 'Planifié';
|
||||
case StatutChantier.TERMINE:
|
||||
return 'Terminé';
|
||||
case StatutChantier.ANNULE:
|
||||
return 'Annulé';
|
||||
case StatutChantier.SUSPENDU:
|
||||
return 'Suspendu';
|
||||
default:
|
||||
return statut;
|
||||
}
|
||||
};
|
||||
|
||||
const statutBodyTemplate = (rowData: ChantierRecent) => {
|
||||
return (
|
||||
<Badge
|
||||
value={getStatutLabel(rowData.statut)}
|
||||
severity={getStatutSeverity(rowData.statut)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const montantBodyTemplate = (rowData: ChantierRecent) => {
|
||||
return rowData.montantPrevu
|
||||
? `${rowData.montantPrevu.toLocaleString()} €`
|
||||
: '-';
|
||||
};
|
||||
|
||||
const dateBodyTemplate = (rowData: ChantierRecent) => {
|
||||
return new Date(rowData.dateDebut).toLocaleDateString('fr-FR');
|
||||
};
|
||||
|
||||
const header = (
|
||||
<div className="flex align-items-center justify-content-between">
|
||||
<h5 className="m-0">Chantiers récents</h5>
|
||||
{onViewAll && (
|
||||
<Button
|
||||
label="Voir tout"
|
||||
icon="pi pi-external-link"
|
||||
className="p-button-text p-button-sm"
|
||||
onClick={onViewAll}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex align-items-center justify-content-between mb-3">
|
||||
<Skeleton width="8rem" height="1.5rem" />
|
||||
<Skeleton width="5rem" height="2rem" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex align-items-center justify-content-between p-3 border-1 border-200 border-round">
|
||||
<div className="flex-1">
|
||||
<Skeleton width="60%" height="1rem" className="mb-2" />
|
||||
<Skeleton width="40%" height="0.8rem" />
|
||||
</div>
|
||||
<Skeleton width="5rem" height="1.5rem" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<DataTable
|
||||
value={chantiers}
|
||||
header={header}
|
||||
responsiveLayout="scroll"
|
||||
showHeaders={false}
|
||||
emptyMessage="Aucun chantier récent"
|
||||
className="p-datatable-sm"
|
||||
>
|
||||
<Column
|
||||
field="nom"
|
||||
header="Nom"
|
||||
style={{ width: '35%' }}
|
||||
body={(rowData: ChantierRecent) => (
|
||||
<div>
|
||||
<div className="font-medium text-900">{rowData.nom}</div>
|
||||
<div className="text-500 text-sm">{rowData.client}</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Column
|
||||
field="statut"
|
||||
header="Statut"
|
||||
style={{ width: '25%' }}
|
||||
body={statutBodyTemplate}
|
||||
/>
|
||||
<Column
|
||||
field="dateDebut"
|
||||
header="Date"
|
||||
style={{ width: '20%' }}
|
||||
body={dateBodyTemplate}
|
||||
/>
|
||||
<Column
|
||||
field="montantPrevu"
|
||||
header="Montant"
|
||||
style={{ width: '20%' }}
|
||||
body={montantBodyTemplate}
|
||||
/>
|
||||
</DataTable>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChantiersList;
|
||||
100
components/dashboard/StatsCard.tsx
Normal file
100
components/dashboard/StatsCard.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Composant carte de statistiques pour le dashboard
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Badge } from 'primereact/badge';
|
||||
import { Skeleton } from 'primereact/skeleton';
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string;
|
||||
value: number | string;
|
||||
icon: string;
|
||||
color: 'primary' | 'success' | 'info' | 'warning' | 'danger';
|
||||
loading?: boolean;
|
||||
subtitle?: string;
|
||||
trend?: {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const StatsCard: React.FC<StatsCardProps> = ({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
color,
|
||||
loading = false,
|
||||
subtitle,
|
||||
trend
|
||||
}) => {
|
||||
const getColorClass = (color: string) => {
|
||||
switch (color) {
|
||||
case 'primary': return 'text-blue-500';
|
||||
case 'success': return 'text-green-500';
|
||||
case 'info': return 'text-cyan-500';
|
||||
case 'warning': return 'text-yellow-500';
|
||||
case 'danger': return 'text-red-500';
|
||||
default: return 'text-blue-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getBgClass = (color: string) => {
|
||||
switch (color) {
|
||||
case 'primary': return 'bg-blue-100';
|
||||
case 'success': return 'bg-green-100';
|
||||
case 'info': return 'bg-cyan-100';
|
||||
case 'warning': return 'bg-yellow-100';
|
||||
case 'danger': return 'bg-red-100';
|
||||
default: return 'bg-blue-100';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<div className="flex align-items-center justify-content-between mb-3">
|
||||
<Skeleton width="60%" height="1rem" />
|
||||
<Skeleton width="2rem" height="2rem" borderRadius="50%" />
|
||||
</div>
|
||||
<Skeleton width="40%" height="2rem" className="mb-2" />
|
||||
<Skeleton width="80%" height="1rem" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<div className="flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<div className="text-500 font-medium text-xl mb-2">{title}</div>
|
||||
<div className="text-900 font-bold text-2xl">
|
||||
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div className="text-500 text-sm mt-1">{subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`${getBgClass(color)} border-round-lg p-3`}>
|
||||
<i className={`${icon} ${getColorClass(color)} text-2xl`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{trend && (
|
||||
<div className="flex align-items-center">
|
||||
<Badge
|
||||
value={`${trend.isPositive ? '+' : ''}${trend.value}%`}
|
||||
severity={trend.isPositive ? 'success' : 'danger'}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-500 text-sm">
|
||||
{trend.isPositive ? 'Augmentation' : 'Diminution'} ce mois
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsCard;
|
||||
212
components/dashboard/__tests__/ChantiersList.test.tsx
Normal file
212
components/dashboard/__tests__/ChantiersList.test.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '../../../test-utils'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ChantiersList from '../ChantiersList'
|
||||
import { ChantierRecent, StatutChantier } from '../../../types/btp'
|
||||
|
||||
const mockChantiers: ChantierRecent[] = [
|
||||
{
|
||||
id: '1',
|
||||
nom: 'Rénovation Bureau',
|
||||
client: 'Entreprise ABC',
|
||||
statut: StatutChantier.EN_COURS,
|
||||
dateDebut: '2024-01-15',
|
||||
montantPrevu: 45000,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
nom: 'Construction Garage',
|
||||
client: 'Client XYZ',
|
||||
statut: StatutChantier.PLANIFIE,
|
||||
dateDebut: '2024-02-01',
|
||||
montantPrevu: 25000,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
nom: 'Réparation Toiture',
|
||||
client: 'Mairie',
|
||||
statut: StatutChantier.TERMINE,
|
||||
dateDebut: '2024-01-01',
|
||||
montantPrevu: 15000,
|
||||
},
|
||||
]
|
||||
|
||||
describe('Composant ChantiersList', () => {
|
||||
const mockOnViewAll = jest.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('devrait afficher la liste des chantiers', () => {
|
||||
render(<ChantiersList chantiers={mockChantiers} onViewAll={mockOnViewAll} />)
|
||||
|
||||
expect(screen.getByText('Chantiers récents')).toBeInTheDocument()
|
||||
expect(screen.getByText('Rénovation Bureau')).toBeInTheDocument()
|
||||
expect(screen.getByText('Construction Garage')).toBeInTheDocument()
|
||||
expect(screen.getByText('Réparation Toiture')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait afficher les informations détaillées de chaque chantier', () => {
|
||||
render(<ChantiersList chantiers={mockChantiers} onViewAll={mockOnViewAll} />)
|
||||
|
||||
// Vérifier les noms des clients
|
||||
expect(screen.getByText('Entreprise ABC')).toBeInTheDocument()
|
||||
expect(screen.getByText('Client XYZ')).toBeInTheDocument()
|
||||
expect(screen.getByText('Mairie')).toBeInTheDocument()
|
||||
|
||||
// Vérifier les montants formatés
|
||||
expect(screen.getByText('45 000 €')).toBeInTheDocument()
|
||||
expect(screen.getByText('25 000 €')).toBeInTheDocument()
|
||||
expect(screen.getByText('15 000 €')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait afficher les statuts corrects avec les bonnes couleurs', () => {
|
||||
render(<ChantiersList chantiers={mockChantiers} onViewAll={mockOnViewAll} />)
|
||||
|
||||
expect(screen.getByText('En cours')).toBeInTheDocument()
|
||||
expect(screen.getByText('Planifié')).toBeInTheDocument()
|
||||
expect(screen.getByText('Terminé')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait formater les dates correctement', () => {
|
||||
render(<ChantiersList chantiers={mockChantiers} onViewAll={mockOnViewAll} />)
|
||||
|
||||
// Vérifier que les dates sont au format français
|
||||
expect(screen.getByText('15/01/2024')).toBeInTheDocument()
|
||||
expect(screen.getByText('01/02/2024')).toBeInTheDocument()
|
||||
expect(screen.getByText('01/01/2024')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait afficher le bouton "Voir tout" et le rendre cliquable', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ChantiersList chantiers={mockChantiers} onViewAll={mockOnViewAll} />)
|
||||
|
||||
const viewAllButton = screen.getByText('Voir tout')
|
||||
expect(viewAllButton).toBeInTheDocument()
|
||||
|
||||
await user.click(viewAllButton)
|
||||
expect(mockOnViewAll).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ne devrait pas afficher le bouton "Voir tout" si onViewAll n\'est pas fourni', () => {
|
||||
render(<ChantiersList chantiers={mockChantiers} />)
|
||||
|
||||
expect(screen.queryByText('Voir tout')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait afficher un message quand il n\'y a pas de chantiers', () => {
|
||||
render(<ChantiersList chantiers={[]} onViewAll={mockOnViewAll} />)
|
||||
|
||||
expect(screen.getByText('Aucun chantier récent')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait afficher l\'état de chargement', () => {
|
||||
render(<ChantiersList chantiers={[]} loading={true} />)
|
||||
|
||||
// Vérifier que le titre n'est pas affiché en mode chargement
|
||||
expect(screen.queryByText('Chantiers récents')).not.toBeInTheDocument()
|
||||
|
||||
// Vérifier la présence des skeletons
|
||||
const skeletons = document.querySelectorAll('[data-pc-name="skeleton"]')
|
||||
expect(skeletons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('devrait gérer tous les statuts de chantier', () => {
|
||||
const chantiersAvecTousStatuts: ChantierRecent[] = [
|
||||
{
|
||||
id: '1',
|
||||
nom: 'Chantier 1',
|
||||
client: 'Client 1',
|
||||
statut: StatutChantier.EN_COURS,
|
||||
dateDebut: '2024-01-01',
|
||||
montantPrevu: 1000,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
nom: 'Chantier 2',
|
||||
client: 'Client 2',
|
||||
statut: StatutChantier.PLANIFIE,
|
||||
dateDebut: '2024-01-01',
|
||||
montantPrevu: 2000,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
nom: 'Chantier 3',
|
||||
client: 'Client 3',
|
||||
statut: StatutChantier.TERMINE,
|
||||
dateDebut: '2024-01-01',
|
||||
montantPrevu: 3000,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
nom: 'Chantier 4',
|
||||
client: 'Client 4',
|
||||
statut: StatutChantier.ANNULE,
|
||||
dateDebut: '2024-01-01',
|
||||
montantPrevu: 4000,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
nom: 'Chantier 5',
|
||||
client: 'Client 5',
|
||||
statut: StatutChantier.SUSPENDU,
|
||||
dateDebut: '2024-01-01',
|
||||
montantPrevu: 5000,
|
||||
},
|
||||
]
|
||||
|
||||
render(<ChantiersList chantiers={chantiersAvecTousStatuts} />)
|
||||
|
||||
expect(screen.getByText('En cours')).toBeInTheDocument()
|
||||
expect(screen.getByText('Planifié')).toBeInTheDocument()
|
||||
expect(screen.getByText('Terminé')).toBeInTheDocument()
|
||||
expect(screen.getByText('Annulé')).toBeInTheDocument()
|
||||
expect(screen.getByText('Suspendu')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait gérer les montants manquants', () => {
|
||||
const chantiersAvecMontantManquant: ChantierRecent[] = [
|
||||
{
|
||||
id: '1',
|
||||
nom: 'Chantier sans montant',
|
||||
client: 'Client Test',
|
||||
statut: StatutChantier.EN_COURS,
|
||||
dateDebut: '2024-01-01',
|
||||
montantPrevu: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
render(<ChantiersList chantiers={chantiersAvecMontantManquant} />)
|
||||
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait avoir une structure de table responsive', () => {
|
||||
const { container } = render(<ChantiersList chantiers={mockChantiers} />)
|
||||
|
||||
const dataTable = container.querySelector('[data-pc-name="datatable"]')
|
||||
expect(dataTable).toBeInTheDocument()
|
||||
|
||||
// Vérifier que les en-têtes sont cachés
|
||||
expect(dataTable).toHaveAttribute('data-pc-section', 'wrapper')
|
||||
})
|
||||
|
||||
it('devrait formater correctement les gros montants', () => {
|
||||
const chantiersAvecGrosMontants: ChantierRecent[] = [
|
||||
{
|
||||
id: '1',
|
||||
nom: 'Gros Chantier',
|
||||
client: 'Client VIP',
|
||||
statut: StatutChantier.EN_COURS,
|
||||
dateDebut: '2024-01-01',
|
||||
montantPrevu: 1234567,
|
||||
},
|
||||
]
|
||||
|
||||
render(<ChantiersList chantiers={chantiersAvecGrosMontants} />)
|
||||
|
||||
// Vérifier que le montant est formaté avec des séparateurs
|
||||
expect(screen.getByText(/1[,\s]?234[,\s]?567 €/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
132
components/dashboard/__tests__/StatsCard.test.tsx
Normal file
132
components/dashboard/__tests__/StatsCard.test.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '../../../test-utils'
|
||||
import StatsCard from '../StatsCard'
|
||||
|
||||
describe('Composant StatsCard', () => {
|
||||
const defaultProps = {
|
||||
title: 'Projets actifs',
|
||||
value: 42,
|
||||
icon: 'pi pi-building',
|
||||
color: 'primary' as const,
|
||||
}
|
||||
|
||||
it('devrait afficher les informations de base', () => {
|
||||
render(<StatsCard {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('Projets actifs')).toBeInTheDocument()
|
||||
expect(screen.getByText('42')).toBeInTheDocument()
|
||||
expect(document.querySelector('.pi-building')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait formater les nombres avec des séparateurs', () => {
|
||||
render(<StatsCard {...defaultProps} value={1234567} />)
|
||||
|
||||
// Le format peut varier selon la locale
|
||||
expect(screen.getByText(/1[,\s]?234[,\s]?567/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait afficher les valeurs string directement', () => {
|
||||
render(<StatsCard {...defaultProps} value="50 000 €" />)
|
||||
|
||||
expect(screen.getByText('50 000 €')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait afficher le sous-titre si fourni', () => {
|
||||
render(<StatsCard {...defaultProps} subtitle="En cours ce mois" />)
|
||||
|
||||
expect(screen.getByText('En cours ce mois')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait afficher la tendance positive', () => {
|
||||
render(
|
||||
<StatsCard
|
||||
{...defaultProps}
|
||||
trend={{ value: 15, isPositive: true }}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('+15%')).toBeInTheDocument()
|
||||
expect(screen.getByText('Augmentation ce mois')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait afficher la tendance négative', () => {
|
||||
render(
|
||||
<StatsCard
|
||||
{...defaultProps}
|
||||
trend={{ value: 5, isPositive: false }}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('-5%')).toBeInTheDocument()
|
||||
expect(screen.getByText('Diminution ce mois')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait appliquer la bonne couleur', () => {
|
||||
const colors = [
|
||||
{ color: 'primary' as const, textClass: 'text-blue-500', bgClass: 'bg-blue-100' },
|
||||
{ color: 'success' as const, textClass: 'text-green-500', bgClass: 'bg-green-100' },
|
||||
{ color: 'info' as const, textClass: 'text-cyan-500', bgClass: 'bg-cyan-100' },
|
||||
{ color: 'warning' as const, textClass: 'text-yellow-500', bgClass: 'bg-yellow-100' },
|
||||
{ color: 'danger' as const, textClass: 'text-red-500', bgClass: 'bg-red-100' },
|
||||
]
|
||||
|
||||
colors.forEach(({ color, textClass, bgClass }) => {
|
||||
const { container } = render(<StatsCard {...defaultProps} color={color} />)
|
||||
|
||||
const icon = container.querySelector(`.${defaultProps.icon.replace(' ', '.')}`)
|
||||
const iconContainer = icon?.parentElement
|
||||
|
||||
expect(icon).toHaveClass(textClass)
|
||||
expect(iconContainer).toHaveClass(bgClass)
|
||||
})
|
||||
})
|
||||
|
||||
it('devrait afficher l\'état de chargement', () => {
|
||||
render(<StatsCard {...defaultProps} loading={true} />)
|
||||
|
||||
// Vérifier la présence des skeletons
|
||||
expect(screen.queryByText('Projets actifs')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('42')).not.toBeInTheDocument()
|
||||
|
||||
// PrimeReact Skeleton crée des éléments avec data-pc-name="skeleton"
|
||||
const skeletons = document.querySelectorAll('[data-pc-name="skeleton"]')
|
||||
expect(skeletons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('devrait avoir une hauteur complète', () => {
|
||||
const { container } = render(<StatsCard {...defaultProps} />)
|
||||
|
||||
const card = container.querySelector('[data-pc-name="card"]')
|
||||
expect(card).toHaveClass('h-full')
|
||||
})
|
||||
|
||||
it('devrait gérer tous les types d\'icônes PrimeIcons', () => {
|
||||
const icons = ['pi pi-users', 'pi pi-euro', 'pi pi-exclamation-triangle']
|
||||
|
||||
icons.forEach(icon => {
|
||||
const { container } = render(<StatsCard {...defaultProps} icon={icon} />)
|
||||
const iconElement = container.querySelector(`.${icon.split(' ').join('.')}`)
|
||||
expect(iconElement).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('devrait afficher correctement avec toutes les props', () => {
|
||||
render(
|
||||
<StatsCard
|
||||
title="Chiffre d'affaires"
|
||||
value="125 450 €"
|
||||
icon="pi pi-euro"
|
||||
color="success"
|
||||
subtitle="Total ce mois"
|
||||
trend={{ value: 8, isPositive: true }}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText("Chiffre d'affaires")).toBeInTheDocument()
|
||||
expect(screen.getByText("125 450 €")).toBeInTheDocument()
|
||||
expect(screen.getByText("Total ce mois")).toBeInTheDocument()
|
||||
expect(screen.getByText("+8%")).toBeInTheDocument()
|
||||
expect(screen.getByText("Augmentation ce mois")).toBeInTheDocument()
|
||||
expect(document.querySelector('.pi-euro')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
251
components/layout/AppLayout.tsx
Normal file
251
components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Sidebar } from 'primereact/sidebar';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Menubar } from 'primereact/menubar';
|
||||
import { Menu } from 'primereact/menu';
|
||||
import { Avatar } from 'primereact/avatar';
|
||||
import { Badge } from 'primereact/badge';
|
||||
import { Ripple } from 'primereact/ripple';
|
||||
import { StyleClass } from 'primereact/styleclass';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useDevAuth } from '../auth/DevAuthProvider';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
||||
const [sidebarVisible, setSidebarVisible] = useState(false);
|
||||
const [profileMenuVisible, setProfileMenuVisible] = useState(false);
|
||||
const { user, logout, isAuthenticated } = useDevAuth();
|
||||
const router = useRouter();
|
||||
const profileMenuRef = useRef<Menu>(null);
|
||||
|
||||
// Menu items pour la sidebar
|
||||
const sidebarItems = [
|
||||
{
|
||||
label: 'Tableau de Bord',
|
||||
icon: 'pi pi-home',
|
||||
command: () => router.push('/dashboard')
|
||||
},
|
||||
{
|
||||
label: 'Chantiers',
|
||||
icon: 'pi pi-building',
|
||||
items: [
|
||||
{ label: 'Tous les chantiers', icon: 'pi pi-list', command: () => router.push('/chantiers') },
|
||||
{ label: 'Nouveau chantier', icon: 'pi pi-plus', command: () => router.push('/chantiers/nouveau') },
|
||||
{ label: 'Planning', icon: 'pi pi-calendar', command: () => router.push('/chantiers/planning') }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Clients',
|
||||
icon: 'pi pi-users',
|
||||
items: [
|
||||
{ label: 'Tous les clients', icon: 'pi pi-list', command: () => router.push('/clients') },
|
||||
{ label: 'Nouveau client', icon: 'pi pi-plus', command: () => router.push('/clients/nouveau') }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Devis & Factures',
|
||||
icon: 'pi pi-file-edit',
|
||||
items: [
|
||||
{ label: 'Devis', icon: 'pi pi-file', command: () => router.push('/devis') },
|
||||
{ label: 'Factures', icon: 'pi pi-receipt', command: () => router.push('/factures') },
|
||||
{ label: 'Nouveau devis', icon: 'pi pi-plus', command: () => router.push('/devis/nouveau') }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Matériel',
|
||||
icon: 'pi pi-cog',
|
||||
items: [
|
||||
{ label: 'Inventaire', icon: 'pi pi-list', command: () => router.push('/materiel') },
|
||||
{ label: 'Maintenance', icon: 'pi pi-wrench', command: () => router.push('/materiel/maintenance') },
|
||||
{ label: 'Réservations', icon: 'pi pi-calendar-plus', command: () => router.push('/materiel/reservations') }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Équipes',
|
||||
icon: 'pi pi-users',
|
||||
items: [
|
||||
{ label: 'Employés', icon: 'pi pi-user', command: () => router.push('/employes') },
|
||||
{ label: 'Équipes', icon: 'pi pi-users', command: () => router.push('/equipes') },
|
||||
{ label: 'Planning', icon: 'pi pi-calendar', command: () => router.push('/employes/planning') }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Fournisseurs',
|
||||
icon: 'pi pi-truck',
|
||||
items: [
|
||||
{ label: 'Tous les fournisseurs', icon: 'pi pi-list', command: () => router.push('/fournisseurs') },
|
||||
{ label: 'Nouveau fournisseur', icon: 'pi pi-plus', command: () => router.push('/fournisseurs/nouveau') }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Rapports',
|
||||
icon: 'pi pi-chart-bar',
|
||||
items: [
|
||||
{ label: 'Statistiques', icon: 'pi pi-chart-line', command: () => router.push('/rapports/statistiques') },
|
||||
{ label: 'Finances', icon: 'pi pi-euro', command: () => router.push('/rapports/finances') },
|
||||
{ label: 'Performance', icon: 'pi pi-chart-pie', command: () => router.push('/rapports/performance') }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Menu items pour le profil
|
||||
const profileItems = [
|
||||
{
|
||||
label: 'Mon Profil',
|
||||
icon: 'pi pi-user',
|
||||
command: () => router.push('/profil')
|
||||
},
|
||||
{
|
||||
label: 'Paramètres',
|
||||
icon: 'pi pi-cog',
|
||||
command: () => router.push('/parametres')
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: 'Déconnexion',
|
||||
icon: 'pi pi-sign-out',
|
||||
command: () => {
|
||||
logout();
|
||||
window.location.href = '/api/auth/login';
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Menu items pour la barre de navigation
|
||||
const navItems = [
|
||||
{
|
||||
label: 'Accueil',
|
||||
icon: 'pi pi-home',
|
||||
command: () => router.push('/dashboard')
|
||||
},
|
||||
{
|
||||
label: 'Chantiers',
|
||||
icon: 'pi pi-building',
|
||||
command: () => router.push('/chantiers')
|
||||
},
|
||||
{
|
||||
label: 'Clients',
|
||||
icon: 'pi pi-users',
|
||||
command: () => router.push('/clients')
|
||||
},
|
||||
{
|
||||
label: 'Matériel',
|
||||
icon: 'pi pi-cog',
|
||||
command: () => router.push('/materiel')
|
||||
}
|
||||
];
|
||||
|
||||
const start = (
|
||||
<div className="flex align-items-center">
|
||||
<Button
|
||||
icon="pi pi-bars"
|
||||
className="p-button-text p-button-rounded p-button-plain mr-2"
|
||||
onClick={() => setSidebarVisible(true)}
|
||||
/>
|
||||
<Link href="/dashboard" className="flex align-items-center text-decoration-none">
|
||||
<img src="/layout/images/logo/logo-dark.png" alt="BTP Xpress" height="40" className="mr-2" />
|
||||
<span className="text-2xl font-bold text-primary">BTP Xpress</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
const end = (
|
||||
<div className="flex align-items-center">
|
||||
<Button
|
||||
icon="pi pi-bell"
|
||||
className="p-button-text p-button-rounded p-button-plain mr-2"
|
||||
badge="3"
|
||||
badgeClassName="p-badge-danger"
|
||||
/>
|
||||
<Avatar
|
||||
image={user?.avatar || '/default-avatar.png'}
|
||||
shape="circle"
|
||||
size="normal"
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => profileMenuRef.current?.toggle(e)}
|
||||
/>
|
||||
<Menu
|
||||
ref={profileMenuRef}
|
||||
model={profileItems}
|
||||
popup
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="layout-wrapper">
|
||||
{/* Navigation principale */}
|
||||
<Menubar
|
||||
model={navItems}
|
||||
start={start}
|
||||
end={end}
|
||||
className="layout-topbar"
|
||||
/>
|
||||
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
visible={sidebarVisible}
|
||||
onHide={() => setSidebarVisible(false)}
|
||||
className="layout-sidebar"
|
||||
modal={false}
|
||||
>
|
||||
<div className="layout-sidebar-content">
|
||||
<div className="layout-menu">
|
||||
{sidebarItems.map((item, index) => (
|
||||
<div key={index} className="layout-menuitem">
|
||||
{item.items ? (
|
||||
<div>
|
||||
<div className="layout-menuitem-text">
|
||||
<i className={item.icon}></i>
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
<div className="layout-submenu">
|
||||
{item.items.map((subItem, subIndex) => (
|
||||
<div
|
||||
key={subIndex}
|
||||
className="layout-submenuitem"
|
||||
onClick={subItem.command}
|
||||
>
|
||||
<i className={subItem.icon}></i>
|
||||
<span>{subItem.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="layout-menuitem-text"
|
||||
onClick={item.command}
|
||||
>
|
||||
<i className={item.icon}></i>
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Sidebar>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<div className="layout-main">
|
||||
<div className="layout-content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
266
components/phases/AtlantisAccessibilityControls.tsx
Normal file
266
components/phases/AtlantisAccessibilityControls.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import { Panel } from 'primereact/panel';
|
||||
import { InputSwitch } from 'primereact/inputswitch';
|
||||
import { Slider } from 'primereact/slider';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Message } from 'primereact/message';
|
||||
import { LayoutContext } from '../../layout/context/layoutcontext';
|
||||
|
||||
interface AtlantisAccessibilityControlsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface AccessibilitySettings {
|
||||
fontSize: number;
|
||||
highContrast: boolean;
|
||||
reduceMotion: boolean;
|
||||
screenReader: boolean;
|
||||
keyboardNav: boolean;
|
||||
}
|
||||
|
||||
const AtlantisAccessibilityControls: React.FC<AtlantisAccessibilityControlsProps> = ({
|
||||
className = ''
|
||||
}) => {
|
||||
const { layoutConfig, setLayoutConfig } = useContext(LayoutContext);
|
||||
const [settings, setSettings] = useState<AccessibilitySettings>({
|
||||
fontSize: 14,
|
||||
highContrast: false,
|
||||
reduceMotion: false,
|
||||
screenReader: false,
|
||||
keyboardNav: true
|
||||
});
|
||||
|
||||
// Options de thème pour l'accessibilité
|
||||
const contrastThemes = [
|
||||
{ label: 'Thème normal', value: 'magenta' },
|
||||
{ label: 'Contraste élevé - Bleu', value: 'blue' },
|
||||
{ label: 'Contraste élevé - Sombre', value: 'dark' }
|
||||
];
|
||||
|
||||
// Appliquer les paramètres d'accessibilité
|
||||
useEffect(() => {
|
||||
// Appliquer la taille de police via scale Atlantis
|
||||
setLayoutConfig(prev => ({
|
||||
...prev,
|
||||
scale: settings.fontSize
|
||||
}));
|
||||
|
||||
// Appliquer le thème de contraste
|
||||
if (settings.highContrast) {
|
||||
setLayoutConfig(prev => ({
|
||||
...prev,
|
||||
theme: 'blue', // Thème avec meilleur contraste
|
||||
colorScheme: 'dark'
|
||||
}));
|
||||
}
|
||||
|
||||
// Classes CSS pour les animations
|
||||
const rootElement = document.documentElement;
|
||||
if (settings.reduceMotion) {
|
||||
rootElement.style.setProperty('--transition-duration', '0ms');
|
||||
} else {
|
||||
rootElement.style.removeProperty('--transition-duration');
|
||||
}
|
||||
|
||||
// Annoncer les changements pour les lecteurs d'écran
|
||||
if (settings.screenReader) {
|
||||
announceChange('Paramètres d\'accessibilité mis à jour');
|
||||
}
|
||||
|
||||
}, [settings, setLayoutConfig]);
|
||||
|
||||
// Fonction d'annonce pour lecteurs d'écran
|
||||
const announceChange = (message: string) => {
|
||||
const announcement = document.createElement('div');
|
||||
announcement.setAttribute('aria-live', 'polite');
|
||||
announcement.setAttribute('aria-atomic', 'true');
|
||||
announcement.className = 'sr-only';
|
||||
announcement.textContent = message;
|
||||
document.body.appendChild(announcement);
|
||||
setTimeout(() => document.body.removeChild(announcement), 1000);
|
||||
};
|
||||
|
||||
const updateSetting = (key: keyof AccessibilitySettings, value: any) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const resetToDefaults = () => {
|
||||
setSettings({
|
||||
fontSize: 14,
|
||||
highContrast: false,
|
||||
reduceMotion: false,
|
||||
screenReader: false,
|
||||
keyboardNav: true
|
||||
});
|
||||
|
||||
setLayoutConfig(prev => ({
|
||||
...prev,
|
||||
scale: 14,
|
||||
theme: 'magenta',
|
||||
colorScheme: 'dark'
|
||||
}));
|
||||
|
||||
announceChange('Paramètres d\'accessibilité réinitialisés');
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel
|
||||
header="Paramètres d'accessibilité"
|
||||
toggleable
|
||||
collapsed
|
||||
className={`card ${className}`}
|
||||
pt={{
|
||||
header: { className: 'surface-100' },
|
||||
content: { className: 'surface-50' }
|
||||
}}
|
||||
>
|
||||
<div className="grid">
|
||||
{/* Taille de police */}
|
||||
<div className="col-12 md:col-6">
|
||||
<div className="field">
|
||||
<label htmlFor="fontSize" className="font-semibold text-color">
|
||||
Taille de police: {settings.fontSize}px
|
||||
</label>
|
||||
<Slider
|
||||
id="fontSize"
|
||||
value={settings.fontSize}
|
||||
onChange={(e) => updateSetting('fontSize', e.value)}
|
||||
min={12}
|
||||
max={20}
|
||||
step={1}
|
||||
className="w-full mt-2"
|
||||
/>
|
||||
<div className="flex justify-content-between text-xs text-color-secondary mt-1">
|
||||
<span>Petit</span>
|
||||
<span>Normal</span>
|
||||
<span>Grand</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contraste élevé */}
|
||||
<div className="col-12 md:col-6">
|
||||
<div className="field">
|
||||
<label htmlFor="highContrast" className="font-semibold text-color">
|
||||
Mode contraste élevé
|
||||
</label>
|
||||
<div className="flex align-items-center gap-2 mt-2">
|
||||
<InputSwitch
|
||||
id="highContrast"
|
||||
checked={settings.highContrast}
|
||||
onChange={(e) => updateSetting('highContrast', e.value)}
|
||||
/>
|
||||
<span className="text-sm text-color-secondary">
|
||||
{settings.highContrast ? 'Activé' : 'Désactivé'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Réduction des animations */}
|
||||
<div className="col-12 md:col-6">
|
||||
<div className="field">
|
||||
<label htmlFor="reduceMotion" className="font-semibold text-color">
|
||||
Réduire les animations
|
||||
</label>
|
||||
<div className="flex align-items-center gap-2 mt-2">
|
||||
<InputSwitch
|
||||
id="reduceMotion"
|
||||
checked={settings.reduceMotion}
|
||||
onChange={(e) => updateSetting('reduceMotion', e.value)}
|
||||
/>
|
||||
<span className="text-sm text-color-secondary">
|
||||
Pour les utilisateurs sensibles au mouvement
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode lecteur d'écran */}
|
||||
<div className="col-12 md:col-6">
|
||||
<div className="field">
|
||||
<label htmlFor="screenReader" className="font-semibold text-color">
|
||||
Mode lecteur d'écran
|
||||
</label>
|
||||
<div className="flex align-items-center gap-2 mt-2">
|
||||
<InputSwitch
|
||||
id="screenReader"
|
||||
checked={settings.screenReader}
|
||||
onChange={(e) => updateSetting('screenReader', e.value)}
|
||||
/>
|
||||
<span className="text-sm text-color-secondary">
|
||||
Annonces vocales activées
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Informations et aide */}
|
||||
<div className="mt-4">
|
||||
<Message
|
||||
severity="info"
|
||||
text="Ces paramètres améliorent l'accessibilité selon vos besoins"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Raccourcis clavier */}
|
||||
<div className="card mt-3">
|
||||
<h6 className="mt-0 mb-3 text-color">Raccourcis clavier disponibles</h6>
|
||||
<div className="grid text-sm">
|
||||
<div className="col-12 md:col-6">
|
||||
<div className="flex align-items-center gap-2 mb-2">
|
||||
<kbd className="bg-surface-200 text-color px-2 py-1 border-round text-xs">Tab</kbd>
|
||||
<span className="text-color-secondary">Naviguer entre les éléments</span>
|
||||
</div>
|
||||
<div className="flex align-items-center gap-2 mb-2">
|
||||
<kbd className="bg-surface-200 text-color px-2 py-1 border-round text-xs">Entrée</kbd>
|
||||
<span className="text-color-secondary">Activer un élément</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 md:col-6">
|
||||
<div className="flex align-items-center gap-2 mb-2">
|
||||
<kbd className="bg-surface-200 text-color px-2 py-1 border-round text-xs">Échap</kbd>
|
||||
<span className="text-color-secondary">Fermer un dialogue</span>
|
||||
</div>
|
||||
<div className="flex align-items-center gap-2 mb-2">
|
||||
<kbd className="bg-surface-200 text-color px-2 py-1 border-round text-xs">↑↓</kbd>
|
||||
<span className="text-color-secondary">Naviguer dans les listes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-content-between align-items-center mt-4">
|
||||
<Button
|
||||
label="Réinitialiser"
|
||||
icon="pi pi-refresh"
|
||||
className="p-button-outlined"
|
||||
onClick={resetToDefaults}
|
||||
/>
|
||||
|
||||
<div className="flex align-items-center gap-2">
|
||||
<i className="pi pi-info-circle text-primary" />
|
||||
<span className="text-sm text-color-secondary">
|
||||
Paramètres sauvegardés automatiquement
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Région pour les annonces lecteurs d'écran */}
|
||||
<div
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
id="accessibility-announcements"
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
|
||||
export default AtlantisAccessibilityControls;
|
||||
371
components/phases/AtlantisResponsivePhasesTable.tsx
Normal file
371
components/phases/AtlantisResponsivePhasesTable.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useContext } from 'react';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Badge } from 'primereact/badge';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Toolbar } from 'primereact/toolbar';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { InputSwitch } from 'primereact/inputswitch';
|
||||
import { LayoutContext } from '../../layout/context/layoutcontext';
|
||||
import type { PhaseChantier } from '../../types/btp';
|
||||
import phaseValidationService from '../../services/phaseValidationService';
|
||||
|
||||
interface AtlantisResponsivePhasesTableProps {
|
||||
phases: PhaseChantier[];
|
||||
onPhaseSelect?: (phase: PhaseChantier) => void;
|
||||
onPhaseStart?: (phaseId: string) => void;
|
||||
onPhaseValidate?: (phase: PhaseChantier) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AtlantisResponsivePhasesTable: React.FC<AtlantisResponsivePhasesTableProps> = ({
|
||||
phases,
|
||||
onPhaseSelect,
|
||||
onPhaseStart,
|
||||
onPhaseValidate,
|
||||
className = ''
|
||||
}) => {
|
||||
const [selectedPhase, setSelectedPhase] = useState<PhaseChantier | null>(null);
|
||||
const [globalFilter, setGlobalFilter] = useState<string>('');
|
||||
const [filters, setFilters] = useState<any>({});
|
||||
const { layoutConfig, isDesktop } = useContext(LayoutContext);
|
||||
|
||||
// Options de filtrage responsive
|
||||
const [compactView, setCompactView] = useState(!isDesktop());
|
||||
const [showSubPhases, setShowSubPhases] = useState(true);
|
||||
|
||||
const handlePhaseSelect = (phase: PhaseChantier) => {
|
||||
setSelectedPhase(phase);
|
||||
onPhaseSelect?.(phase);
|
||||
};
|
||||
|
||||
// Template pour le nom des phases avec hiérarchie Atlantis
|
||||
const nameBodyTemplate = (rowData: PhaseChantier) => {
|
||||
const isSubPhase = !!rowData.phaseParent;
|
||||
|
||||
return (
|
||||
<div className={`flex align-items-center gap-2 ${isSubPhase ? 'ml-4' : ''}`}>
|
||||
{isSubPhase && (
|
||||
<i className="pi pi-arrow-right text-color-secondary text-sm" />
|
||||
)}
|
||||
<span className={`${isSubPhase ? 'text-color-secondary' : 'font-semibold text-color'}`}>
|
||||
{rowData.nom}
|
||||
</span>
|
||||
{rowData.critique && (
|
||||
<Tag
|
||||
value="Critique"
|
||||
severity="danger"
|
||||
className="text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Template pour le statut avec Tag PrimeReact
|
||||
const statusBodyTemplate = (rowData: PhaseChantier) => {
|
||||
const getSeverity = () => {
|
||||
switch (rowData.statut) {
|
||||
case 'TERMINEE': return 'success';
|
||||
case 'EN_COURS': return 'info';
|
||||
default: return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tag
|
||||
value={rowData.statut}
|
||||
severity={getSeverity()}
|
||||
icon={`pi pi-${rowData.statut === 'TERMINEE' ? 'check' : rowData.statut === 'EN_COURS' ? 'clock' : 'calendar'}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Template pour l'avancement avec style Atlantis
|
||||
const progressBodyTemplate = (rowData: PhaseChantier) => {
|
||||
const progress = rowData.pourcentageAvancement || 0;
|
||||
|
||||
return (
|
||||
<div className="flex align-items-center gap-2">
|
||||
<div className="w-full bg-surface-200 border-round" style={{ height: '8px' }}>
|
||||
<div
|
||||
className="bg-primary border-round h-full transition-all transition-duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-color-secondary min-w-max">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Template pour la validation avec couleurs Atlantis
|
||||
const validationBodyTemplate = (rowData: PhaseChantier) => {
|
||||
const validation = phaseValidationService.validatePhaseStart(rowData, phases);
|
||||
|
||||
const getValidationButton = () => {
|
||||
if (validation.readyToStart) {
|
||||
return (
|
||||
<Button
|
||||
icon="pi pi-check-circle"
|
||||
className="p-button-success p-button-rounded p-button-text"
|
||||
onClick={() => onPhaseValidate?.(rowData)}
|
||||
tooltip="Prête à démarrer"
|
||||
/>
|
||||
);
|
||||
} else if (validation.canStart) {
|
||||
return (
|
||||
<Button
|
||||
icon="pi pi-exclamation-triangle"
|
||||
className="p-button-warning p-button-rounded p-button-text"
|
||||
onClick={() => onPhaseValidate?.(rowData)}
|
||||
tooltip="Peut démarrer avec précautions"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Button
|
||||
icon="pi pi-times-circle"
|
||||
className="p-button-danger p-button-rounded p-button-text"
|
||||
onClick={() => onPhaseValidate?.(rowData)}
|
||||
tooltip="Ne peut pas démarrer"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex align-items-center gap-2">
|
||||
{getValidationButton()}
|
||||
{validation.errors.length > 0 && (
|
||||
<Badge value={validation.errors.length} severity="danger" />
|
||||
)}
|
||||
{validation.warnings.length > 0 && (
|
||||
<Badge value={validation.warnings.length} severity="warning" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Template pour les actions
|
||||
const actionsBodyTemplate = (rowData: PhaseChantier) => {
|
||||
const validation = phaseValidationService.validatePhaseStart(rowData, phases);
|
||||
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
icon="pi pi-play"
|
||||
className="p-button-success p-button-sm"
|
||||
disabled={!validation.canStart || rowData.statut === 'TERMINEE'}
|
||||
onClick={() => onPhaseStart?.(rowData.id!)}
|
||||
tooltip="Démarrer"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-eye"
|
||||
className="p-button-outlined p-button-sm"
|
||||
onClick={() => onPhaseValidate?.(rowData)}
|
||||
tooltip="Détails"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Template pour les dates avec style Atlantis
|
||||
const dateBodyTemplate = (field: string) => (rowData: PhaseChantier) => {
|
||||
const date = rowData[field as keyof PhaseChantier] as string;
|
||||
if (!date) return <span className="text-color-secondary">-</span>;
|
||||
|
||||
const formattedDate = new Date(date).toLocaleDateString('fr-FR');
|
||||
const isOverdue = field.includes('Fin') && rowData.statut !== 'TERMINEE' && new Date(date) < new Date();
|
||||
|
||||
return (
|
||||
<span className={isOverdue ? 'text-red-500 font-semibold' : 'text-color'}>
|
||||
{formattedDate}
|
||||
{isOverdue && <i className="pi pi-exclamation-triangle ml-2 text-red-500" />}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Barre d'outils responsive Atlantis
|
||||
const toolbarStart = (
|
||||
<div className="flex align-items-center gap-2">
|
||||
<h5 className="m-0 text-color">Phases du chantier</h5>
|
||||
{!isDesktop() && (
|
||||
<Badge value={phases.length} className="ml-2" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const toolbarEnd = (
|
||||
<div className="flex align-items-center gap-2">
|
||||
<div className="field-checkbox">
|
||||
<InputSwitch
|
||||
inputId="compactView"
|
||||
checked={compactView}
|
||||
onChange={(e) => setCompactView(e.value)}
|
||||
/>
|
||||
<label htmlFor="compactView" className="ml-2 text-sm">Vue compacte</label>
|
||||
</div>
|
||||
|
||||
<div className="field-checkbox">
|
||||
<InputSwitch
|
||||
inputId="showSubPhases"
|
||||
checked={showSubPhases}
|
||||
onChange={(e) => setShowSubPhases(e.value)}
|
||||
/>
|
||||
<label htmlFor="showSubPhases" className="ml-2 text-sm">Sous-phases</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Filtrer les phases selon les options
|
||||
const filteredPhases = phases.filter(phase => {
|
||||
if (!showSubPhases && phase.phaseParent) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Déterminer les colonnes à afficher selon la taille d'écran
|
||||
const getVisibleColumns = () => {
|
||||
if (compactView) {
|
||||
return ['nom', 'statut', 'pourcentageAvancement', 'actions'];
|
||||
} else if (!isDesktop()) {
|
||||
return ['nom', 'statut', 'pourcentageAvancement', 'validation', 'actions'];
|
||||
} else {
|
||||
return ['nom', 'statut', 'pourcentageAvancement', 'dateDebutPrevue', 'dateFinPrevue', 'validation', 'actions'];
|
||||
}
|
||||
};
|
||||
|
||||
const visibleColumns = getVisibleColumns();
|
||||
|
||||
return (
|
||||
<div className={`card ${className}`}>
|
||||
<Toolbar
|
||||
start={toolbarStart}
|
||||
end={toolbarEnd}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
value={filteredPhases}
|
||||
selection={selectedPhase}
|
||||
onSelectionChange={(e) => setSelectedPhase(e.value)}
|
||||
selectionMode="single"
|
||||
dataKey="id"
|
||||
size={compactView ? 'small' : 'normal'}
|
||||
stripedRows
|
||||
responsiveLayout="scroll"
|
||||
className="datatable-responsive"
|
||||
emptyMessage="Aucune phase trouvée"
|
||||
globalFilter={globalFilter}
|
||||
header={
|
||||
isDesktop() ? (
|
||||
<div className="flex justify-content-between align-items-center">
|
||||
<span className="text-xl font-semibold text-color">
|
||||
Gestion des phases ({filteredPhases.length})
|
||||
</span>
|
||||
<span className="p-input-icon-left">
|
||||
<i className="pi pi-search" />
|
||||
<input
|
||||
type="text"
|
||||
className="p-inputtext p-component"
|
||||
placeholder="Rechercher..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{visibleColumns.includes('nom') && (
|
||||
<Column
|
||||
field="nom"
|
||||
header="Phase"
|
||||
body={nameBodyTemplate}
|
||||
sortable
|
||||
style={{ minWidth: compactView ? '200px' : '250px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{visibleColumns.includes('statut') && (
|
||||
<Column
|
||||
field="statut"
|
||||
header="Statut"
|
||||
body={statusBodyTemplate}
|
||||
sortable
|
||||
style={{ width: '120px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{visibleColumns.includes('pourcentageAvancement') && (
|
||||
<Column
|
||||
field="pourcentageAvancement"
|
||||
header="Avancement"
|
||||
body={progressBodyTemplate}
|
||||
sortable
|
||||
style={{ width: compactView ? '120px' : '150px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{visibleColumns.includes('dateDebutPrevue') && (
|
||||
<Column
|
||||
field="dateDebutPrevue"
|
||||
header="Début prévu"
|
||||
body={dateBodyTemplate('dateDebutPrevue')}
|
||||
sortable
|
||||
style={{ width: '130px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{visibleColumns.includes('dateFinPrevue') && (
|
||||
<Column
|
||||
field="dateFinPrevue"
|
||||
header="Fin prévue"
|
||||
body={dateBodyTemplate('dateFinPrevue')}
|
||||
sortable
|
||||
style={{ width: '130px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{visibleColumns.includes('validation') && (
|
||||
<Column
|
||||
header="Validation"
|
||||
body={validationBodyTemplate}
|
||||
style={{ width: '140px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{visibleColumns.includes('actions') && (
|
||||
<Column
|
||||
header="Actions"
|
||||
body={actionsBodyTemplate}
|
||||
exportable={false}
|
||||
style={{ width: '100px' }}
|
||||
/>
|
||||
)}
|
||||
</DataTable>
|
||||
|
||||
{/* Informations sur la phase sélectionnée - Style Atlantis */}
|
||||
{selectedPhase && (
|
||||
<div className="card mt-3">
|
||||
<div className="card-header">
|
||||
<h6 className="m-0">Phase sélectionnée</h6>
|
||||
</div>
|
||||
<p className="m-0 mt-2">
|
||||
<strong>{selectedPhase.nom}</strong> - {selectedPhase.statut} -
|
||||
{selectedPhase.pourcentageAvancement || 0}% d'avancement
|
||||
</p>
|
||||
{selectedPhase.description && (
|
||||
<p className="mt-2 mb-0 text-color-secondary">{selectedPhase.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AtlantisResponsivePhasesTable;
|
||||
584
components/phases/BudgetExecutionDialog.tsx
Normal file
584
components/phases/BudgetExecutionDialog.tsx
Normal file
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* Dialog d'exécution budgétaire pour le suivi des dépenses réelles
|
||||
* Permet la comparaison budget prévu vs coût réel avec analyse des écarts
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Button } from 'primereact/button';
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Card } from 'primereact/card';
|
||||
import { TabView, TabPanel } from 'primereact/tabview';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { Divider } from 'primereact/divider';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Calendar } from 'primereact/calendar';
|
||||
import { Chart } from 'primereact/chart';
|
||||
import { PhaseChantier } from '../../types/btp-extended';
|
||||
|
||||
interface DepenseReelle {
|
||||
id?: string;
|
||||
date: string;
|
||||
categorie: 'MATERIEL' | 'MAIN_OEUVRE' | 'SOUS_TRAITANCE' | 'TRANSPORT' | 'AUTRES';
|
||||
designation: string;
|
||||
montant: number;
|
||||
fournisseur?: string;
|
||||
numeroPiece?: string; // Numéro de facture, bon de commande, etc.
|
||||
notes?: string;
|
||||
valide: boolean;
|
||||
validePar?: string;
|
||||
dateValidation?: string;
|
||||
}
|
||||
|
||||
interface AnalyseEcart {
|
||||
categorieId: string;
|
||||
categorieNom: string;
|
||||
budgetPrevu: number;
|
||||
depenseReelle: number;
|
||||
ecart: number;
|
||||
ecartPourcentage: number;
|
||||
statut: 'CONFORME' | 'ALERTE' | 'DEPASSEMENT';
|
||||
}
|
||||
|
||||
interface BudgetExecutionDialogProps {
|
||||
visible: boolean;
|
||||
onHide: () => void;
|
||||
phase: PhaseChantier | null;
|
||||
onSave: (executionData: any) => void;
|
||||
}
|
||||
|
||||
export const BudgetExecutionDialog: React.FC<BudgetExecutionDialogProps> = ({
|
||||
visible,
|
||||
onHide,
|
||||
phase,
|
||||
onSave
|
||||
}) => {
|
||||
const toast = useRef<Toast>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [depenses, setDepenses] = useState<DepenseReelle[]>([]);
|
||||
const [nouvelleDepense, setNouvelleDepense] = useState<DepenseReelle>({
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
categorie: 'MATERIEL',
|
||||
designation: '',
|
||||
montant: 0,
|
||||
valide: false
|
||||
});
|
||||
|
||||
const categories = [
|
||||
{ label: 'Matériel', value: 'MATERIEL', color: '#007ad9' },
|
||||
{ label: 'Main d\'œuvre', value: 'MAIN_OEUVRE', color: '#22c55e' },
|
||||
{ label: 'Sous-traitance', value: 'SOUS_TRAITANCE', color: '#f97316' },
|
||||
{ label: 'Transport', value: 'TRANSPORT', color: '#8b5cf6' },
|
||||
{ label: 'Autres', value: 'AUTRES', color: '#6b7280' }
|
||||
];
|
||||
|
||||
// Simuler le budget prévu par catégorie (normalement récupéré de l'analyse budgétaire)
|
||||
const budgetParCategorie = {
|
||||
MATERIEL: phase?.budgetPrevu ? phase.budgetPrevu * 0.4 : 0,
|
||||
MAIN_OEUVRE: phase?.budgetPrevu ? phase.budgetPrevu * 0.3 : 0,
|
||||
SOUS_TRAITANCE: phase?.budgetPrevu ? phase.budgetPrevu * 0.2 : 0,
|
||||
TRANSPORT: phase?.budgetPrevu ? phase.budgetPrevu * 0.05 : 0,
|
||||
AUTRES: phase?.budgetPrevu ? phase.budgetPrevu * 0.05 : 0
|
||||
};
|
||||
|
||||
// Charger les dépenses existantes au montage du composant
|
||||
useEffect(() => {
|
||||
if (phase && visible) {
|
||||
loadDepenses();
|
||||
}
|
||||
}, [phase, visible]);
|
||||
|
||||
const loadDepenses = async () => {
|
||||
// Simuler le chargement des dépenses depuis l'API
|
||||
// En réalité, ceci ferait appel à une API
|
||||
const depensesSimulees: DepenseReelle[] = [
|
||||
{
|
||||
id: '1',
|
||||
date: '2025-01-15',
|
||||
categorie: 'MATERIEL',
|
||||
designation: 'Béton C25/30',
|
||||
montant: 1500,
|
||||
fournisseur: 'Béton Express',
|
||||
numeroPiece: 'FC-2025-001',
|
||||
valide: true,
|
||||
validePar: 'Chef de projet',
|
||||
dateValidation: '2025-01-16'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
date: '2025-01-20',
|
||||
categorie: 'MAIN_OEUVRE',
|
||||
designation: 'Équipe de maçonnerie - 2 jours',
|
||||
montant: 800,
|
||||
numeroPiece: 'TS-2025-005',
|
||||
valide: true,
|
||||
validePar: 'Chef de projet',
|
||||
dateValidation: '2025-01-21'
|
||||
}
|
||||
];
|
||||
setDepenses(depensesSimulees);
|
||||
};
|
||||
|
||||
// Ajouter une nouvelle dépense
|
||||
const ajouterDepense = () => {
|
||||
if (!nouvelleDepense.designation.trim() || nouvelleDepense.montant <= 0) {
|
||||
toast.current?.show({
|
||||
severity: 'warn',
|
||||
summary: 'Champs requis',
|
||||
detail: 'Veuillez remplir la désignation et le montant',
|
||||
life: 3000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nouvelleDepenseAvecId = {
|
||||
...nouvelleDepense,
|
||||
id: `dep_${Date.now()}`
|
||||
};
|
||||
|
||||
setDepenses([...depenses, nouvelleDepenseAvecId]);
|
||||
|
||||
// Réinitialiser le formulaire
|
||||
setNouvelleDepense({
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
categorie: 'MATERIEL',
|
||||
designation: '',
|
||||
montant: 0,
|
||||
valide: false
|
||||
});
|
||||
|
||||
toast.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Dépense ajoutée',
|
||||
detail: 'La dépense a été enregistrée',
|
||||
life: 3000
|
||||
});
|
||||
};
|
||||
|
||||
// Valider une dépense
|
||||
const validerDepense = (depenseId: string) => {
|
||||
setDepenses(depenses.map(d =>
|
||||
d.id === depenseId
|
||||
? {
|
||||
...d,
|
||||
valide: true,
|
||||
validePar: 'Utilisateur actuel',
|
||||
dateValidation: new Date().toISOString()
|
||||
}
|
||||
: d
|
||||
));
|
||||
};
|
||||
|
||||
// Supprimer une dépense
|
||||
const supprimerDepense = (depenseId: string) => {
|
||||
setDepenses(depenses.filter(d => d.id !== depenseId));
|
||||
};
|
||||
|
||||
// Calculer l'analyse des écarts
|
||||
const getAnalyseEcarts = (): AnalyseEcart[] => {
|
||||
return categories.map(cat => {
|
||||
const budgetPrevu = budgetParCategorie[cat.value as keyof typeof budgetParCategorie];
|
||||
const depenseReelle = depenses
|
||||
.filter(d => d.categorie === cat.value && d.valide)
|
||||
.reduce((sum, d) => sum + d.montant, 0);
|
||||
|
||||
const ecart = depenseReelle - budgetPrevu;
|
||||
const ecartPourcentage = budgetPrevu > 0 ? (ecart / budgetPrevu) * 100 : 0;
|
||||
|
||||
let statut: 'CONFORME' | 'ALERTE' | 'DEPASSEMENT' = 'CONFORME';
|
||||
if (ecartPourcentage > 10) statut = 'DEPASSEMENT';
|
||||
else if (ecartPourcentage > 5) statut = 'ALERTE';
|
||||
|
||||
return {
|
||||
categorieId: cat.value,
|
||||
categorieNom: cat.label,
|
||||
budgetPrevu,
|
||||
depenseReelle,
|
||||
ecart,
|
||||
ecartPourcentage,
|
||||
statut
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Préparer les données pour le graphique
|
||||
const getChartData = () => {
|
||||
const analyses = getAnalyseEcarts();
|
||||
return {
|
||||
labels: analyses.map(a => a.categorieNom),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Budget prévu',
|
||||
data: analyses.map(a => a.budgetPrevu),
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.6)',
|
||||
borderColor: 'rgba(54, 162, 235, 1)',
|
||||
borderWidth: 1
|
||||
},
|
||||
{
|
||||
label: 'Dépense réelle',
|
||||
data: analyses.map(a => a.depenseReelle),
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.6)',
|
||||
borderColor: 'rgba(255, 99, 132, 1)',
|
||||
borderWidth: 1
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Budget prévu vs Dépenses réelles'
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: function(value: any) {
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0
|
||||
}).format(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Templates pour le DataTable
|
||||
const categorieTemplate = (rowData: DepenseReelle) => {
|
||||
const category = categories.find(cat => cat.value === rowData.categorie);
|
||||
const severityMap = {
|
||||
'MATERIEL': 'info',
|
||||
'MAIN_OEUVRE': 'success',
|
||||
'SOUS_TRAITANCE': 'warning',
|
||||
'TRANSPORT': 'help',
|
||||
'AUTRES': 'secondary'
|
||||
} as const;
|
||||
|
||||
return <Tag value={category?.label} severity={severityMap[rowData.categorie]} />;
|
||||
};
|
||||
|
||||
const montantTemplate = (rowData: DepenseReelle) => {
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(rowData.montant);
|
||||
};
|
||||
|
||||
const validationTemplate = (rowData: DepenseReelle) => {
|
||||
return rowData.valide ? (
|
||||
<Tag value="Validé" severity="success" icon="pi pi-check" />
|
||||
) : (
|
||||
<Tag value="En attente" severity="warning" icon="pi pi-clock" />
|
||||
);
|
||||
};
|
||||
|
||||
const actionsTemplate = (rowData: DepenseReelle) => {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
{!rowData.valide && (
|
||||
<Button
|
||||
icon="pi pi-check"
|
||||
className="p-button-success p-button-text p-button-sm"
|
||||
tooltip="Valider"
|
||||
onClick={() => validerDepense(rowData.id!)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
className="p-button-danger p-button-text p-button-sm"
|
||||
tooltip="Supprimer"
|
||||
onClick={() => supprimerDepense(rowData.id!)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const analysesEcarts = getAnalyseEcarts();
|
||||
const totalBudgetPrevu = analysesEcarts.reduce((sum, a) => sum + a.budgetPrevu, 0);
|
||||
const totalDepenseReelle = analysesEcarts.reduce((sum, a) => sum + a.depenseReelle, 0);
|
||||
const ecartTotal = totalDepenseReelle - totalBudgetPrevu;
|
||||
const ecartTotalPourcentage = totalBudgetPrevu > 0 ? (ecartTotal / totalBudgetPrevu) * 100 : 0;
|
||||
|
||||
const dialogFooter = (
|
||||
<div className="flex justify-content-between">
|
||||
<Button
|
||||
label="Fermer"
|
||||
icon="pi pi-times"
|
||||
onClick={onHide}
|
||||
className="p-button-text"
|
||||
/>
|
||||
<Button
|
||||
label="Enregistrer l'exécution"
|
||||
icon="pi pi-save"
|
||||
onClick={() => {
|
||||
const executionData = {
|
||||
depenses: depenses.filter(d => d.valide),
|
||||
analyse: analysesEcarts,
|
||||
coutTotal: totalDepenseReelle,
|
||||
ecartTotal,
|
||||
ecartPourcentage: ecartTotalPourcentage,
|
||||
dateAnalyse: new Date().toISOString()
|
||||
};
|
||||
onSave(executionData);
|
||||
onHide();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toast ref={toast} />
|
||||
<Dialog
|
||||
header={`Exécution budgétaire - ${phase?.nom || 'Phase'}`}
|
||||
visible={visible}
|
||||
onHide={onHide}
|
||||
footer={dialogFooter}
|
||||
style={{ width: '95vw', maxWidth: '1200px' }}
|
||||
modal
|
||||
maximizable
|
||||
>
|
||||
<TabView activeIndex={activeIndex} onTabChange={(e) => setActiveIndex(e.index)}>
|
||||
<TabPanel header="Saisie des dépenses" leftIcon="pi pi-plus">
|
||||
<div className="grid">
|
||||
{/* Formulaire d'ajout de dépense */}
|
||||
<div className="col-12">
|
||||
<Card title="Ajouter une dépense" className="mb-4">
|
||||
<div className="grid">
|
||||
<div className="col-12 md:col-2">
|
||||
<label htmlFor="dateDepense" className="font-semibold">Date</label>
|
||||
<Calendar
|
||||
id="dateDepense"
|
||||
value={nouvelleDepense.date ? new Date(nouvelleDepense.date) : null}
|
||||
onChange={(e) => setNouvelleDepense({
|
||||
...nouvelleDepense,
|
||||
date: e.value ? e.value.toISOString().split('T')[0] : ''
|
||||
})}
|
||||
className="w-full"
|
||||
dateFormat="dd/mm/yy"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 md:col-2">
|
||||
<label htmlFor="categorieDepense" className="font-semibold">Catégorie</label>
|
||||
<Dropdown
|
||||
id="categorieDepense"
|
||||
value={nouvelleDepense.categorie}
|
||||
options={categories}
|
||||
onChange={(e) => setNouvelleDepense({...nouvelleDepense, categorie: e.value})}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 md:col-4">
|
||||
<label htmlFor="designationDepense" className="font-semibold">Désignation</label>
|
||||
<InputText
|
||||
id="designationDepense"
|
||||
value={nouvelleDepense.designation}
|
||||
onChange={(e) => setNouvelleDepense({...nouvelleDepense, designation: e.target.value})}
|
||||
className="w-full"
|
||||
placeholder="Description de la dépense"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 md:col-2">
|
||||
<label htmlFor="montantDepense" className="font-semibold">Montant (€)</label>
|
||||
<InputNumber
|
||||
id="montantDepense"
|
||||
value={nouvelleDepense.montant}
|
||||
onValueChange={(e) => setNouvelleDepense({...nouvelleDepense, montant: e.value || 0})}
|
||||
className="w-full"
|
||||
mode="currency"
|
||||
currency="EUR"
|
||||
locale="fr-FR"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 md:col-2">
|
||||
<label className="font-semibold"> </label>
|
||||
<Button
|
||||
icon="pi pi-plus"
|
||||
onClick={ajouterDepense}
|
||||
className="w-full"
|
||||
tooltip="Ajouter cette dépense"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid mt-3">
|
||||
<div className="col-12 md:col-4">
|
||||
<label htmlFor="fournisseurDepense" className="font-semibold">Fournisseur (optionnel)</label>
|
||||
<InputText
|
||||
id="fournisseurDepense"
|
||||
value={nouvelleDepense.fournisseur || ''}
|
||||
onChange={(e) => setNouvelleDepense({...nouvelleDepense, fournisseur: e.target.value})}
|
||||
className="w-full"
|
||||
placeholder="Nom du fournisseur"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 md:col-3">
|
||||
<label htmlFor="numeroPiece" className="font-semibold">N° pièce</label>
|
||||
<InputText
|
||||
id="numeroPiece"
|
||||
value={nouvelleDepense.numeroPiece || ''}
|
||||
onChange={(e) => setNouvelleDepense({...nouvelleDepense, numeroPiece: e.target.value})}
|
||||
className="w-full"
|
||||
placeholder="N° facture, bon..."
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 md:col-5">
|
||||
<label htmlFor="notesDepense" className="font-semibold">Notes</label>
|
||||
<InputText
|
||||
id="notesDepense"
|
||||
value={nouvelleDepense.notes || ''}
|
||||
onChange={(e) => setNouvelleDepense({...nouvelleDepense, notes: e.target.value})}
|
||||
className="w-full"
|
||||
placeholder="Notes supplémentaires"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Liste des dépenses */}
|
||||
<div className="col-12">
|
||||
<DataTable
|
||||
value={depenses}
|
||||
emptyMessage="Aucune dépense enregistrée"
|
||||
size="small"
|
||||
header="Dépenses enregistrées"
|
||||
>
|
||||
<Column field="date" header="Date" style={{ width: '8rem' }} />
|
||||
<Column field="categorie" header="Catégorie" body={categorieTemplate} style={{ width: '10rem' }} />
|
||||
<Column field="designation" header="Désignation" style={{ minWidth: '15rem' }} />
|
||||
<Column field="montant" header="Montant" body={montantTemplate} style={{ width: '8rem' }} />
|
||||
<Column field="fournisseur" header="Fournisseur" style={{ width: '10rem' }} />
|
||||
<Column field="numeroPiece" header="N° pièce" style={{ width: '8rem' }} />
|
||||
<Column field="valide" header="Statut" body={validationTemplate} style={{ width: '8rem' }} />
|
||||
<Column header="Actions" body={actionsTemplate} style={{ width: '8rem' }} />
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel header="Analyse des écarts" leftIcon="pi pi-chart-line">
|
||||
<div className="grid">
|
||||
<div className="col-12 lg:col-8">
|
||||
<Card title="Comparaison budget/réalisé">
|
||||
<div style={{ height: '300px' }}>
|
||||
<Chart type="bar" data={getChartData()} options={chartOptions} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="col-12 lg:col-4">
|
||||
<Card title="Synthèse globale">
|
||||
<div className="flex justify-content-between align-items-center mb-3">
|
||||
<span>Budget total prévu:</span>
|
||||
<span className="font-semibold">
|
||||
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(totalBudgetPrevu)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-content-between align-items-center mb-3">
|
||||
<span>Dépenses réelles:</span>
|
||||
<span className="font-semibold">
|
||||
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(totalDepenseReelle)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="flex justify-content-between align-items-center mb-2">
|
||||
<span>Écart total:</span>
|
||||
<span className={`font-bold ${ecartTotal > 0 ? 'text-red-500' : 'text-green-500'}`}>
|
||||
{ecartTotal > 0 ? '+' : ''}{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(ecartTotal)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-content-between align-items-center mb-3">
|
||||
<span>Écart (%):</span>
|
||||
<span className={`font-bold ${ecartTotalPourcentage > 0 ? 'text-red-500' : 'text-green-500'}`}>
|
||||
{ecartTotalPourcentage > 0 ? '+' : ''}{ecartTotalPourcentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
value={totalBudgetPrevu > 0 ? (totalDepenseReelle / totalBudgetPrevu) * 100 : 0}
|
||||
className="mb-2"
|
||||
color={ecartTotalPourcentage > 10 ? '#dc3545' : ecartTotalPourcentage > 5 ? '#ffc107' : '#22c55e'}
|
||||
/>
|
||||
|
||||
<small className="text-color-secondary">
|
||||
Taux de consommation budgétaire
|
||||
</small>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Détail par catégorie */}
|
||||
<div className="col-12">
|
||||
<Card title="Analyse détaillée par catégorie">
|
||||
<DataTable value={analysesEcarts} size="small">
|
||||
<Column field="categorieNom" header="Catégorie" />
|
||||
<Column
|
||||
field="budgetPrevu"
|
||||
header="Budget prévu"
|
||||
body={(rowData) => new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(rowData.budgetPrevu)}
|
||||
/>
|
||||
<Column
|
||||
field="depenseReelle"
|
||||
header="Dépense réelle"
|
||||
body={(rowData) => new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(rowData.depenseReelle)}
|
||||
/>
|
||||
<Column
|
||||
field="ecart"
|
||||
header="Écart"
|
||||
body={(rowData) => (
|
||||
<span className={rowData.ecart > 0 ? 'text-red-500 font-semibold' : 'text-green-500'}>
|
||||
{rowData.ecart > 0 ? '+' : ''}{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(rowData.ecart)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<Column
|
||||
field="ecartPourcentage"
|
||||
header="Écart %"
|
||||
body={(rowData) => (
|
||||
<span className={rowData.ecartPourcentage > 10 ? 'text-red-500 font-semibold' : rowData.ecartPourcentage > 5 ? 'text-orange-500' : 'text-green-500'}>
|
||||
{rowData.ecartPourcentage > 0 ? '+' : ''}{rowData.ecartPourcentage.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<Column
|
||||
field="statut"
|
||||
header="Statut"
|
||||
body={(rowData) => {
|
||||
const severityMap = {
|
||||
'CONFORME': 'success',
|
||||
'ALERTE': 'warning',
|
||||
'DEPASSEMENT': 'danger'
|
||||
} as const;
|
||||
return <Tag value={rowData.statut} severity={severityMap[rowData.statut]} />;
|
||||
}}
|
||||
/>
|
||||
</DataTable>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BudgetExecutionDialog;
|
||||
528
components/phases/BudgetPlanningDialog.tsx
Normal file
528
components/phases/BudgetPlanningDialog.tsx
Normal file
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* Dialog de planification budgétaire avancée pour les phases
|
||||
* Permet l'estimation détaillée des coûts par catégorie
|
||||
*/
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Button } from 'primereact/button';
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Card } from 'primereact/card';
|
||||
import { TabView, TabPanel } from 'primereact/tabview';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { Divider } from 'primereact/divider';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { PhaseChantier } from '../../types/btp-extended';
|
||||
|
||||
interface BudgetItem {
|
||||
id?: string;
|
||||
categorie: 'MATERIEL' | 'MAIN_OEUVRE' | 'SOUS_TRAITANCE' | 'TRANSPORT' | 'AUTRES';
|
||||
designation: string;
|
||||
quantite: number;
|
||||
unite: string;
|
||||
prixUnitaire: number;
|
||||
montantHT: number;
|
||||
tauxTVA: number;
|
||||
montantTTC: number;
|
||||
fournisseur?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
interface BudgetAnalysis {
|
||||
totalMateriel: number;
|
||||
totalMainOeuvre: number;
|
||||
totalSousTraitance: number;
|
||||
totalTransport: number;
|
||||
totalAutres: number;
|
||||
totalHT: number;
|
||||
totalTVA: number;
|
||||
totalTTC: number;
|
||||
margeObjectif: number;
|
||||
tauxMarge: number;
|
||||
prixVenteCalcule: number;
|
||||
}
|
||||
|
||||
interface BudgetPlanningDialogProps {
|
||||
visible: boolean;
|
||||
onHide: () => void;
|
||||
phase: PhaseChantier | null;
|
||||
onSave: (budgetData: BudgetAnalysis) => void;
|
||||
}
|
||||
|
||||
export const BudgetPlanningDialog: React.FC<BudgetPlanningDialogProps> = ({
|
||||
visible,
|
||||
onHide,
|
||||
phase,
|
||||
onSave
|
||||
}) => {
|
||||
const toast = useRef<Toast>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [budgetItems, setBudgetItems] = useState<BudgetItem[]>([]);
|
||||
const [newItem, setNewItem] = useState<BudgetItem>({
|
||||
categorie: 'MATERIEL',
|
||||
designation: '',
|
||||
quantite: 1,
|
||||
unite: 'unité',
|
||||
prixUnitaire: 0,
|
||||
montantHT: 0,
|
||||
tauxTVA: 20,
|
||||
montantTTC: 0
|
||||
});
|
||||
const [margeObjectif, setMargeObjectif] = useState(15); // 15% par défaut
|
||||
|
||||
const categories = [
|
||||
{ label: 'Matériel', value: 'MATERIEL' },
|
||||
{ label: 'Main d\'œuvre', value: 'MAIN_OEUVRE' },
|
||||
{ label: 'Sous-traitance', value: 'SOUS_TRAITANCE' },
|
||||
{ label: 'Transport', value: 'TRANSPORT' },
|
||||
{ label: 'Autres', value: 'AUTRES' }
|
||||
];
|
||||
|
||||
const unites = [
|
||||
{ label: 'Unité', value: 'unité' },
|
||||
{ label: 'Heure', value: 'h' },
|
||||
{ label: 'Jour', value: 'j' },
|
||||
{ label: 'Mètre', value: 'm' },
|
||||
{ label: 'Mètre carré', value: 'm²' },
|
||||
{ label: 'Mètre cube', value: 'm³' },
|
||||
{ label: 'Kilogramme', value: 'kg' },
|
||||
{ label: 'Tonne', value: 't' },
|
||||
{ label: 'Forfait', value: 'forfait' }
|
||||
];
|
||||
|
||||
// Calculer automatiquement les montants
|
||||
const calculateAmounts = (item: BudgetItem) => {
|
||||
const montantHT = item.quantite * item.prixUnitaire;
|
||||
const montantTVA = montantHT * (item.tauxTVA / 100);
|
||||
const montantTTC = montantHT + montantTVA;
|
||||
|
||||
return {
|
||||
...item,
|
||||
montantHT,
|
||||
montantTTC
|
||||
};
|
||||
};
|
||||
|
||||
// Ajouter un nouvel élément au budget
|
||||
const addBudgetItem = () => {
|
||||
if (!newItem.designation.trim()) {
|
||||
toast.current?.show({
|
||||
severity: 'warn',
|
||||
summary: 'Champ requis',
|
||||
detail: 'Veuillez saisir une désignation',
|
||||
life: 3000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const calculatedItem = calculateAmounts({
|
||||
...newItem,
|
||||
id: `budget_${Date.now()}`
|
||||
});
|
||||
|
||||
setBudgetItems([...budgetItems, calculatedItem]);
|
||||
setNewItem({
|
||||
categorie: 'MATERIEL',
|
||||
designation: '',
|
||||
quantite: 1,
|
||||
unite: 'unité',
|
||||
prixUnitaire: 0,
|
||||
montantHT: 0,
|
||||
tauxTVA: 20,
|
||||
montantTTC: 0
|
||||
});
|
||||
};
|
||||
|
||||
// Supprimer un élément du budget
|
||||
const removeBudgetItem = (itemId: string) => {
|
||||
setBudgetItems(budgetItems.filter(item => item.id !== itemId));
|
||||
};
|
||||
|
||||
// Calculer l'analyse budgétaire
|
||||
const getBudgetAnalysis = (): BudgetAnalysis => {
|
||||
const totalMateriel = budgetItems
|
||||
.filter(item => item.categorie === 'MATERIEL')
|
||||
.reduce((sum, item) => sum + item.montantHT, 0);
|
||||
|
||||
const totalMainOeuvre = budgetItems
|
||||
.filter(item => item.categorie === 'MAIN_OEUVRE')
|
||||
.reduce((sum, item) => sum + item.montantHT, 0);
|
||||
|
||||
const totalSousTraitance = budgetItems
|
||||
.filter(item => item.categorie === 'SOUS_TRAITANCE')
|
||||
.reduce((sum, item) => sum + item.montantHT, 0);
|
||||
|
||||
const totalTransport = budgetItems
|
||||
.filter(item => item.categorie === 'TRANSPORT')
|
||||
.reduce((sum, item) => sum + item.montantHT, 0);
|
||||
|
||||
const totalAutres = budgetItems
|
||||
.filter(item => item.categorie === 'AUTRES')
|
||||
.reduce((sum, item) => sum + item.montantHT, 0);
|
||||
|
||||
const totalHT = totalMateriel + totalMainOeuvre + totalSousTraitance + totalTransport + totalAutres;
|
||||
const totalTVA = budgetItems.reduce((sum, item) => sum + (item.montantHT * item.tauxTVA / 100), 0);
|
||||
const totalTTC = totalHT + totalTVA;
|
||||
|
||||
const montantMarge = totalHT * (margeObjectif / 100);
|
||||
const prixVenteCalcule = totalHT + montantMarge;
|
||||
|
||||
return {
|
||||
totalMateriel,
|
||||
totalMainOeuvre,
|
||||
totalSousTraitance,
|
||||
totalTransport,
|
||||
totalAutres,
|
||||
totalHT,
|
||||
totalTVA,
|
||||
totalTTC,
|
||||
margeObjectif: montantMarge,
|
||||
tauxMarge: margeObjectif,
|
||||
prixVenteCalcule
|
||||
};
|
||||
};
|
||||
|
||||
// Template pour afficher la catégorie
|
||||
const categorieTemplate = (rowData: BudgetItem) => {
|
||||
const category = categories.find(cat => cat.value === rowData.categorie);
|
||||
const severityMap = {
|
||||
'MATERIEL': 'info',
|
||||
'MAIN_OEUVRE': 'success',
|
||||
'SOUS_TRAITANCE': 'warning',
|
||||
'TRANSPORT': 'help',
|
||||
'AUTRES': 'secondary'
|
||||
} as const;
|
||||
|
||||
return <Tag value={category?.label} severity={severityMap[rowData.categorie]} />;
|
||||
};
|
||||
|
||||
// Template pour afficher les montants
|
||||
const montantTemplate = (rowData: BudgetItem, field: keyof BudgetItem) => {
|
||||
const value = rowData[field] as number;
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
// Footer du dialog
|
||||
const dialogFooter = (
|
||||
<div className="flex justify-content-between">
|
||||
<Button
|
||||
label="Annuler"
|
||||
icon="pi pi-times"
|
||||
onClick={onHide}
|
||||
className="p-button-text"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
label="Réinitialiser"
|
||||
icon="pi pi-refresh"
|
||||
onClick={() => setBudgetItems([])}
|
||||
className="p-button-outlined"
|
||||
/>
|
||||
<Button
|
||||
label="Enregistrer le budget"
|
||||
icon="pi pi-check"
|
||||
onClick={() => {
|
||||
const analysis = getBudgetAnalysis();
|
||||
onSave(analysis);
|
||||
onHide();
|
||||
}}
|
||||
disabled={budgetItems.length === 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const analysis = getBudgetAnalysis();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toast ref={toast} />
|
||||
<Dialog
|
||||
header={`Planification budgétaire - ${phase?.nom || 'Phase'}`}
|
||||
visible={visible}
|
||||
onHide={onHide}
|
||||
footer={dialogFooter}
|
||||
style={{ width: '95vw', maxWidth: '1200px' }}
|
||||
modal
|
||||
maximizable
|
||||
>
|
||||
<TabView activeIndex={activeIndex} onTabChange={(e) => setActiveIndex(e.index)}>
|
||||
<TabPanel header="Saisie des coûts" leftIcon="pi pi-plus">
|
||||
<div className="grid">
|
||||
{/* Formulaire d'ajout */}
|
||||
<div className="col-12">
|
||||
<Card title="Ajouter un élément de coût" className="mb-4">
|
||||
<div className="grid">
|
||||
<div className="col-12 md:col-3">
|
||||
<label htmlFor="categorie" className="font-semibold">Catégorie</label>
|
||||
<Dropdown
|
||||
id="categorie"
|
||||
value={newItem.categorie}
|
||||
options={categories}
|
||||
onChange={(e) => setNewItem({...newItem, categorie: e.value})}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 md:col-4">
|
||||
<label htmlFor="designation" className="font-semibold">Désignation</label>
|
||||
<InputText
|
||||
id="designation"
|
||||
value={newItem.designation}
|
||||
onChange={(e) => setNewItem({...newItem, designation: e.target.value})}
|
||||
className="w-full"
|
||||
placeholder="Ex: Béton C25/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 md:col-2">
|
||||
<label htmlFor="quantite" className="font-semibold">Quantité</label>
|
||||
<InputNumber
|
||||
id="quantite"
|
||||
value={newItem.quantite}
|
||||
onValueChange={(e) => setNewItem({...newItem, quantite: e.value || 1})}
|
||||
className="w-full"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 md:col-2">
|
||||
<label htmlFor="unite" className="font-semibold">Unité</label>
|
||||
<Dropdown
|
||||
id="unite"
|
||||
value={newItem.unite}
|
||||
options={unites}
|
||||
onChange={(e) => setNewItem({...newItem, unite: e.value})}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 md:col-1">
|
||||
<label className="font-semibold"> </label>
|
||||
<Button
|
||||
icon="pi pi-plus"
|
||||
onClick={addBudgetItem}
|
||||
className="w-full"
|
||||
tooltip="Ajouter cet élément"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid mt-3">
|
||||
<div className="col-12 md:col-3">
|
||||
<label htmlFor="prixUnitaire" className="font-semibold">Prix unitaire HT (€)</label>
|
||||
<InputNumber
|
||||
id="prixUnitaire"
|
||||
value={newItem.prixUnitaire}
|
||||
onValueChange={(e) => setNewItem({...newItem, prixUnitaire: e.value || 0})}
|
||||
className="w-full"
|
||||
mode="currency"
|
||||
currency="EUR"
|
||||
locale="fr-FR"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 md:col-2">
|
||||
<label htmlFor="tauxTVA" className="font-semibold">TVA (%)</label>
|
||||
<InputNumber
|
||||
id="tauxTVA"
|
||||
value={newItem.tauxTVA}
|
||||
onValueChange={(e) => setNewItem({...newItem, tauxTVA: e.value || 20})}
|
||||
className="w-full"
|
||||
suffix="%"
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 md:col-3">
|
||||
<label htmlFor="fournisseur" className="font-semibold">Fournisseur (optionnel)</label>
|
||||
<InputText
|
||||
id="fournisseur"
|
||||
value={newItem.fournisseur || ''}
|
||||
onChange={(e) => setNewItem({...newItem, fournisseur: e.target.value})}
|
||||
className="w-full"
|
||||
placeholder="Nom du fournisseur"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 md:col-4">
|
||||
<label htmlFor="notes" className="font-semibold">Notes (optionnel)</label>
|
||||
<InputText
|
||||
id="notes"
|
||||
value={newItem.notes || ''}
|
||||
onChange={(e) => setNewItem({...newItem, notes: e.target.value})}
|
||||
className="w-full"
|
||||
placeholder="Notes supplémentaires"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Liste des éléments */}
|
||||
<div className="col-12">
|
||||
<DataTable
|
||||
value={budgetItems}
|
||||
emptyMessage="Aucun élément de coût ajouté"
|
||||
size="small"
|
||||
header="Éléments du budget"
|
||||
>
|
||||
<Column field="categorie" header="Catégorie" body={categorieTemplate} style={{ width: '10rem' }} />
|
||||
<Column field="designation" header="Désignation" style={{ minWidth: '15rem' }} />
|
||||
<Column field="quantite" header="Qté" style={{ width: '6rem' }} />
|
||||
<Column field="unite" header="Unité" style={{ width: '6rem' }} />
|
||||
<Column
|
||||
field="prixUnitaire"
|
||||
header="Prix unit. HT"
|
||||
body={(rowData) => montantTemplate(rowData, 'prixUnitaire')}
|
||||
style={{ width: '8rem' }}
|
||||
/>
|
||||
<Column
|
||||
field="montantHT"
|
||||
header="Montant HT"
|
||||
body={(rowData) => montantTemplate(rowData, 'montantHT')}
|
||||
style={{ width: '8rem' }}
|
||||
/>
|
||||
<Column
|
||||
field="montantTTC"
|
||||
header="Montant TTC"
|
||||
body={(rowData) => montantTemplate(rowData, 'montantTTC')}
|
||||
style={{ width: '8rem' }}
|
||||
/>
|
||||
<Column
|
||||
header="Actions"
|
||||
style={{ width: '6rem' }}
|
||||
body={(rowData) => (
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
className="p-button-text p-button-danger"
|
||||
onClick={() => removeBudgetItem(rowData.id)}
|
||||
tooltip="Supprimer"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel header="Analyse budgétaire" leftIcon="pi pi-chart-bar">
|
||||
<div className="grid">
|
||||
<div className="col-12 lg:col-8">
|
||||
<Card title="Répartition des coûts">
|
||||
<div className="grid">
|
||||
<div className="col-6 md:col-3">
|
||||
<div className="text-center">
|
||||
<h6 className="m-0 text-color-secondary">Matériel</h6>
|
||||
<span className="text-xl font-semibold text-primary">
|
||||
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(analysis.totalMateriel)}
|
||||
</span>
|
||||
<ProgressBar
|
||||
value={analysis.totalHT > 0 ? (analysis.totalMateriel / analysis.totalHT) * 100 : 0}
|
||||
className="mt-2"
|
||||
color="#007ad9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-6 md:col-3">
|
||||
<div className="text-center">
|
||||
<h6 className="m-0 text-color-secondary">Main d'œuvre</h6>
|
||||
<span className="text-xl font-semibold text-green-500">
|
||||
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(analysis.totalMainOeuvre)}
|
||||
</span>
|
||||
<ProgressBar
|
||||
value={analysis.totalHT > 0 ? (analysis.totalMainOeuvre / analysis.totalHT) * 100 : 0}
|
||||
className="mt-2"
|
||||
color="#22c55e"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-6 md:col-3">
|
||||
<div className="text-center">
|
||||
<h6 className="m-0 text-color-secondary">Sous-traitance</h6>
|
||||
<span className="text-xl font-semibold text-orange-500">
|
||||
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(analysis.totalSousTraitance)}
|
||||
</span>
|
||||
<ProgressBar
|
||||
value={analysis.totalHT > 0 ? (analysis.totalSousTraitance / analysis.totalHT) * 100 : 0}
|
||||
className="mt-2"
|
||||
color="#f97316"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-6 md:col-3">
|
||||
<div className="text-center">
|
||||
<h6 className="m-0 text-color-secondary">Transport + Autres</h6>
|
||||
<span className="text-xl font-semibold text-purple-500">
|
||||
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(analysis.totalTransport + analysis.totalAutres)}
|
||||
</span>
|
||||
<ProgressBar
|
||||
value={analysis.totalHT > 0 ? ((analysis.totalTransport + analysis.totalAutres) / analysis.totalHT) * 100 : 0}
|
||||
className="mt-2"
|
||||
color="#8b5cf6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="col-12 lg:col-4">
|
||||
<Card title="Calcul de la marge">
|
||||
<div className="field">
|
||||
<label htmlFor="margeObjectif" className="font-semibold">Marge objectif (%)</label>
|
||||
<InputNumber
|
||||
id="margeObjectif"
|
||||
value={margeObjectif}
|
||||
onValueChange={(e) => setMargeObjectif(e.value || 15)}
|
||||
className="w-full"
|
||||
suffix="%"
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="flex justify-content-between align-items-center mb-2">
|
||||
<span>Total HT:</span>
|
||||
<span className="font-semibold">
|
||||
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(analysis.totalHT)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-content-between align-items-center mb-2">
|
||||
<span>TVA:</span>
|
||||
<span className="font-semibold">
|
||||
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(analysis.totalTVA)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-content-between align-items-center mb-2">
|
||||
<span>Marge ({margeObjectif}%):</span>
|
||||
<span className="font-semibold text-green-500">
|
||||
+{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(analysis.margeObjectif)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="flex justify-content-between align-items-center">
|
||||
<span className="text-lg font-bold">Prix de vente calculé:</span>
|
||||
<span className="text-xl font-bold text-primary">
|
||||
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(analysis.prixVenteCalcule)}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BudgetPlanningDialog;
|
||||
570
components/phases/PhaseGenerationWizard.tsx
Normal file
570
components/phases/PhaseGenerationWizard.tsx
Normal file
@@ -0,0 +1,570 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Assistant de génération automatique de phases pour chantiers BTP
|
||||
* Wizard en 3 étapes: Sélection template -> Personnalisation -> Prévisualisation & Génération
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Steps } from 'primereact/steps';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Divider } from 'primereact/divider';
|
||||
import { Badge } from 'primereact/badge';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import TemplateSelectionStep from './wizard/TemplateSelectionStep';
|
||||
import CustomizationStep from './wizard/CustomizationStep';
|
||||
import PreviewGenerationStep from './wizard/PreviewGenerationStep';
|
||||
import typeChantierService from '../../services/typeChantierService';
|
||||
import phaseService from '../../services/phaseService';
|
||||
|
||||
export interface PhaseTemplate {
|
||||
id: string;
|
||||
nom: string;
|
||||
description: string;
|
||||
ordre: number;
|
||||
dureeEstimee: number;
|
||||
budgetEstime: number;
|
||||
competencesRequises: string[];
|
||||
prerequis: string[];
|
||||
sousPhases: SousPhaseTemplate[];
|
||||
categorieMetier: 'GROS_OEUVRE' | 'SECOND_OEUVRE' | 'FINITIONS' | 'EQUIPEMENTS' | 'AMENAGEMENTS';
|
||||
obligatoire: boolean;
|
||||
personnalisable: boolean;
|
||||
}
|
||||
|
||||
export interface SousPhaseTemplate {
|
||||
id: string;
|
||||
nom: string;
|
||||
description: string;
|
||||
ordre: number;
|
||||
dureeEstimee: number;
|
||||
budgetEstime: number;
|
||||
competencesRequises: string[];
|
||||
obligatoire: boolean;
|
||||
}
|
||||
|
||||
export interface TypeChantierTemplate {
|
||||
id: string;
|
||||
nom: string;
|
||||
description: string;
|
||||
categorie: string;
|
||||
phases: PhaseTemplate[];
|
||||
dureeGlobaleEstimee: number;
|
||||
budgetGlobalEstime: number;
|
||||
nombreTotalPhases: number;
|
||||
complexiteMetier: 'SIMPLE' | 'MOYENNE' | 'COMPLEXE' | 'EXPERT';
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface WizardConfiguration {
|
||||
typeChantier: TypeChantierTemplate | null;
|
||||
phasesSelectionnees: PhaseTemplate[];
|
||||
configurationsPersonnalisees: Record<string, any>;
|
||||
budgetGlobal: number;
|
||||
dureeGlobale: number;
|
||||
dateDebutSouhaitee: Date | null;
|
||||
optionsAvancees: {
|
||||
integrerPlanning: boolean;
|
||||
calculerBudgetAuto: boolean;
|
||||
appliquerMarges: boolean;
|
||||
taux: {
|
||||
margeCommerciale: number;
|
||||
alea: number;
|
||||
tva: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface PhaseGenerationWizardProps {
|
||||
visible: boolean;
|
||||
onHide: () => void;
|
||||
chantier: Chantier;
|
||||
onGenerated: (phases: any[]) => void;
|
||||
}
|
||||
|
||||
const PhaseGenerationWizard: React.FC<PhaseGenerationWizardProps> = ({
|
||||
visible,
|
||||
onHide,
|
||||
chantier,
|
||||
onGenerated
|
||||
}) => {
|
||||
const toast = useRef<Toast>(null);
|
||||
|
||||
// États du wizard
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [generationProgress, setGenerationProgress] = useState(0);
|
||||
|
||||
// Configuration du wizard initialisée avec les données du chantier
|
||||
const [configuration, setConfiguration] = useState<WizardConfiguration>(() => ({
|
||||
typeChantier: null,
|
||||
phasesSelectionnees: [],
|
||||
configurationsPersonnalisees: {},
|
||||
budgetGlobal: chantier?.montantPrevu || 0,
|
||||
dureeGlobale: chantier?.dateDebut && chantier?.dateFinPrevue
|
||||
? Math.ceil((new Date(chantier.dateFinPrevue).getTime() - new Date(chantier.dateDebut).getTime()) / (1000 * 60 * 60 * 24))
|
||||
: 0,
|
||||
dateDebutSouhaitee: chantier?.dateDebut ? new Date(chantier.dateDebut) : null,
|
||||
optionsAvancees: {
|
||||
integrerPlanning: true,
|
||||
calculerBudgetAuto: true,
|
||||
appliquerMarges: true,
|
||||
taux: {
|
||||
margeCommerciale: 15,
|
||||
alea: 10,
|
||||
tva: 20
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Données chargées
|
||||
const [templatesTypes, setTemplatesTypes] = useState<TypeChantierTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Étapes du wizard
|
||||
const wizardSteps = [
|
||||
{
|
||||
label: 'Sélection Template',
|
||||
icon: 'pi pi-th-large',
|
||||
description: 'Choisir le type de chantier et template de phases'
|
||||
},
|
||||
{
|
||||
label: 'Personnalisation',
|
||||
icon: 'pi pi-cog',
|
||||
description: 'Adapter les phases et paramètres à vos besoins'
|
||||
},
|
||||
{
|
||||
label: 'Génération',
|
||||
icon: 'pi pi-check-circle',
|
||||
description: 'Prévisualiser et générer les phases'
|
||||
}
|
||||
];
|
||||
|
||||
// Charger les templates au montage
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
loadTemplatesTypes();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// Mettre à jour la configuration quand les données du chantier changent
|
||||
useEffect(() => {
|
||||
if (chantier && visible) {
|
||||
setConfiguration(prev => ({
|
||||
...prev,
|
||||
budgetGlobal: chantier.montantPrevu || 0,
|
||||
dureeGlobale: chantier.dateDebut && chantier.dateFinPrevue
|
||||
? Math.ceil((new Date(chantier.dateFinPrevue).getTime() - new Date(chantier.dateDebut).getTime()) / (1000 * 60 * 60 * 24))
|
||||
: 0,
|
||||
dateDebutSouhaitee: chantier.dateDebut ? new Date(chantier.dateDebut) : null
|
||||
}));
|
||||
}
|
||||
}, [chantier, visible]);
|
||||
|
||||
const loadTemplatesTypes = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const templates = await typeChantierService.getAllTemplates();
|
||||
setTemplatesTypes(templates);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des templates:', error);
|
||||
toast.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Erreur',
|
||||
detail: 'Impossible de charger les templates de chantiers',
|
||||
life: 3000
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Navigation du wizard
|
||||
const nextStep = () => {
|
||||
if (activeIndex < wizardSteps.length - 1) {
|
||||
setActiveIndex(activeIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (activeIndex > 0) {
|
||||
setActiveIndex(activeIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const canProceedToNext = (): boolean => {
|
||||
switch (activeIndex) {
|
||||
case 0:
|
||||
return configuration.typeChantier !== null;
|
||||
case 1:
|
||||
return configuration.phasesSelectionnees.length > 0;
|
||||
case 2:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Génération des phases
|
||||
const generatePhases = async () => {
|
||||
if (!configuration.typeChantier) {
|
||||
toast.current?.show({
|
||||
severity: 'warn',
|
||||
summary: 'Configuration incomplète',
|
||||
detail: 'Veuillez sélectionner un type de chantier',
|
||||
life: 3000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
setGenerationProgress(0);
|
||||
|
||||
// Simulation du processus de génération avec étapes
|
||||
const etapes = [
|
||||
{ label: 'Validation de la configuration', delay: 500 },
|
||||
{ label: 'Génération des phases principales', delay: 800 },
|
||||
{ label: 'Création des sous-phases', delay: 600 },
|
||||
{ label: 'Calcul des budgets automatiques', delay: 700 },
|
||||
{ label: 'Intégration au planning', delay: 400 },
|
||||
{ label: 'Sauvegarde en base', delay: 500 }
|
||||
];
|
||||
|
||||
for (let i = 0; i < etapes.length; i++) {
|
||||
const etape = etapes[i];
|
||||
|
||||
toast.current?.show({
|
||||
severity: 'info',
|
||||
summary: `Étape ${i + 1}/${etapes.length}`,
|
||||
detail: etape.label,
|
||||
life: 2000
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, etape.delay));
|
||||
setGenerationProgress(((i + 1) / etapes.length) * 100);
|
||||
}
|
||||
|
||||
// Appel au service pour générer les phases avec données du chantier
|
||||
try {
|
||||
const phasesGenerees = await phaseService.generateFromTemplate(
|
||||
parseInt(chantier.id.toString()),
|
||||
configuration.typeChantier.id,
|
||||
{
|
||||
phasesSelectionnees: configuration.phasesSelectionnees,
|
||||
configurationsPersonnalisees: configuration.configurationsPersonnalisees,
|
||||
optionsAvancees: configuration.optionsAvancees,
|
||||
dateDebutSouhaitee: configuration.dateDebutSouhaitee || chantier.dateDebut,
|
||||
dureeGlobale: configuration.dureeGlobale,
|
||||
// Données du chantier pour cohérence
|
||||
chantierData: {
|
||||
budgetTotal: chantier.montantPrevu,
|
||||
typeChantier: chantier.typeChantier,
|
||||
dateDebut: chantier.dateDebut,
|
||||
dateFinPrevue: chantier.dateFinPrevue,
|
||||
surface: chantier.surface,
|
||||
adresse: chantier.adresse
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
toast.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Génération réussie',
|
||||
detail: `${phasesGenerees.length} phases ont été générées avec succès`,
|
||||
life: 5000
|
||||
});
|
||||
|
||||
onGenerated(phasesGenerees);
|
||||
onHide();
|
||||
|
||||
} catch (serviceError) {
|
||||
console.warn('Service non disponible, génération simulée:', serviceError);
|
||||
|
||||
// Génération simulée de phases avec données du chantier
|
||||
const baseDate = chantier.dateDebut ? new Date(chantier.dateDebut) : new Date();
|
||||
let currentDate = new Date(baseDate);
|
||||
|
||||
const phasesSimulees = configuration.phasesSelectionnees.map((phase, index) => {
|
||||
const dateDebut = new Date(currentDate);
|
||||
const dateFin = new Date(dateDebut.getTime() + phase.dureeEstimee * 24 * 60 * 60 * 1000);
|
||||
currentDate = new Date(dateFin.getTime() + 24 * 60 * 60 * 1000); // Jour suivant pour la phase suivante
|
||||
|
||||
// Calculer budget proportionnel si budget total défini
|
||||
let budgetProportionnel = phase.budgetEstime;
|
||||
if (chantier.montantPrevu && configuration.budgetGlobal) {
|
||||
const ratioPhase = phase.budgetEstime / configuration.budgetGlobal;
|
||||
budgetProportionnel = chantier.montantPrevu * ratioPhase;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `sim_${Date.now()}_${index}`,
|
||||
nom: phase.nom,
|
||||
description: phase.description,
|
||||
chantierId: chantier.id.toString(),
|
||||
dateDebutPrevue: dateDebut.toISOString(),
|
||||
dateFinPrevue: dateFin.toISOString(),
|
||||
dureeEstimeeHeures: phase.dureeEstimee * 8,
|
||||
budgetPrevu: budgetProportionnel,
|
||||
coutReel: 0,
|
||||
statut: 'PLANIFIEE',
|
||||
priorite: phase.categorieMetier === 'GROS_OEUVRE' ? 'CRITIQUE' : 'MOYENNE',
|
||||
critique: phase.obligatoire,
|
||||
ordreExecution: phase.ordre,
|
||||
phaseParent: null,
|
||||
prerequisPhases: [],
|
||||
competencesRequises: phase.competencesRequises,
|
||||
materielsNecessaires: [],
|
||||
fournisseursRecommandes: [],
|
||||
dateCreation: new Date().toISOString(),
|
||||
dateModification: new Date().toISOString(),
|
||||
creePar: 'wizard',
|
||||
modifiePar: 'wizard'
|
||||
};
|
||||
});
|
||||
|
||||
toast.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Génération simulée réussie',
|
||||
detail: `${phasesSimulees.length} phases ont été générées (mode simulation)`,
|
||||
life: 5000
|
||||
});
|
||||
|
||||
onGenerated(phasesSimulees);
|
||||
onHide();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la génération:', error);
|
||||
toast.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Erreur de génération',
|
||||
detail: 'Impossible de générer les phases automatiquement',
|
||||
life: 3000
|
||||
});
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
setGenerationProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset du wizard avec données du chantier
|
||||
const resetWizard = () => {
|
||||
setActiveIndex(0);
|
||||
setConfiguration({
|
||||
typeChantier: null,
|
||||
phasesSelectionnees: [],
|
||||
configurationsPersonnalisees: {},
|
||||
budgetGlobal: chantier?.montantPrevu || 0,
|
||||
dureeGlobale: chantier?.dateDebut && chantier?.dateFinPrevue
|
||||
? Math.ceil((new Date(chantier.dateFinPrevue).getTime() - new Date(chantier.dateDebut).getTime()) / (1000 * 60 * 60 * 24))
|
||||
: 0,
|
||||
dateDebutSouhaitee: chantier?.dateDebut ? new Date(chantier.dateDebut) : null,
|
||||
optionsAvancees: {
|
||||
integrerPlanning: true,
|
||||
calculerBudgetAuto: true,
|
||||
appliquerMarges: true,
|
||||
taux: {
|
||||
margeCommerciale: 15,
|
||||
alea: 10,
|
||||
tva: 20
|
||||
}
|
||||
}
|
||||
});
|
||||
setGenerationProgress(0);
|
||||
setIsGenerating(false);
|
||||
};
|
||||
|
||||
// Template du header
|
||||
const headerTemplate = () => (
|
||||
<div className="flex align-items-center gap-3">
|
||||
<i className="pi pi-magic text-2xl text-primary"></i>
|
||||
<div>
|
||||
<h4 className="m-0">Assistant de Génération de Phases</h4>
|
||||
<p className="m-0 text-color-secondary text-sm">
|
||||
Chantier: <strong>{chantier?.nom}</strong>
|
||||
{chantier?.typeChantier && (
|
||||
<span className="ml-2 text-xs">({chantier.typeChantier})</span>
|
||||
)}
|
||||
</p>
|
||||
{chantier && (
|
||||
<p className="m-0 text-xs text-500">
|
||||
Budget: {chantier.montantPrevu?.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' }) || 'Non défini'} |
|
||||
Période: {chantier.dateDebut ? new Date(chantier.dateDebut).toLocaleDateString('fr-FR') : 'Non défini'} →
|
||||
{chantier.dateFinPrevue ? new Date(chantier.dateFinPrevue).toLocaleDateString('fr-FR') : 'Non défini'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{configuration.typeChantier && (
|
||||
<div className="ml-auto">
|
||||
<Tag
|
||||
value={configuration.typeChantier.nom}
|
||||
severity="info"
|
||||
icon="pi pi-building"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Template du footer
|
||||
const footerTemplate = () => (
|
||||
<div className="flex justify-content-between align-items-center">
|
||||
<div className="flex align-items-center gap-2">
|
||||
<Button
|
||||
label="Précédent"
|
||||
icon="pi pi-arrow-left"
|
||||
className="p-button-text p-button-rounded"
|
||||
onClick={prevStep}
|
||||
disabled={activeIndex === 0 || isGenerating}
|
||||
/>
|
||||
{activeIndex < wizardSteps.length - 1 ? (
|
||||
<Button
|
||||
label="Suivant"
|
||||
icon="pi pi-arrow-right"
|
||||
iconPos="right"
|
||||
className="p-button-text p-button-rounded p-button-info"
|
||||
onClick={nextStep}
|
||||
disabled={!canProceedToNext() || isGenerating}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
label={isGenerating ? "Génération..." : "Générer les phases"}
|
||||
icon={isGenerating ? "pi pi-spin pi-spinner" : "pi pi-check"}
|
||||
className="p-button-text p-button-rounded p-button-success"
|
||||
onClick={generatePhases}
|
||||
disabled={!canProceedToNext() || isGenerating}
|
||||
loading={isGenerating}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex align-items-center gap-2">
|
||||
{configuration.phasesSelectionnees.length > 0 && (
|
||||
<Badge
|
||||
value={`${configuration.phasesSelectionnees.length} phases`}
|
||||
severity="info"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
label="Fermer"
|
||||
icon="pi pi-times"
|
||||
className="p-button-text p-button-rounded"
|
||||
onClick={() => {
|
||||
resetWizard();
|
||||
onHide();
|
||||
}}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Rendu conditionnel des étapes
|
||||
const renderCurrentStep = () => {
|
||||
switch (activeIndex) {
|
||||
case 0:
|
||||
return (
|
||||
<TemplateSelectionStep
|
||||
templates={templatesTypes}
|
||||
loading={loading}
|
||||
configuration={configuration}
|
||||
onConfigurationChange={setConfiguration}
|
||||
chantier={chantier}
|
||||
/>
|
||||
);
|
||||
case 1:
|
||||
return (
|
||||
<CustomizationStep
|
||||
configuration={configuration}
|
||||
onConfigurationChange={setConfiguration}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<PreviewGenerationStep
|
||||
configuration={configuration}
|
||||
onConfigurationChange={setConfiguration}
|
||||
chantier={chantier}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toast ref={toast} />
|
||||
|
||||
<Dialog
|
||||
visible={visible}
|
||||
onHide={() => {
|
||||
if (!isGenerating) {
|
||||
resetWizard();
|
||||
onHide();
|
||||
}
|
||||
}}
|
||||
header={headerTemplate}
|
||||
footer={footerTemplate}
|
||||
style={{ width: '90vw', maxWidth: '1200px' }}
|
||||
modal
|
||||
closable={!isGenerating}
|
||||
className="p-dialog-maximized-responsive"
|
||||
>
|
||||
<div className="grid">
|
||||
{/* Barre de progression de génération */}
|
||||
{isGenerating && (
|
||||
<div className="col-12 mb-4">
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<div className="flex align-items-center gap-3">
|
||||
<i className="pi pi-spin pi-cog text-blue-600 text-2xl"></i>
|
||||
<div className="flex-1">
|
||||
<h6 className="m-0 text-blue-800">Génération en cours...</h6>
|
||||
<ProgressBar
|
||||
value={generationProgress}
|
||||
className="mt-2"
|
||||
style={{ height: '8px' }}
|
||||
/>
|
||||
<small className="text-blue-600 mt-1 block">
|
||||
{generationProgress.toFixed(0)}% terminé
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Steps navigation */}
|
||||
<div className="col-12 mb-4">
|
||||
<Steps
|
||||
model={wizardSteps}
|
||||
activeIndex={activeIndex}
|
||||
onSelect={(e) => {
|
||||
if (!isGenerating && e.index <= activeIndex) {
|
||||
setActiveIndex(e.index);
|
||||
}
|
||||
}}
|
||||
readOnly={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Contenu de l'étape courante */}
|
||||
<div className="col-12">
|
||||
<div style={{ minHeight: '500px' }}>
|
||||
{renderCurrentStep()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhaseGenerationWizard;
|
||||
364
components/phases/PhaseValidationPanel.tsx
Normal file
364
components/phases/PhaseValidationPanel.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Panel } from 'primereact/panel';
|
||||
import { Message } from 'primereact/message';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Badge } from 'primereact/badge';
|
||||
import { Timeline } from 'primereact/timeline';
|
||||
import { Accordion, AccordionTab } from 'primereact/accordion';
|
||||
import { Tooltip } from 'primereact/tooltip';
|
||||
import phaseValidationService, {
|
||||
type PhaseValidationResult,
|
||||
type ValidationError,
|
||||
type ValidationWarning
|
||||
} from '../../services/phaseValidationService';
|
||||
import type { PhaseChantier } from '../../types/btp';
|
||||
|
||||
interface PhaseValidationPanelProps {
|
||||
phase: PhaseChantier;
|
||||
allPhases: PhaseChantier[];
|
||||
onStartPhase?: (phaseId: string) => void;
|
||||
onViewPrerequisite?: (prerequisiteId: string) => void;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const PhaseValidationPanel: React.FC<PhaseValidationPanelProps> = ({
|
||||
phase,
|
||||
allPhases,
|
||||
onStartPhase,
|
||||
onViewPrerequisite,
|
||||
className = '',
|
||||
compact = false
|
||||
}) => {
|
||||
const [validation, setValidation] = useState<PhaseValidationResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedSections, setExpandedSections] = useState<string[]>(['errors']);
|
||||
|
||||
useEffect(() => {
|
||||
validatePhase();
|
||||
}, [phase, allPhases]);
|
||||
|
||||
const validatePhase = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = phaseValidationService.validatePhaseStart(phase, allPhases, {
|
||||
strictMode: false
|
||||
});
|
||||
setValidation(result);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la validation de la phase:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityIcon = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'error': return 'pi pi-times-circle';
|
||||
case 'warning': return 'pi pi-exclamation-triangle';
|
||||
case 'info': return 'pi pi-info-circle';
|
||||
default: return 'pi pi-circle';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'error': return 'danger';
|
||||
case 'warning': return 'warning';
|
||||
case 'info': return 'info';
|
||||
default: return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const renderValidationStatus = () => {
|
||||
if (!validation) return null;
|
||||
|
||||
const statusColor = validation.readyToStart ? 'success' :
|
||||
validation.canStart ? 'warning' : 'danger';
|
||||
|
||||
const statusIcon = validation.readyToStart ? 'pi pi-check-circle' :
|
||||
validation.canStart ? 'pi pi-exclamation-triangle' : 'pi pi-times-circle';
|
||||
|
||||
const statusText = validation.readyToStart ? 'Prête à démarrer' :
|
||||
validation.canStart ? 'Peut démarrer avec précautions' : 'Ne peut pas démarrer';
|
||||
|
||||
return (
|
||||
<div className="flex align-items-center gap-2 mb-3">
|
||||
<i className={`${statusIcon} text-${statusColor === 'danger' ? 'red' : statusColor === 'warning' ? 'yellow' : 'green'}-500 text-lg`}></i>
|
||||
<span className="font-semibold">{statusText}</span>
|
||||
{validation.errors.length > 0 && (
|
||||
<Badge value={validation.errors.length} severity="danger" />
|
||||
)}
|
||||
{validation.warnings.length > 0 && (
|
||||
<Badge value={validation.warnings.length} severity="warning" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderErrorsAndWarnings = () => {
|
||||
if (!validation || (validation.errors.length === 0 && validation.warnings.length === 0)) {
|
||||
return (
|
||||
<Message
|
||||
severity="success"
|
||||
text="Aucun problème détecté"
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-column gap-2">
|
||||
{validation.errors.map((error, index) => (
|
||||
<Message
|
||||
key={`error-${index}`}
|
||||
severity={getSeverityColor(error.severity) as any}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex align-items-center justify-content-between w-full">
|
||||
<span>{error.message}</span>
|
||||
{error.phaseId && onViewPrerequisite && (
|
||||
<Button
|
||||
icon="pi pi-external-link"
|
||||
className="p-button-text p-button-sm"
|
||||
onClick={() => onViewPrerequisite(error.phaseId!)}
|
||||
tooltip="Voir la phase"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Message>
|
||||
))}
|
||||
|
||||
{validation.warnings.map((warning, index) => (
|
||||
<Message
|
||||
key={`warning-${index}`}
|
||||
severity="warn"
|
||||
className="w-full"
|
||||
>
|
||||
<div>
|
||||
<div>{warning.message}</div>
|
||||
{warning.recommendation && (
|
||||
<small className="text-600 mt-1 block">
|
||||
💡 {warning.recommendation}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</Message>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPrerequisites = () => {
|
||||
if (!phase.prerequis || phase.prerequis.length === 0) {
|
||||
return (
|
||||
<Message
|
||||
severity="info"
|
||||
text="Aucun prérequis défini"
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const prerequisitePhases = phase.prerequis
|
||||
.map(prereqId => allPhases.find(p => p.id === prereqId))
|
||||
.filter(Boolean) as PhaseChantier[];
|
||||
|
||||
if (prerequisitePhases.length === 0) {
|
||||
return (
|
||||
<Message
|
||||
severity="warn"
|
||||
text="Prérequis non trouvés dans le projet"
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-column gap-2">
|
||||
{prerequisitePhases.map(prereq => {
|
||||
const isCompleted = prereq.statut === 'TERMINEE';
|
||||
const isInProgress = prereq.statut === 'EN_COURS';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={prereq.id}
|
||||
className="flex align-items-center justify-content-between p-3 border-1 surface-border border-round"
|
||||
>
|
||||
<div className="flex align-items-center gap-2">
|
||||
<i className={`pi ${isCompleted ? 'pi-check-circle text-green-500' :
|
||||
isInProgress ? 'pi-clock text-blue-500' :
|
||||
'pi-circle text-gray-400'}`}></i>
|
||||
<span className={isCompleted ? 'text-green-700' : isInProgress ? 'text-blue-700' : 'text-600'}>
|
||||
{prereq.nom}
|
||||
</span>
|
||||
<Tag
|
||||
value={prereq.statut}
|
||||
severity={isCompleted ? 'success' : isInProgress ? 'info' : 'secondary'}
|
||||
className="text-xs"
|
||||
/>
|
||||
{prereq.critique && (
|
||||
<Tag value="Critique" severity="danger" className="text-xs" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex align-items-center gap-2">
|
||||
{prereq.dateFinReelle && (
|
||||
<small className="text-600">
|
||||
Terminé le {new Date(prereq.dateFinReelle).toLocaleDateString('fr-FR')}
|
||||
</small>
|
||||
)}
|
||||
{prereq.dateFinPrevue && !prereq.dateFinReelle && (
|
||||
<small className="text-600">
|
||||
Prévu le {new Date(prereq.dateFinPrevue).toLocaleDateString('fr-FR')}
|
||||
</small>
|
||||
)}
|
||||
{onViewPrerequisite && (
|
||||
<Button
|
||||
icon="pi pi-eye"
|
||||
className="p-button-text p-button-sm"
|
||||
onClick={() => onViewPrerequisite(prereq.id!)}
|
||||
tooltip="Voir les détails"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBlockingPhases = () => {
|
||||
if (!validation || validation.blockedBy.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Message severity="error" className="w-full">
|
||||
<div>
|
||||
<div className="font-semibold mb-2">Phase bloquée par :</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{validation.blockedBy.map((blocker, index) => (
|
||||
<Tag key={index} value={blocker} severity="danger" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Message>
|
||||
);
|
||||
};
|
||||
|
||||
const renderActionButtons = () => {
|
||||
if (!validation || !onStartPhase) return null;
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 pt-3 border-top-1 surface-border">
|
||||
<Button
|
||||
label="Démarrer la phase"
|
||||
icon="pi pi-play"
|
||||
className="p-button-success"
|
||||
disabled={!validation.canStart}
|
||||
onClick={() => onStartPhase(phase.id!)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Revalider"
|
||||
icon="pi pi-refresh"
|
||||
className="p-button-outlined"
|
||||
onClick={validatePhase}
|
||||
/>
|
||||
|
||||
{validation.warnings.length > 0 && (
|
||||
<Button
|
||||
label="Ignorer les avertissements"
|
||||
icon="pi pi-exclamation-triangle"
|
||||
className="p-button-warning p-button-outlined"
|
||||
disabled={validation.errors.length > 0}
|
||||
onClick={() => onStartPhase && onStartPhase(phase.id!)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Panel header="Validation des prérequis" className={className}>
|
||||
<div className="text-center p-4">
|
||||
<i className="pi pi-spinner pi-spin text-2xl text-primary"></i>
|
||||
<div className="mt-2">Validation en cours...</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={`surface-card p-3 border-round ${className}`}>
|
||||
{renderValidationStatus()}
|
||||
{validation && validation.blockedBy.length > 0 && (
|
||||
<div className="text-sm text-red-600">
|
||||
Bloquée par : {validation.blockedBy.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
header={
|
||||
<div className="flex align-items-center gap-2">
|
||||
<span>Validation des prérequis</span>
|
||||
{validation && !validation.readyToStart && (
|
||||
<i className="pi pi-exclamation-triangle text-yellow-500"></i>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
className={className}
|
||||
toggleable
|
||||
>
|
||||
{renderValidationStatus()}
|
||||
{renderBlockingPhases()}
|
||||
|
||||
<Accordion
|
||||
multiple
|
||||
activeIndex={expandedSections}
|
||||
onTabChange={(e) => setExpandedSections(e.index as string[])}
|
||||
>
|
||||
<AccordionTab
|
||||
header={
|
||||
<div className="flex align-items-center gap-2">
|
||||
<span>Erreurs et avertissements</span>
|
||||
{validation && (validation.errors.length > 0 || validation.warnings.length > 0) && (
|
||||
<div className="flex gap-1">
|
||||
{validation.errors.length > 0 && (
|
||||
<Badge value={validation.errors.length} severity="danger" />
|
||||
)}
|
||||
{validation.warnings.length > 0 && (
|
||||
<Badge value={validation.warnings.length} severity="warning" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{renderErrorsAndWarnings()}
|
||||
</AccordionTab>
|
||||
|
||||
<AccordionTab header="Prérequis">
|
||||
{renderPrerequisites()}
|
||||
</AccordionTab>
|
||||
</Accordion>
|
||||
|
||||
{renderActionButtons()}
|
||||
|
||||
<Tooltip target=".validation-tooltip" />
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhaseValidationPanel;
|
||||
192
components/phases/PhasesQuickPreview.tsx
Normal file
192
components/phases/PhasesQuickPreview.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Badge } from 'primereact/badge';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Button } from 'primereact/button';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import type { PhaseChantier } from '../../types/btp';
|
||||
|
||||
interface PhasesQuickPreviewProps {
|
||||
phases: PhaseChantier[];
|
||||
className?: string;
|
||||
onViewDetails?: () => void;
|
||||
}
|
||||
|
||||
const PhasesQuickPreview: React.FC<PhasesQuickPreviewProps> = ({
|
||||
phases,
|
||||
className = '',
|
||||
onViewDetails
|
||||
}) => {
|
||||
const getPhaseStats = () => {
|
||||
const total = phases.length;
|
||||
const principales = phases.filter(p => !p.phaseParent).length;
|
||||
const sousPhases = phases.filter(p => p.phaseParent).length;
|
||||
const enCours = phases.filter(p => p.statut === 'EN_COURS').length;
|
||||
const terminees = phases.filter(p => p.statut === 'TERMINEE').length;
|
||||
const critiques = phases.filter(p => p.critique).length;
|
||||
const enRetard = phases.filter(p => {
|
||||
if (p.statut === 'TERMINEE') return false;
|
||||
const maintenant = new Date();
|
||||
const dateFinPrevue = p.dateFinPrevue ? new Date(p.dateFinPrevue) : null;
|
||||
return dateFinPrevue ? dateFinPrevue < maintenant : false;
|
||||
}).length;
|
||||
|
||||
const avancementMoyen = total > 0 ?
|
||||
Math.round(phases.reduce((acc, p) => acc + (p.pourcentageAvancement || 0), 0) / total) : 0;
|
||||
|
||||
return { total, principales, sousPhases, enCours, terminees, critiques, enRetard, avancementMoyen };
|
||||
};
|
||||
|
||||
const stats = getPhaseStats();
|
||||
|
||||
const getProgressColor = (percentage: number) => {
|
||||
if (percentage >= 80) return 'success';
|
||||
if (percentage >= 50) return 'info';
|
||||
if (percentage >= 25) return 'warning';
|
||||
return 'danger';
|
||||
};
|
||||
|
||||
const getNextPhases = () => {
|
||||
return phases
|
||||
.filter(p => p.statut === 'PLANIFIEE' || p.statut === 'EN_COURS')
|
||||
.sort((a, b) => (a.ordreExecution || 0) - (b.ordreExecution || 0))
|
||||
.slice(0, 3);
|
||||
};
|
||||
|
||||
const nextPhases = getNextPhases();
|
||||
|
||||
return (
|
||||
<Card title="Aperçu des phases" className={className}>
|
||||
{/* Statistiques rapides */}
|
||||
<div className="grid mb-4">
|
||||
<div className="col-6 md:col-3">
|
||||
<div className="text-center p-2 border-1 surface-border border-round">
|
||||
<div className="text-xl font-bold text-primary">{stats.total}</div>
|
||||
<div className="text-600 text-sm">Total phases</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-6 md:col-3">
|
||||
<div className="text-center p-2 border-1 surface-border border-round">
|
||||
<div className="text-xl font-bold text-green-500">{stats.terminees}</div>
|
||||
<div className="text-600 text-sm">Terminées</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-6 md:col-3">
|
||||
<div className="text-center p-2 border-1 surface-border border-round">
|
||||
<div className="text-xl font-bold text-blue-500">{stats.enCours}</div>
|
||||
<div className="text-600 text-sm">En cours</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-6 md:col-3">
|
||||
<div className="text-center p-2 border-1 surface-border border-round">
|
||||
<div className="text-xl font-bold text-red-500">{stats.critiques}</div>
|
||||
<div className="text-600 text-sm">Critiques</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Avancement global */}
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-content-between align-items-center mb-2">
|
||||
<span className="font-semibold">Avancement global</span>
|
||||
<Badge value={`${stats.avancementMoyen}%`} severity={getProgressColor(stats.avancementMoyen)} />
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={stats.avancementMoyen}
|
||||
color={
|
||||
stats.avancementMoyen >= 80 ? '#10b981' :
|
||||
stats.avancementMoyen >= 50 ? '#3b82f6' :
|
||||
stats.avancementMoyen >= 25 ? '#f59e0b' : '#ef4444'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alertes */}
|
||||
{(stats.enRetard > 0 || stats.critiques > 0) && (
|
||||
<div className="mb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats.enRetard > 0 && (
|
||||
<Tag
|
||||
value={`${stats.enRetard} phase${stats.enRetard > 1 ? 's' : ''} en retard`}
|
||||
severity="danger"
|
||||
icon="pi pi-exclamation-triangle"
|
||||
/>
|
||||
)}
|
||||
{stats.critiques > 0 && (
|
||||
<Tag
|
||||
value={`${stats.critiques} phase${stats.critiques > 1 ? 's' : ''} critique${stats.critiques > 1 ? 's' : ''}`}
|
||||
severity="warning"
|
||||
icon="pi pi-flag"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prochaines phases */}
|
||||
{nextPhases.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-lg font-semibold mb-3">Prochaines phases</h4>
|
||||
<div className="flex flex-column gap-2">
|
||||
{nextPhases.map((phase, index) => (
|
||||
<div key={phase.id} className="flex align-items-center gap-3 p-2 surface-100 border-round">
|
||||
<div className="flex align-items-center justify-content-center w-2rem h-2rem border-circle bg-primary text-white font-bold">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold">{phase.nom}</div>
|
||||
<div className="text-600 text-sm">
|
||||
{phase.dateFinPrevue && `Prévue: ${new Date(phase.dateFinPrevue).toLocaleDateString('fr-FR')}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{phase.critique && (
|
||||
<Tag value="Critique" severity="danger" className="text-xs" />
|
||||
)}
|
||||
<Tag
|
||||
value={phase.statut}
|
||||
severity={phase.statut === 'EN_COURS' ? 'info' : 'secondary'}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Structure hiérarchique */}
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-content-between align-items-center">
|
||||
<span className="font-semibold">Structure du projet</span>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex align-items-center gap-1">
|
||||
<div className="w-0.5rem h-0.5rem border-circle bg-primary"></div>
|
||||
<span className="text-sm text-600">{stats.principales} principales</span>
|
||||
</div>
|
||||
<div className="flex align-items-center gap-1">
|
||||
<div className="w-0.5rem h-0.5rem border-circle bg-gray-400"></div>
|
||||
<span className="text-sm text-600">{stats.sousPhases} sous-phases</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{onViewDetails && (
|
||||
<div className="text-center">
|
||||
<Button
|
||||
label="Voir le détail des phases"
|
||||
icon="pi pi-eye"
|
||||
className="p-button-outlined"
|
||||
onClick={onViewDetails}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhasesQuickPreview;
|
||||
639
components/phases/PhasesTable.tsx
Normal file
639
components/phases/PhasesTable.tsx
Normal file
@@ -0,0 +1,639 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { DataTable, DataTableExpandedRows } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Badge } from 'primereact/badge';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { ConfirmDialog, confirmDialog } from 'primereact/confirmdialog';
|
||||
import { Menu } from 'primereact/menu';
|
||||
import { TabView, TabPanel } from 'primereact/tabview';
|
||||
import {
|
||||
ActionButtonGroup,
|
||||
ViewButton,
|
||||
EditButton,
|
||||
DeleteButton,
|
||||
StartButton,
|
||||
CompleteButton,
|
||||
ProgressButton,
|
||||
BudgetPlanButton,
|
||||
BudgetTrackButton
|
||||
} from '../ui/ActionButton';
|
||||
|
||||
import { PhaseChantier, StatutPhase } from '../../types/btp-extended';
|
||||
import phaseService from '../../services/phaseService';
|
||||
import materielPhaseService from '../../services/materielPhaseService';
|
||||
import fournisseurPhaseService from '../../services/fournisseurPhaseService';
|
||||
|
||||
export interface PhasesTableProps {
|
||||
// Données
|
||||
phases: PhaseChantier[];
|
||||
loading?: boolean;
|
||||
chantierId?: string;
|
||||
|
||||
// Affichage
|
||||
showStats?: boolean;
|
||||
showChantierColumn?: boolean;
|
||||
showSubPhases?: boolean;
|
||||
showBudget?: boolean;
|
||||
showExpansion?: boolean;
|
||||
showGlobalFilter?: boolean;
|
||||
|
||||
// Actions disponibles
|
||||
actions?: Array<'view' | 'edit' | 'delete' | 'start' | 'complete' | 'progress' | 'budget-plan' | 'budget-track' | 'all'>;
|
||||
|
||||
// Callbacks
|
||||
onRefresh?: () => void;
|
||||
onPhaseSelect?: (phase: PhaseChantier) => void;
|
||||
onPhaseEdit?: (phase: PhaseChantier) => void;
|
||||
onPhaseDelete?: (phaseId: string) => void;
|
||||
onPhaseStart?: (phaseId: string) => void;
|
||||
onPhaseProgress?: (phase: PhaseChantier) => void;
|
||||
onPhaseBudgetPlan?: (phase: PhaseChantier) => void;
|
||||
onPhaseBudgetTrack?: (phase: PhaseChantier) => void;
|
||||
onSubPhaseAdd?: (parentPhase: PhaseChantier) => void;
|
||||
|
||||
// Configuration
|
||||
rows?: number;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
globalFilter?: string;
|
||||
}
|
||||
|
||||
const PhasesTable: React.FC<PhasesTableProps> = ({
|
||||
phases,
|
||||
loading = false,
|
||||
chantierId,
|
||||
showStats = false,
|
||||
showChantierColumn = false,
|
||||
showSubPhases = true,
|
||||
showBudget = true,
|
||||
showExpansion = true,
|
||||
showGlobalFilter = false,
|
||||
actions = ['all'],
|
||||
onRefresh,
|
||||
onPhaseSelect,
|
||||
onPhaseEdit,
|
||||
onPhaseDelete,
|
||||
onPhaseStart,
|
||||
onPhaseProgress,
|
||||
onPhaseBudgetPlan,
|
||||
onPhaseBudgetTrack,
|
||||
onSubPhaseAdd,
|
||||
rows = 15,
|
||||
emptyMessage = "Aucune phase trouvée",
|
||||
className = "p-datatable-lg",
|
||||
globalFilter = ''
|
||||
}) => {
|
||||
const toast = useRef<Toast>(null);
|
||||
const [expandedRows, setExpandedRows] = useState<DataTableExpandedRows | undefined>(undefined);
|
||||
const [materielsPhase, setMaterielsPhase] = useState<any[]>([]);
|
||||
const [fournisseursPhase, setFournisseursPhase] = useState<any[]>([]);
|
||||
|
||||
// Déterminer quelles actions afficher
|
||||
const shouldShowAction = (action: string) => {
|
||||
return actions.includes('all') || actions.includes(action as any);
|
||||
};
|
||||
|
||||
// Templates de colonnes
|
||||
const statutBodyTemplate = (rowData: PhaseChantier) => {
|
||||
const severityMap: Record<string, any> = {
|
||||
'PLANIFIEE': 'secondary',
|
||||
'EN_ATTENTE': 'warning',
|
||||
'EN_COURS': 'info',
|
||||
'SUSPENDUE': 'warning',
|
||||
'TERMINEE': 'success',
|
||||
'ANNULEE': 'danger'
|
||||
};
|
||||
|
||||
return <Tag value={rowData.statut} severity={severityMap[rowData.statut]} />;
|
||||
};
|
||||
|
||||
const avancementBodyTemplate = (rowData: PhaseChantier) => {
|
||||
const progress = rowData.pourcentageAvancement || 0;
|
||||
const color = progress === 100 ? 'var(--green-500)' : progress >= 50 ? 'var(--blue-500)' : 'var(--orange-500)';
|
||||
|
||||
return (
|
||||
<div className="flex align-items-center gap-2">
|
||||
<ProgressBar
|
||||
value={progress}
|
||||
style={{ width: '100px', height: '8px' }}
|
||||
color={color}
|
||||
showValue={false}
|
||||
/>
|
||||
<span className="text-sm font-semibold">{progress}%</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const dateBodyTemplate = (rowData: PhaseChantier, field: keyof PhaseChantier) => {
|
||||
const date = rowData[field] as string;
|
||||
if (!date) return <span className="text-color-secondary">-</span>;
|
||||
|
||||
const dateObj = new Date(date);
|
||||
const isOverdue = field === 'dateFinPrevue' && dateObj < new Date() && rowData.statut !== 'TERMINEE';
|
||||
|
||||
return (
|
||||
<span className={isOverdue ? 'text-red-500 font-semibold' : ''}>
|
||||
{dateObj.toLocaleDateString('fr-FR')}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const prioriteBodyTemplate = (rowData: PhaseChantier) => {
|
||||
const severityMap: Record<string, any> = {
|
||||
'FAIBLE': 'secondary',
|
||||
'MOYENNE': 'info',
|
||||
'ELEVEE': 'warning',
|
||||
'CRITIQUE': 'danger'
|
||||
};
|
||||
|
||||
return rowData.priorite ? <Tag value={rowData.priorite} severity={severityMap[rowData.priorite]} /> : null;
|
||||
};
|
||||
|
||||
const budgetBodyTemplate = (rowData: PhaseChantier) => {
|
||||
return (
|
||||
<span className="text-900 font-semibold">
|
||||
{new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0
|
||||
}).format(rowData.budgetPrevu || 0)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const coutReelBodyTemplate = (rowData: PhaseChantier) => {
|
||||
const cout = rowData.coutReel || 0;
|
||||
const budget = rowData.budgetPrevu || 0;
|
||||
const depassement = cout > budget;
|
||||
|
||||
return (
|
||||
<span className={depassement ? 'text-red-500 font-semibold' : 'text-900'}>
|
||||
{new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0
|
||||
}).format(cout)}
|
||||
{depassement && (
|
||||
<i className="pi pi-exclamation-triangle text-red-500 ml-2" title="Dépassement budgétaire"></i>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const chantierBodyTemplate = (rowData: PhaseChantier) => {
|
||||
return rowData.chantier?.nom || '-';
|
||||
};
|
||||
|
||||
const phaseNameBodyTemplate = (rowData: PhaseChantier) => {
|
||||
return (
|
||||
<div className="flex align-items-center gap-2">
|
||||
{showSubPhases && (
|
||||
<Badge
|
||||
value={rowData.ordreExecution || 0}
|
||||
className="bg-primary text-primary-50"
|
||||
style={{ minWidth: '1.5rem' }}
|
||||
/>
|
||||
)}
|
||||
<i className="pi pi-sitemap text-sm text-color-secondary"></i>
|
||||
<div className="flex flex-column flex-1">
|
||||
<span className="font-semibold text-color">
|
||||
{rowData.nom}
|
||||
</span>
|
||||
{rowData.description && (
|
||||
<small className="text-color-secondary text-xs mt-1">
|
||||
{rowData.description.length > 60
|
||||
? rowData.description.substring(0, 60) + '...'
|
||||
: rowData.description
|
||||
}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
{rowData.critique && (
|
||||
<Badge
|
||||
value="Critique"
|
||||
severity="danger"
|
||||
className="text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Chargement du matériel et fournisseurs pour l'expansion
|
||||
const loadMaterielPhase = async (phaseId: string) => {
|
||||
try {
|
||||
const materiels = await materielPhaseService.getByPhase(phaseId);
|
||||
setMaterielsPhase(materiels);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement du matériel:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadFournisseursPhase = async (phaseId: string) => {
|
||||
try {
|
||||
const fournisseurs = await fournisseurPhaseService.getByPhase(phaseId);
|
||||
setFournisseursPhase(fournisseurs);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des fournisseurs:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Template d'expansion
|
||||
const rowExpansionTemplate = (data: PhaseChantier) => {
|
||||
if (data.phaseParent || !showSubPhases) return null;
|
||||
|
||||
const sousPhases = phases.filter(p => p.phaseParent === data.id);
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-surface-50">
|
||||
<TabView>
|
||||
<TabPanel header="Sous-phases" leftIcon="pi pi-sitemap">
|
||||
<div className="flex justify-content-between align-items-center mb-4">
|
||||
<h6 className="m-0 text-color">
|
||||
Sous-phases de "{data.nom}" ({sousPhases.length})
|
||||
</h6>
|
||||
{onSubPhaseAdd && (
|
||||
<Button
|
||||
label="Ajouter une sous-phase"
|
||||
icon="pi pi-plus"
|
||||
className="p-button-text p-button-rounded p-button-success p-button-sm"
|
||||
onClick={() => onSubPhaseAdd(data)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sousPhases.length > 0 ? (
|
||||
<DataTable
|
||||
value={sousPhases}
|
||||
size="small"
|
||||
className="p-datatable-sm"
|
||||
emptyMessage="Aucune sous-phase"
|
||||
>
|
||||
<Column
|
||||
field="nom"
|
||||
header="Sous-phase"
|
||||
style={{ minWidth: '15rem' }}
|
||||
body={(rowData) => (
|
||||
<div className="flex align-items-center gap-2">
|
||||
<Badge
|
||||
value={rowData.ordreExecution || 0}
|
||||
className="bg-surface-100 text-surface-700 text-xs"
|
||||
style={{ minWidth: '1.2rem', fontSize: '0.7rem' }}
|
||||
/>
|
||||
<i className="pi pi-minus text-xs text-color-secondary"></i>
|
||||
<span className="font-semibold flex-1">{rowData.nom}</span>
|
||||
{rowData.critique && (
|
||||
<Tag value="Critique" severity="danger" className="text-xs" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Column
|
||||
field="statut"
|
||||
header="Statut"
|
||||
style={{ width: '8rem' }}
|
||||
body={statutBodyTemplate}
|
||||
/>
|
||||
<Column
|
||||
field="pourcentageAvancement"
|
||||
header="Avancement"
|
||||
style={{ width: '10rem' }}
|
||||
body={avancementBodyTemplate}
|
||||
/>
|
||||
<Column
|
||||
field="dateDebutPrevue"
|
||||
header="Début prévu"
|
||||
style={{ width: '10rem' }}
|
||||
body={(rowData) => dateBodyTemplate(rowData, 'dateDebutPrevue')}
|
||||
/>
|
||||
<Column
|
||||
field="dateFinPrevue"
|
||||
header="Fin prévue"
|
||||
style={{ width: '10rem' }}
|
||||
body={(rowData) => dateBodyTemplate(rowData, 'dateFinPrevue')}
|
||||
/>
|
||||
{showBudget && (
|
||||
<>
|
||||
<Column
|
||||
field="budgetPrevu"
|
||||
header="Budget"
|
||||
style={{ width: '8rem' }}
|
||||
body={budgetBodyTemplate}
|
||||
/>
|
||||
<Column
|
||||
field="coutReel"
|
||||
header="Coût réel"
|
||||
style={{ width: '8rem' }}
|
||||
body={coutReelBodyTemplate}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Column
|
||||
header="Actions"
|
||||
style={{ width: '10rem' }}
|
||||
body={(rowData) => (
|
||||
<ActionButtonGroup>
|
||||
{shouldShowAction('view') && onPhaseSelect && (
|
||||
<ViewButton
|
||||
tooltip="Voir détails"
|
||||
onClick={() => onPhaseSelect(rowData)}
|
||||
/>
|
||||
)}
|
||||
{shouldShowAction('edit') && onPhaseEdit && (
|
||||
<EditButton
|
||||
tooltip="Modifier"
|
||||
onClick={() => onPhaseEdit(rowData)}
|
||||
/>
|
||||
)}
|
||||
{shouldShowAction('start') && onPhaseStart && (
|
||||
<StartButton
|
||||
tooltip="Démarrer"
|
||||
disabled={rowData.statut !== 'PLANIFIEE'}
|
||||
onClick={() => onPhaseStart(rowData.id!)}
|
||||
/>
|
||||
)}
|
||||
{shouldShowAction('delete') && onPhaseDelete && (
|
||||
<DeleteButton
|
||||
tooltip="Supprimer"
|
||||
onClick={() => onPhaseDelete(rowData.id!)}
|
||||
/>
|
||||
)}
|
||||
</ActionButtonGroup>
|
||||
)}
|
||||
/>
|
||||
</DataTable>
|
||||
) : (
|
||||
<div className="text-center p-4">
|
||||
<i className="pi pi-info-circle text-4xl text-color-secondary mb-3"></i>
|
||||
<p className="text-color-secondary m-0">
|
||||
Aucune sous-phase définie pour cette phase.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel header="Matériel" leftIcon="pi pi-wrench">
|
||||
<Button
|
||||
label="Charger le matériel"
|
||||
icon="pi pi-refresh"
|
||||
onClick={() => loadMaterielPhase(data.id!)}
|
||||
className="mb-3 p-button-text p-button-rounded"
|
||||
/>
|
||||
<DataTable
|
||||
value={materielsPhase}
|
||||
size="small"
|
||||
emptyMessage="Aucun matériel assigné"
|
||||
>
|
||||
<Column field="nom" header="Matériel" />
|
||||
<Column field="quantite" header="Quantité" />
|
||||
<Column field="statut" header="Statut" />
|
||||
</DataTable>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel header="Fournisseurs" leftIcon="pi pi-users">
|
||||
<Button
|
||||
label="Charger les fournisseurs"
|
||||
icon="pi pi-refresh"
|
||||
onClick={() => loadFournisseursPhase(data.id!)}
|
||||
className="mb-3 p-button-text p-button-rounded"
|
||||
/>
|
||||
<DataTable
|
||||
value={fournisseursPhase}
|
||||
size="small"
|
||||
emptyMessage="Aucun fournisseur recommandé"
|
||||
>
|
||||
<Column field="nom" header="Fournisseur" />
|
||||
<Column field="specialite" header="Spécialité" />
|
||||
<Column field="notation" header="Note" />
|
||||
</DataTable>
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Template des actions principales
|
||||
const actionBodyTemplate = (rowData: PhaseChantier) => {
|
||||
const handleDelete = () => {
|
||||
confirmDialog({
|
||||
message: `Êtes-vous sûr de vouloir supprimer la phase "${rowData.nom}" ?`,
|
||||
header: 'Confirmer la suppression',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClassName: 'p-button-danger',
|
||||
acceptLabel: 'Supprimer',
|
||||
rejectLabel: 'Annuler',
|
||||
accept: async () => {
|
||||
try {
|
||||
await phaseService.delete(rowData.id!);
|
||||
if (onRefresh) onRefresh();
|
||||
toast.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Suppression réussie',
|
||||
detail: 'La phase a été supprimée',
|
||||
life: 3000
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
toast.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Erreur',
|
||||
detail: 'Impossible de supprimer la phase',
|
||||
life: 5000
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionButtonGroup>
|
||||
{shouldShowAction('view') && onPhaseSelect && (
|
||||
<ViewButton
|
||||
tooltip="Voir détails"
|
||||
onClick={() => onPhaseSelect(rowData)}
|
||||
/>
|
||||
)}
|
||||
{shouldShowAction('edit') && onPhaseEdit && (
|
||||
<EditButton
|
||||
tooltip="Modifier"
|
||||
onClick={() => onPhaseEdit(rowData)}
|
||||
/>
|
||||
)}
|
||||
{shouldShowAction('start') && onPhaseStart && (
|
||||
<StartButton
|
||||
tooltip="Démarrer"
|
||||
disabled={rowData.statut !== 'PLANIFIEE'}
|
||||
onClick={() => onPhaseStart(rowData.id!)}
|
||||
/>
|
||||
)}
|
||||
{shouldShowAction('progress') && onPhaseProgress && (
|
||||
<ProgressButton
|
||||
tooltip="Mettre à jour avancement"
|
||||
onClick={() => onPhaseProgress(rowData)}
|
||||
/>
|
||||
)}
|
||||
{shouldShowAction('budget-plan') && onPhaseBudgetPlan && (
|
||||
<BudgetPlanButton
|
||||
tooltip="Planifier le budget"
|
||||
onClick={() => onPhaseBudgetPlan(rowData)}
|
||||
/>
|
||||
)}
|
||||
{shouldShowAction('budget-track') && onPhaseBudgetTrack && (
|
||||
<BudgetTrackButton
|
||||
tooltip="Suivi des dépenses"
|
||||
onClick={() => onPhaseBudgetTrack(rowData)}
|
||||
/>
|
||||
)}
|
||||
{shouldShowAction('delete') && (onPhaseDelete || true) && (
|
||||
<DeleteButton
|
||||
tooltip="Supprimer"
|
||||
onClick={() => onPhaseDelete ? onPhaseDelete(rowData.id!) : handleDelete()}
|
||||
/>
|
||||
)}
|
||||
</ActionButtonGroup>
|
||||
);
|
||||
};
|
||||
|
||||
// Style des lignes
|
||||
const phaseRowClassName = (rowData: PhaseChantier) => {
|
||||
let className = '';
|
||||
|
||||
if (rowData.phaseParent) {
|
||||
className += ' bg-surface-card border-left-4 border-surface-300';
|
||||
} else {
|
||||
className += ' bg-surface-ground border-left-4 border-primary font-semibold';
|
||||
}
|
||||
|
||||
if (rowData.critique) {
|
||||
className += ' border-red-500';
|
||||
}
|
||||
|
||||
return className;
|
||||
};
|
||||
|
||||
// Filtrer les phases principales seulement si subPhases est activé
|
||||
const displayPhases = showSubPhases ? phases.filter(p => !p.phaseParent) : phases;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toast ref={toast} />
|
||||
<ConfirmDialog />
|
||||
|
||||
<DataTable
|
||||
value={displayPhases}
|
||||
loading={loading}
|
||||
paginator
|
||||
rows={rows}
|
||||
globalFilter={showGlobalFilter ? globalFilter : undefined}
|
||||
emptyMessage={emptyMessage}
|
||||
className={className}
|
||||
dataKey="id"
|
||||
expandedRows={showExpansion ? expandedRows : undefined}
|
||||
onRowToggle={showExpansion ? (e) => setExpandedRows(e.data) : undefined}
|
||||
rowExpansionTemplate={showExpansion ? rowExpansionTemplate : undefined}
|
||||
rowClassName={phaseRowClassName}
|
||||
>
|
||||
{showExpansion && showSubPhases && <Column expander style={{ width: '3rem' }} />}
|
||||
|
||||
<Column
|
||||
field="nom"
|
||||
header="Phase"
|
||||
sortable
|
||||
style={{ minWidth: '20rem' }}
|
||||
body={phaseNameBodyTemplate}
|
||||
/>
|
||||
|
||||
{showChantierColumn && (
|
||||
<Column
|
||||
field="chantier.nom"
|
||||
header="Chantier"
|
||||
sortable
|
||||
style={{ width: '15rem' }}
|
||||
body={chantierBodyTemplate}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Column
|
||||
field="statut"
|
||||
header="Statut"
|
||||
body={statutBodyTemplate}
|
||||
sortable
|
||||
style={{ width: '10rem' }}
|
||||
/>
|
||||
|
||||
<Column
|
||||
field="priorite"
|
||||
header="Priorité"
|
||||
body={prioriteBodyTemplate}
|
||||
sortable
|
||||
style={{ width: '8rem' }}
|
||||
/>
|
||||
|
||||
<Column
|
||||
field="pourcentageAvancement"
|
||||
header="Avancement"
|
||||
body={avancementBodyTemplate}
|
||||
style={{ width: '12rem' }}
|
||||
/>
|
||||
|
||||
<Column
|
||||
field="dateDebutPrevue"
|
||||
header="Début prévu"
|
||||
body={(rowData) => dateBodyTemplate(rowData, 'dateDebutPrevue')}
|
||||
sortable
|
||||
style={{ width: '10rem' }}
|
||||
/>
|
||||
|
||||
<Column
|
||||
field="dateFinPrevue"
|
||||
header="Fin prévue"
|
||||
body={(rowData) => dateBodyTemplate(rowData, 'dateFinPrevue')}
|
||||
sortable
|
||||
style={{ width: '10rem' }}
|
||||
/>
|
||||
|
||||
<Column
|
||||
field="dureeEstimeeHeures"
|
||||
header="Durée (h)"
|
||||
sortable
|
||||
style={{ width: '8rem' }}
|
||||
body={(rowData) => (
|
||||
<span>{rowData.dureeEstimeeHeures || 0}h</span>
|
||||
)}
|
||||
/>
|
||||
|
||||
{showBudget && (
|
||||
<>
|
||||
<Column
|
||||
field="budgetPrevu"
|
||||
header="Budget prévu"
|
||||
sortable
|
||||
style={{ width: '10rem' }}
|
||||
body={budgetBodyTemplate}
|
||||
/>
|
||||
<Column
|
||||
field="coutReel"
|
||||
header="Coût réel"
|
||||
sortable
|
||||
style={{ width: '10rem' }}
|
||||
body={coutReelBodyTemplate}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Column
|
||||
header="Actions"
|
||||
style={{ width: '12rem' }}
|
||||
body={actionBodyTemplate}
|
||||
/>
|
||||
</DataTable>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhasesTable;
|
||||
386
components/phases/PhasesTimelinePreview.tsx
Normal file
386
components/phases/PhasesTimelinePreview.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Timeline } from 'primereact/timeline';
|
||||
import { Badge } from 'primereact/badge';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Accordion, AccordionTab } from 'primereact/accordion';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Tooltip } from 'primereact/tooltip';
|
||||
import chantierTemplateService from '../../services/chantierTemplateService';
|
||||
import type { TypeChantier, PhaseTemplate } from '../../types/chantier-templates';
|
||||
import type { ChantierPreview } from '../../types/chantier-form';
|
||||
|
||||
interface PhasesTimelinePreviewProps {
|
||||
typeChantier: TypeChantier;
|
||||
dateDebut: Date;
|
||||
surface?: number;
|
||||
nombreNiveaux?: number;
|
||||
inclureSousPhases?: boolean;
|
||||
ajusterDelais?: boolean;
|
||||
margeSecurite?: number;
|
||||
className?: string;
|
||||
showDetails?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
interface PhaseTimelineItem {
|
||||
id: string;
|
||||
nom: string;
|
||||
description: string;
|
||||
dateDebut: Date;
|
||||
dateFin: Date;
|
||||
duree: number;
|
||||
ordreExecution: number;
|
||||
critique: boolean;
|
||||
prerequis: string[];
|
||||
sousPhases?: PhaseTimelineItem[];
|
||||
competences: string[];
|
||||
status?: 'planned' | 'current' | 'completed' | 'late';
|
||||
}
|
||||
|
||||
const PhasesTimelinePreview: React.FC<PhasesTimelinePreviewProps> = ({
|
||||
typeChantier,
|
||||
dateDebut,
|
||||
surface,
|
||||
nombreNiveaux,
|
||||
inclureSousPhases = true,
|
||||
ajusterDelais = true,
|
||||
margeSecurite = 5,
|
||||
className = '',
|
||||
showDetails = true,
|
||||
compact = false
|
||||
}) => {
|
||||
const [timelineItems, setTimelineItems] = useState<PhaseTimelineItem[]>([]);
|
||||
const [preview, setPreview] = useState<ChantierPreview | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedPhases, setExpandedPhases] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
generateTimeline();
|
||||
}, [typeChantier, dateDebut, surface, nombreNiveaux, inclureSousPhases, ajusterDelais, margeSecurite]);
|
||||
|
||||
const generateTimeline = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const template = chantierTemplateService.getTemplate(typeChantier);
|
||||
const complexity = chantierTemplateService.analyzeComplexity(typeChantier);
|
||||
const planning = chantierTemplateService.calculatePlanning(typeChantier, dateDebut);
|
||||
|
||||
// Générer la prévisualisation
|
||||
const previewData: ChantierPreview = {
|
||||
typeChantier,
|
||||
nom: template.nom,
|
||||
dureeEstimee: template.dureeMoyenneJours,
|
||||
dateFinEstimee: planning.dateFin,
|
||||
complexite: complexity,
|
||||
phasesCount: template.phases.length,
|
||||
sousePhasesCount: template.phases.reduce((total, phase) => total + (phase.sousPhases?.length || 0), 0),
|
||||
specificites: template.specificites || [],
|
||||
reglementations: template.reglementations || []
|
||||
};
|
||||
|
||||
setPreview(previewData);
|
||||
|
||||
// Convertir les phases du template en éléments timeline
|
||||
const items: PhaseTimelineItem[] = [];
|
||||
let currentDate = new Date(dateDebut);
|
||||
|
||||
template.phases.forEach((phase, index) => {
|
||||
const adjustedDuration = ajusterDelais ?
|
||||
Math.ceil(phase.dureePrevueJours * (complexity.score / 100)) :
|
||||
phase.dureePrevueJours;
|
||||
|
||||
const phaseItem: PhaseTimelineItem = {
|
||||
id: phase.id,
|
||||
nom: phase.nom,
|
||||
description: phase.description,
|
||||
dateDebut: new Date(currentDate),
|
||||
dateFin: addDays(currentDate, adjustedDuration),
|
||||
duree: adjustedDuration,
|
||||
ordreExecution: phase.ordreExecution,
|
||||
critique: phase.critique,
|
||||
prerequis: phase.prerequis || [],
|
||||
competences: phase.competencesRequises || [],
|
||||
status: 'planned'
|
||||
};
|
||||
|
||||
if (inclureSousPhases && phase.sousPhases) {
|
||||
let sousPhaseDate = new Date(currentDate);
|
||||
phaseItem.sousPhases = phase.sousPhases.map(sousPhase => {
|
||||
const sousPhaseAdjustedDuration = ajusterDelais ?
|
||||
Math.ceil(sousPhase.dureePrevueJours * (complexity.score / 100)) :
|
||||
sousPhase.dureePrevueJours;
|
||||
|
||||
const item: PhaseTimelineItem = {
|
||||
id: sousPhase.id,
|
||||
nom: sousPhase.nom,
|
||||
description: sousPhase.description,
|
||||
dateDebut: new Date(sousPhaseDate),
|
||||
dateFin: addDays(sousPhaseDate, sousPhaseAdjustedDuration),
|
||||
duree: sousPhaseAdjustedDuration,
|
||||
ordreExecution: sousPhase.ordreExecution,
|
||||
critique: sousPhase.critique,
|
||||
prerequis: sousPhase.prerequis || [],
|
||||
competences: sousPhase.competencesRequises || [],
|
||||
status: 'planned'
|
||||
};
|
||||
|
||||
sousPhaseDate = addDays(sousPhaseDate, sousPhaseAdjustedDuration);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
items.push(phaseItem);
|
||||
currentDate = addDays(currentDate, adjustedDuration + (margeSecurite || 0));
|
||||
});
|
||||
|
||||
setTimelineItems(items);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la génération du timeline:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addDays = (date: Date, days: number): Date => {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
};
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getSeverityByStatus = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'success';
|
||||
case 'current': return 'info';
|
||||
case 'late': return 'danger';
|
||||
default: return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const getIconByPhase = (phase: PhaseTimelineItem): string => {
|
||||
if (phase.critique) return 'pi pi-exclamation-triangle';
|
||||
if (phase.competences.includes('ELECTRICITE')) return 'pi pi-bolt';
|
||||
if (phase.competences.includes('PLOMBERIE')) return 'pi pi-home';
|
||||
if (phase.competences.includes('MACONNERIE')) return 'pi pi-building';
|
||||
if (phase.competences.includes('CHARPENTE')) return 'pi pi-sitemap';
|
||||
return 'pi pi-circle';
|
||||
};
|
||||
|
||||
const renderPhaseCard = (phase: PhaseTimelineItem, isSubPhase = false) => (
|
||||
<div
|
||||
key={phase.id}
|
||||
className={`${isSubPhase ? 'ml-4 surface-100' : 'surface-card'} p-3 border-1 surface-border border-round mb-2`}
|
||||
>
|
||||
<div className="flex justify-content-between align-items-start mb-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex align-items-center gap-2 mb-1">
|
||||
<i className={getIconByPhase(phase)} />
|
||||
<span className={`font-semibold ${isSubPhase ? 'text-sm' : ''}`}>
|
||||
{phase.nom}
|
||||
</span>
|
||||
{phase.critique && (
|
||||
<Tag value="Critique" severity="danger" className="text-xs" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-600 text-sm m-0 mb-2">{phase.description}</p>
|
||||
|
||||
{/* Compétences requises */}
|
||||
{phase.competences.length > 0 && (
|
||||
<div className="flex gap-1 mb-2">
|
||||
{phase.competences.map(comp => (
|
||||
<Badge
|
||||
key={comp}
|
||||
value={comp}
|
||||
severity="info"
|
||||
className="text-xs"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-semibold text-primary">
|
||||
{phase.duree} jour{phase.duree > 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="text-xs text-600">
|
||||
{formatDate(phase.dateDebut)} - {formatDate(phase.dateFin)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prérequis */}
|
||||
{showDetails && phase.prerequis.length > 0 && (
|
||||
<div className="mt-2 p-2 surface-100 border-round">
|
||||
<div className="text-xs font-semibold text-600 mb-1">Prérequis:</div>
|
||||
<div className="text-xs text-700">
|
||||
{phase.prerequis.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sous-phases */}
|
||||
{!compact && inclureSousPhases && phase.sousPhases && phase.sousPhases.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
label={`${expandedPhases.includes(phase.id) ? 'Masquer' : 'Voir'} les sous-phases (${phase.sousPhases.length})`}
|
||||
icon={`pi pi-chevron-${expandedPhases.includes(phase.id) ? 'up' : 'down'}`}
|
||||
className="p-button-text p-button-sm"
|
||||
onClick={() => {
|
||||
setExpandedPhases(prev =>
|
||||
prev.includes(phase.id)
|
||||
? prev.filter(id => id !== phase.id)
|
||||
: [...prev, phase.id]
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{expandedPhases.includes(phase.id) && (
|
||||
<div className="mt-2">
|
||||
{phase.sousPhases.map(sousPhase =>
|
||||
renderPhaseCard(sousPhase, true)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderCompactTimeline = () => {
|
||||
const timelineData = timelineItems.map(phase => ({
|
||||
status: phase.nom,
|
||||
date: formatDate(phase.dateDebut),
|
||||
icon: getIconByPhase(phase),
|
||||
color: phase.critique ? '#ef4444' : '#3b82f6',
|
||||
phase: phase
|
||||
}));
|
||||
|
||||
return (
|
||||
<Timeline
|
||||
value={timelineData}
|
||||
opposite={(item) => (
|
||||
<div className="text-right">
|
||||
<div className="font-semibold">{item.status}</div>
|
||||
<div className="text-600 text-sm">{item.phase.duree} jour{item.phase.duree > 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
content={(item) => (
|
||||
<div>
|
||||
<div className="text-600 text-sm">{item.date}</div>
|
||||
{item.phase.critique && (
|
||||
<Tag value="Critique" severity="danger" className="text-xs mt-1" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<ProgressBar mode="indeterminate" style={{ height: '4px' }} />
|
||||
<div className="text-center mt-3">
|
||||
<span className="text-600">Génération du planning...</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Planning prévisionnel des phases"
|
||||
className={className}
|
||||
subTitle={preview ? `${preview.dureeEstimee} jours estimés • ${preview.phasesCount} phases • ${preview.sousePhasesCount} sous-phases` : undefined}
|
||||
>
|
||||
{/* Métriques rapides */}
|
||||
{preview && (
|
||||
<div className="grid mb-4">
|
||||
<div className="col-3">
|
||||
<div className="text-center p-2 border-1 surface-border border-round">
|
||||
<div className="text-lg font-bold text-primary">{preview.phasesCount}</div>
|
||||
<div className="text-600 text-sm">Phases</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-3">
|
||||
<div className="text-center p-2 border-1 surface-border border-round">
|
||||
<div className="text-lg font-bold text-primary">{preview.sousePhasesCount}</div>
|
||||
<div className="text-600 text-sm">Sous-phases</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-3">
|
||||
<div className="text-center p-2 border-1 surface-border border-round">
|
||||
<div className="text-lg font-bold text-orange-500">{preview.dureeEstimee}</div>
|
||||
<div className="text-600 text-sm">Jours</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-3">
|
||||
<div className="text-center p-2 border-1 surface-border border-round">
|
||||
<Tag
|
||||
value={preview.complexite.niveau}
|
||||
severity={
|
||||
preview.complexite.niveau === 'SIMPLE' ? 'success' :
|
||||
preview.complexite.niveau === 'MOYEN' ? 'warning' :
|
||||
'danger'
|
||||
}
|
||||
className="text-xs"
|
||||
/>
|
||||
<div className="text-600 text-sm mt-1">Complexité</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Affichage compact ou détaillé */}
|
||||
{compact ? (
|
||||
renderCompactTimeline()
|
||||
) : (
|
||||
<div className="max-h-30rem overflow-auto">
|
||||
{timelineItems.map(phase => renderPhaseCard(phase))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Légende */}
|
||||
{showDetails && (
|
||||
<div className="mt-4 p-3 surface-100 border-round">
|
||||
<div className="text-sm font-semibold text-600 mb-2">Légende:</div>
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
<div className="flex align-items-center gap-1">
|
||||
<i className="pi pi-exclamation-triangle text-red-500"></i>
|
||||
<span>Phase critique</span>
|
||||
</div>
|
||||
<div className="flex align-items-center gap-1">
|
||||
<i className="pi pi-bolt text-yellow-500"></i>
|
||||
<span>Électricité</span>
|
||||
</div>
|
||||
<div className="flex align-items-center gap-1">
|
||||
<i className="pi pi-home text-blue-500"></i>
|
||||
<span>Plomberie</span>
|
||||
</div>
|
||||
<div className="flex align-items-center gap-1">
|
||||
<i className="pi pi-building text-gray-600"></i>
|
||||
<span>Maçonnerie</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tooltip target=".phase-tooltip" />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhasesTimelinePreview;
|
||||
582
components/phases/wizard/CustomizationStep.tsx
Normal file
582
components/phases/wizard/CustomizationStep.tsx
Normal file
@@ -0,0 +1,582 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Étape 2: Personnalisation avancée avec tableau interactif
|
||||
* Interface de configuration détaillée des phases et budgets
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Button } from 'primereact/button';
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Checkbox } from 'primereact/checkbox';
|
||||
import { Calendar } from 'primereact/calendar';
|
||||
import { TabView, TabPanel } from 'primereact/tabview';
|
||||
import { Accordion, AccordionTab } from 'primereact/accordion';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Badge } from 'primereact/badge';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { Message } from 'primereact/message';
|
||||
import { Divider } from 'primereact/divider';
|
||||
import { Slider } from 'primereact/slider';
|
||||
import { ToggleButton } from 'primereact/togglebutton';
|
||||
import { PhaseTemplate, SousPhaseTemplate, WizardConfiguration } from '../PhaseGenerationWizard';
|
||||
|
||||
interface CustomizationStepProps {
|
||||
configuration: WizardConfiguration;
|
||||
onConfigurationChange: (config: WizardConfiguration) => void;
|
||||
}
|
||||
|
||||
const CustomizationStep: React.FC<CustomizationStepProps> = ({
|
||||
configuration,
|
||||
onConfigurationChange
|
||||
}) => {
|
||||
const toast = useRef<Toast>(null);
|
||||
const [activeTabIndex, setActiveTabIndex] = useState(0);
|
||||
const [expandedPhases, setExpandedPhases] = useState<Record<string, boolean>>({});
|
||||
const [editingCell, setEditingCell] = useState<string | null>(null);
|
||||
|
||||
// État local pour les modifications
|
||||
const [localPhases, setLocalPhases] = useState<PhaseTemplate[]>(
|
||||
configuration.phasesSelectionnees || []
|
||||
);
|
||||
|
||||
// Synchroniser avec la configuration parent seulement si les localPhases sont vides
|
||||
useEffect(() => {
|
||||
if (configuration.phasesSelectionnees && localPhases.length === 0) {
|
||||
console.log('🔄 Synchronisation initiale localPhases avec configuration:', configuration.phasesSelectionnees.length, 'phases');
|
||||
setLocalPhases([...configuration.phasesSelectionnees]);
|
||||
}
|
||||
}, [configuration.phasesSelectionnees, localPhases.length]);
|
||||
|
||||
// Log les changements de localPhases pour debug
|
||||
useEffect(() => {
|
||||
console.log('📋 LocalPhases mis à jour:', localPhases.length, 'phases, budget total:',
|
||||
localPhases.reduce((sum, p) => sum + (p.budgetEstime || 0), 0));
|
||||
}, [localPhases]);
|
||||
|
||||
// Mettre à jour la configuration
|
||||
const updateConfiguration = (updates: Partial<WizardConfiguration>) => {
|
||||
onConfigurationChange({
|
||||
...configuration,
|
||||
...updates
|
||||
});
|
||||
};
|
||||
|
||||
// Mettre à jour une phase
|
||||
const updatePhase = (phaseId: string, updates: Partial<PhaseTemplate>) => {
|
||||
setLocalPhases(prevPhases => {
|
||||
const updatedPhases = prevPhases.map(phase =>
|
||||
phase.id === phaseId
|
||||
? { ...phase, ...updates }
|
||||
: phase
|
||||
);
|
||||
|
||||
// Recalculer le budget et la durée globale
|
||||
const budgetTotal = updatedPhases.reduce((sum, p) => sum + (p.budgetEstime || 0), 0);
|
||||
const dureeTotal = updatedPhases.reduce((sum, p) => sum + (p.dureeEstimee || 0), 0);
|
||||
|
||||
console.log('💰 Budget mis à jour:', { phaseId, updates, budgetTotal, dureeTotal });
|
||||
|
||||
// Mettre à jour la configuration avec les nouvelles données
|
||||
setTimeout(() => {
|
||||
updateConfiguration({
|
||||
phasesSelectionnees: updatedPhases,
|
||||
budgetGlobal: budgetTotal,
|
||||
dureeGlobale: dureeTotal
|
||||
});
|
||||
}, 0);
|
||||
|
||||
return updatedPhases;
|
||||
});
|
||||
};
|
||||
|
||||
// Mettre à jour une sous-phase
|
||||
const updateSousPhase = (phaseId: string, sousPhaseId: string, updates: Partial<SousPhaseTemplate>) => {
|
||||
setLocalPhases(prevPhases => {
|
||||
const updatedPhases = prevPhases.map(phase => {
|
||||
if (phase.id === phaseId) {
|
||||
const updatedSousPhases = phase.sousPhases.map(sp =>
|
||||
sp.id === sousPhaseId ? { ...sp, ...updates } : sp
|
||||
);
|
||||
return { ...phase, sousPhases: updatedSousPhases };
|
||||
}
|
||||
return phase;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
updateConfiguration({ phasesSelectionnees: updatedPhases });
|
||||
}, 0);
|
||||
|
||||
return updatedPhases;
|
||||
});
|
||||
};
|
||||
|
||||
// Templates pour le tableau des phases
|
||||
const checkboxTemplate = (rowData: PhaseTemplate) => (
|
||||
<Checkbox
|
||||
checked={localPhases.some(p => p.id === rowData.id)}
|
||||
onChange={(e) => {
|
||||
setLocalPhases(prevPhases => {
|
||||
const newPhases = e.checked
|
||||
? [...prevPhases, rowData]
|
||||
: prevPhases.filter(p => p.id !== rowData.id);
|
||||
|
||||
setTimeout(() => {
|
||||
updateConfiguration({ phasesSelectionnees: newPhases });
|
||||
}, 0);
|
||||
|
||||
return newPhases;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const nomTemplate = (rowData: PhaseTemplate) => (
|
||||
<div className="flex align-items-center gap-2">
|
||||
<Badge value={rowData.ordre} severity="info" />
|
||||
<div>
|
||||
<div className="font-semibold">{rowData.nom}</div>
|
||||
<small className="text-color-secondary">{rowData.description}</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const categorieTemplate = (rowData: PhaseTemplate) => (
|
||||
<Tag
|
||||
value={rowData.categorieMetier}
|
||||
severity={
|
||||
rowData.categorieMetier === 'GROS_OEUVRE' ? 'warning' :
|
||||
rowData.categorieMetier === 'SECOND_OEUVRE' ? 'info' :
|
||||
rowData.categorieMetier === 'FINITIONS' ? 'success' :
|
||||
rowData.categorieMetier === 'EQUIPEMENTS' ? 'danger' : 'secondary'
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const dureeTemplate = (rowData: PhaseTemplate) => {
|
||||
const isEditing = editingCell === `duree_${rowData.id}`;
|
||||
|
||||
return isEditing ? (
|
||||
<InputNumber
|
||||
value={rowData.dureeEstimee}
|
||||
onValueChange={(e) => updatePhase(rowData.id, { dureeEstimee: e.value || 0 })}
|
||||
onBlur={() => setEditingCell(null)}
|
||||
suffix=" j"
|
||||
min={1}
|
||||
max={365}
|
||||
autoFocus
|
||||
className="w-full"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="cursor-pointer hover:surface-hover p-2 border-round"
|
||||
onClick={() => setEditingCell(`duree_${rowData.id}`)}
|
||||
>
|
||||
<span className="font-semibold">{rowData.dureeEstimee}</span>
|
||||
<small className="text-color-secondary ml-1">jours</small>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const budgetTemplate = (rowData: PhaseTemplate) => {
|
||||
const isEditing = editingCell === `budget_${rowData.id}`;
|
||||
|
||||
return isEditing ? (
|
||||
<InputNumber
|
||||
value={rowData.budgetEstime}
|
||||
onValueChange={(e) => {
|
||||
const newValue = e.value || 0;
|
||||
console.log('💰 Modification budget phase:', rowData.id, 'ancien:', rowData.budgetEstime, 'nouveau:', newValue);
|
||||
updatePhase(rowData.id, { budgetEstime: newValue });
|
||||
}}
|
||||
onBlur={() => {
|
||||
console.log('💰 Fin édition budget phase:', rowData.id);
|
||||
setEditingCell(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
setEditingCell(null);
|
||||
}
|
||||
}}
|
||||
mode="decimal"
|
||||
minFractionDigits={0}
|
||||
maxFractionDigits={2}
|
||||
min={0}
|
||||
placeholder="0"
|
||||
autoFocus
|
||||
className="w-full"
|
||||
style={{ textAlign: 'right' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="cursor-pointer hover:surface-hover p-2 border-round flex align-items-center gap-2"
|
||||
onClick={() => {
|
||||
console.log('💰 Début édition budget phase:', rowData.id);
|
||||
setEditingCell(`budget_${rowData.id}`);
|
||||
}}
|
||||
title="Cliquer pour modifier le budget"
|
||||
>
|
||||
<span>
|
||||
{new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0
|
||||
}).format(rowData.budgetEstime)}
|
||||
</span>
|
||||
<i className="pi pi-pencil text-xs text-color-secondary"></i>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const competencesTemplate = (rowData: PhaseTemplate) => (
|
||||
<div className="flex flex-wrap gap-1 max-w-15rem">
|
||||
{rowData.competencesRequises.slice(0, 3).map((comp, index) => (
|
||||
<Badge key={index} value={comp} severity="secondary" className="text-xs" />
|
||||
))}
|
||||
{rowData.competencesRequises.length > 3 && (
|
||||
<Badge value={`+${rowData.competencesRequises.length - 3}`} severity="info" className="text-xs" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const sousPhaseTemplate = (rowData: PhaseTemplate) => (
|
||||
<Button
|
||||
icon="pi pi-list"
|
||||
label={`${rowData.sousPhases.length} sous-phases`}
|
||||
className="p-button-text p-button-rounded p-button-sm"
|
||||
onClick={() => {
|
||||
setExpandedPhases({
|
||||
...expandedPhases,
|
||||
[rowData.id]: !expandedPhases[rowData.id]
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
// Panel d'options avancées
|
||||
const optionsAvanceesPanel = () => (
|
||||
<Card title="Options Avancées" className="mt-4">
|
||||
<div className="grid">
|
||||
<div className="col-12 md:col-6">
|
||||
<div className="field-checkbox mb-4">
|
||||
<Checkbox
|
||||
id="integrerPlanning"
|
||||
checked={configuration.optionsAvancees.integrerPlanning}
|
||||
onChange={(e) => updateConfiguration({
|
||||
optionsAvancees: {
|
||||
...configuration.optionsAvancees,
|
||||
integrerPlanning: e.checked || false
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<label htmlFor="integrerPlanning" className="ml-2">
|
||||
<strong>Intégrer au module Planning & Organisation</strong>
|
||||
<div className="text-color-secondary text-sm">
|
||||
Créer automatiquement les événements de planning
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="field-checkbox mb-4">
|
||||
<Checkbox
|
||||
id="calculerBudgetAuto"
|
||||
checked={configuration.optionsAvancees.calculerBudgetAuto}
|
||||
onChange={(e) => updateConfiguration({
|
||||
optionsAvancees: {
|
||||
...configuration.optionsAvancees,
|
||||
calculerBudgetAuto: e.checked || false
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<label htmlFor="calculerBudgetAuto" className="ml-2">
|
||||
<strong>Calcul automatique des budgets</strong>
|
||||
<div className="text-color-secondary text-sm">
|
||||
Appliquer les coefficients et marges automatiquement
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="field-checkbox mb-4">
|
||||
<Checkbox
|
||||
id="appliquerMarges"
|
||||
checked={configuration.optionsAvancees.appliquerMarges}
|
||||
onChange={(e) => updateConfiguration({
|
||||
optionsAvancees: {
|
||||
...configuration.optionsAvancees,
|
||||
appliquerMarges: e.checked || false
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<label htmlFor="appliquerMarges" className="ml-2">
|
||||
<strong>Appliquer les marges commerciales</strong>
|
||||
<div className="text-color-secondary text-sm">
|
||||
Inclure les taux de marge et aléas dans les calculs
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{configuration.optionsAvancees.appliquerMarges && (
|
||||
<div className="col-12 md:col-6">
|
||||
<h6>Configuration des Taux</h6>
|
||||
|
||||
<div className="field mb-3">
|
||||
<label htmlFor="margeCommerciale">Marge commerciale: {configuration.optionsAvancees.taux.margeCommerciale}%</label>
|
||||
<Slider
|
||||
id="margeCommerciale"
|
||||
value={configuration.optionsAvancees.taux.margeCommerciale}
|
||||
onChange={(e) => updateConfiguration({
|
||||
optionsAvancees: {
|
||||
...configuration.optionsAvancees,
|
||||
taux: {
|
||||
...configuration.optionsAvancees.taux,
|
||||
margeCommerciale: e.value as number
|
||||
}
|
||||
}
|
||||
})}
|
||||
min={0}
|
||||
max={50}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field mb-3">
|
||||
<label htmlFor="alea">Aléa/Imprévus: {configuration.optionsAvancees.taux.alea}%</label>
|
||||
<Slider
|
||||
id="alea"
|
||||
value={configuration.optionsAvancees.taux.alea}
|
||||
onChange={(e) => updateConfiguration({
|
||||
optionsAvancees: {
|
||||
...configuration.optionsAvancees,
|
||||
taux: {
|
||||
...configuration.optionsAvancees.taux,
|
||||
alea: e.value as number
|
||||
}
|
||||
}
|
||||
})}
|
||||
min={0}
|
||||
max={30}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field mb-3">
|
||||
<label htmlFor="tva">TVA: {configuration.optionsAvancees.taux.tva}%</label>
|
||||
<Slider
|
||||
id="tva"
|
||||
value={configuration.optionsAvancees.taux.tva}
|
||||
onChange={(e) => updateConfiguration({
|
||||
optionsAvancees: {
|
||||
...configuration.optionsAvancees,
|
||||
taux: {
|
||||
...configuration.optionsAvancees.taux,
|
||||
tva: e.value as number
|
||||
}
|
||||
}
|
||||
})}
|
||||
min={0}
|
||||
max={25}
|
||||
step={0.5}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// Panel de planning
|
||||
const planningPanel = () => (
|
||||
<Card title="Configuration du Planning" className="mt-4">
|
||||
<div className="grid">
|
||||
<div className="col-12 md:col-6">
|
||||
<div className="field">
|
||||
<label htmlFor="dateDebut">Date de début souhaitée</label>
|
||||
<Calendar
|
||||
id="dateDebut"
|
||||
value={configuration.dateDebutSouhaitee}
|
||||
onChange={(e) => updateConfiguration({ dateDebutSouhaitee: e.value as Date })}
|
||||
dateFormat="dd/mm/yy"
|
||||
showIcon
|
||||
className="w-full"
|
||||
placeholder="Sélectionner une date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 md:col-6">
|
||||
<div className="field">
|
||||
<label htmlFor="dureeGlobale">Durée globale estimée</label>
|
||||
<InputNumber
|
||||
id="dureeGlobale"
|
||||
value={configuration.dureeGlobale}
|
||||
onValueChange={(e) => updateConfiguration({ dureeGlobale: e.value || 0 })}
|
||||
suffix=" jours"
|
||||
min={1}
|
||||
className="w-full"
|
||||
/>
|
||||
<small className="text-color-secondary">
|
||||
Calculé automatiquement: {localPhases.reduce((sum, p) => sum + (p.dureeEstimee || 0), 0)} jours
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// Panel récapitulatif
|
||||
const recapitulatifPanel = () => {
|
||||
const budgetTotal = localPhases.reduce((sum, p) => sum + (p.budgetEstime || 0), 0);
|
||||
const dureeTotal = localPhases.reduce((sum, p) => sum + (p.dureeEstimee || 0), 0);
|
||||
const nombreSousPhases = localPhases.reduce((sum, p) => sum + p.sousPhases.length, 0);
|
||||
|
||||
console.log('📊 Récapitulatif recalculé:', {
|
||||
budgetTotal,
|
||||
dureeTotal,
|
||||
nombrePhases: localPhases.length,
|
||||
phases: localPhases.map(p => ({ id: p.id, nom: p.nom, budget: p.budgetEstime }))
|
||||
});
|
||||
|
||||
return (
|
||||
<Card title="Récapitulatif" className="mt-4">
|
||||
<div className="grid">
|
||||
<div className="col-12 md:col-3">
|
||||
<div className="text-center p-3 surface-100 border-round">
|
||||
<i className="pi pi-sitemap text-primary text-2xl mb-2"></i>
|
||||
<div className="text-color font-bold text-xl">{localPhases.length}</div>
|
||||
<small className="text-color-secondary">phases sélectionnées</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 md:col-3">
|
||||
<div className="text-center p-3 surface-100 border-round">
|
||||
<i className="pi pi-list text-primary text-2xl mb-2"></i>
|
||||
<div className="text-color font-bold text-xl">{nombreSousPhases}</div>
|
||||
<small className="text-color-secondary">sous-phases incluses</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 md:col-3">
|
||||
<div className="text-center p-3 surface-100 border-round">
|
||||
<i className="pi pi-clock text-primary text-2xl mb-2"></i>
|
||||
<div className="text-color font-bold text-xl">{dureeTotal}</div>
|
||||
<small className="text-color-secondary">jours estimés</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 md:col-3">
|
||||
<div className="text-center p-3 surface-100 border-round">
|
||||
<i className="pi pi-euro text-primary text-2xl mb-2"></i>
|
||||
<div className="text-color font-bold text-lg">
|
||||
{new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0
|
||||
}).format(budgetTotal)}
|
||||
</div>
|
||||
<small className="text-color-secondary">budget estimé</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
if (!configuration.typeChantier) {
|
||||
return (
|
||||
<Message
|
||||
severity="warn"
|
||||
text="Veuillez d'abord sélectionner un template de chantier dans l'étape précédente."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex align-items-center gap-3 mb-4">
|
||||
<i className="pi pi-cog text-primary text-2xl"></i>
|
||||
<div>
|
||||
<h4 className="m-0">Personnalisation Avancée</h4>
|
||||
<p className="m-0 text-color-secondary">
|
||||
Configurez les phases, budgets et options pour votre chantier
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabView activeIndex={activeTabIndex} onTabChange={(e) => setActiveTabIndex(e.index)}>
|
||||
<TabPanel header="Phases & Budgets" leftIcon="pi pi-table">
|
||||
<Message
|
||||
severity="info"
|
||||
text="Cliquez sur les cellules Durée et Budget pour les modifier. Cochez/décochez les phases à inclure."
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
value={configuration.typeChantier.phases}
|
||||
responsiveLayout="scroll"
|
||||
paginator={false}
|
||||
emptyMessage="Aucune phase disponible"
|
||||
className="p-datatable-sm"
|
||||
>
|
||||
<Column
|
||||
header="Inclure"
|
||||
body={checkboxTemplate}
|
||||
style={{ width: '80px', textAlign: 'center' }}
|
||||
/>
|
||||
<Column
|
||||
field="nom"
|
||||
header="Phase"
|
||||
body={nomTemplate}
|
||||
style={{ minWidth: '250px' }}
|
||||
/>
|
||||
<Column
|
||||
field="categorieMetier"
|
||||
header="Catégorie"
|
||||
body={categorieTemplate}
|
||||
style={{ width: '150px' }}
|
||||
/>
|
||||
<Column
|
||||
field="dureeEstimee"
|
||||
header="Durée"
|
||||
body={dureeTemplate}
|
||||
style={{ width: '120px' }}
|
||||
/>
|
||||
<Column
|
||||
field="budgetEstime"
|
||||
header="Budget"
|
||||
body={budgetTemplate}
|
||||
style={{ width: '150px' }}
|
||||
/>
|
||||
<Column
|
||||
field="competencesRequises"
|
||||
header="Compétences"
|
||||
body={competencesTemplate}
|
||||
style={{ width: '200px' }}
|
||||
/>
|
||||
<Column
|
||||
field="sousPhases"
|
||||
header="Sous-phases"
|
||||
body={sousPhaseTemplate}
|
||||
style={{ width: '150px' }}
|
||||
/>
|
||||
</DataTable>
|
||||
|
||||
{recapitulatifPanel()}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel header="Options Avancées" leftIcon="pi pi-cog">
|
||||
{optionsAvanceesPanel()}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel header="Planning" leftIcon="pi pi-calendar">
|
||||
{planningPanel()}
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
|
||||
<Toast ref={toast} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomizationStep;
|
||||
744
components/phases/wizard/PreviewGenerationStep.tsx
Normal file
744
components/phases/wizard/PreviewGenerationStep.tsx
Normal file
@@ -0,0 +1,744 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Étape 3 : Prévisualisation et génération avec planning intégré
|
||||
* Interface finale de validation et génération des phases
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Timeline } from 'primereact/timeline';
|
||||
import { TabView, TabPanel } from 'primereact/tabview';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Badge } from 'primereact/badge';
|
||||
import { Divider } from 'primereact/divider';
|
||||
import { Message } from 'primereact/message';
|
||||
import { Panel } from 'primereact/panel';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Calendar } from 'primereact/calendar';
|
||||
import { Accordion, AccordionTab } from 'primereact/accordion';
|
||||
import { Chart } from 'primereact/chart';
|
||||
import { WizardConfiguration } from '../PhaseGenerationWizard';
|
||||
import { Chantier } from '../../../types/btp';
|
||||
import budgetCoherenceService from '../../../services/budgetCoherenceService';
|
||||
|
||||
interface PreviewGenerationStepProps {
|
||||
configuration: WizardConfiguration;
|
||||
onConfigurationChange: (config: WizardConfiguration) => void;
|
||||
chantier: Chantier;
|
||||
}
|
||||
|
||||
interface PhasePreview {
|
||||
id: string;
|
||||
nom: string;
|
||||
ordre: number;
|
||||
dateDebut: Date;
|
||||
dateFin: Date;
|
||||
duree: number;
|
||||
budget: number;
|
||||
categorie: string;
|
||||
sousPhases: SousPhasePreview[];
|
||||
prerequis: string[];
|
||||
competences: string[];
|
||||
status: 'pending' | 'ready' | 'blocked';
|
||||
}
|
||||
|
||||
interface SousPhasePreview {
|
||||
id: string;
|
||||
nom: string;
|
||||
dateDebut: Date;
|
||||
dateFin: Date;
|
||||
duree: number;
|
||||
budget: number;
|
||||
}
|
||||
|
||||
const PreviewGenerationStep: React.FC<PreviewGenerationStepProps> = ({
|
||||
configuration,
|
||||
onConfigurationChange,
|
||||
chantier
|
||||
}) => {
|
||||
const [activeTabIndex, setActiveTabIndex] = useState(0);
|
||||
const [phasesPreview, setPhasesPreview] = useState<PhasePreview[]>([]);
|
||||
const [chartData, setChartData] = useState<any>({});
|
||||
const [validationBudget, setValidationBudget] = useState<any>(null);
|
||||
const [chargementValidation, setChargementValidation] = useState(false);
|
||||
|
||||
// Calculer la prévisualisation des phases avec dates
|
||||
useEffect(() => {
|
||||
if (configuration.phasesSelectionnees.length > 0) {
|
||||
calculatePhasesPreview();
|
||||
}
|
||||
}, [configuration.phasesSelectionnees, configuration.dateDebutSouhaitee]);
|
||||
|
||||
// Validation budgétaire séparée quand la prévisualisation change
|
||||
useEffect(() => {
|
||||
if (phasesPreview.length > 0) {
|
||||
validerBudgetPhases();
|
||||
}
|
||||
}, [phasesPreview]);
|
||||
|
||||
const calculatePhasesPreview = () => {
|
||||
const dateDebut = configuration.dateDebutSouhaitee || new Date();
|
||||
let currentDate = new Date(dateDebut);
|
||||
|
||||
const previews: PhasePreview[] = configuration.phasesSelectionnees
|
||||
.sort((a, b) => a.ordre - b.ordre)
|
||||
.map((phase) => {
|
||||
const dateDebutPhase = new Date(currentDate);
|
||||
const dateFinPhase = new Date(currentDate);
|
||||
const dureePhaseDays = phase.dureeEstimee || 1; // Défaut 1 jour si non défini
|
||||
dateFinPhase.setDate(dateFinPhase.getDate() + dureePhaseDays);
|
||||
|
||||
// Calculer les sous-phases
|
||||
let currentSousPhaseDate = new Date(dateDebutPhase);
|
||||
const sousPhases: SousPhasePreview[] = phase.sousPhases.map((sp) => {
|
||||
const debutSp = new Date(currentSousPhaseDate);
|
||||
const finSp = new Date(currentSousPhaseDate);
|
||||
const dureeSousPhasedays = sp.dureeEstimee || 1; // Défaut 1 jour si non défini
|
||||
finSp.setDate(finSp.getDate() + dureeSousPhasedays);
|
||||
|
||||
currentSousPhaseDate = finSp;
|
||||
|
||||
return {
|
||||
id: sp.id,
|
||||
nom: sp.nom,
|
||||
dateDebut: debutSp,
|
||||
dateFin: finSp,
|
||||
duree: sp.dureeEstimee || 0,
|
||||
budget: sp.budgetEstime || 0
|
||||
};
|
||||
});
|
||||
|
||||
// Déterminer le statut
|
||||
let status: 'pending' | 'ready' | 'blocked' = 'ready';
|
||||
if (phase.prerequis.length > 0) {
|
||||
// Vérifier si tous les prérequis sont satisfaits
|
||||
const prerequisSatisfaits = phase.prerequis.every(prereq =>
|
||||
configuration.phasesSelectionnees.some(p =>
|
||||
p.nom.includes(prereq) && p.ordre < phase.ordre
|
||||
)
|
||||
);
|
||||
status = prerequisSatisfaits ? 'ready' : 'blocked';
|
||||
}
|
||||
|
||||
const preview: PhasePreview = {
|
||||
id: phase.id,
|
||||
nom: phase.nom,
|
||||
ordre: phase.ordre,
|
||||
dateDebut: dateDebutPhase,
|
||||
dateFin: dateFinPhase,
|
||||
duree: phase.dureeEstimee || 0,
|
||||
budget: phase.budgetEstime || 0,
|
||||
categorie: phase.categorieMetier,
|
||||
sousPhases,
|
||||
prerequis: phase.prerequis,
|
||||
competences: phase.competencesRequises,
|
||||
status
|
||||
};
|
||||
|
||||
// Passer à la phase suivante
|
||||
currentDate = new Date(dateFinPhase);
|
||||
currentDate.setDate(currentDate.getDate() + 1); // 1 jour de battement
|
||||
|
||||
return preview;
|
||||
});
|
||||
|
||||
setPhasesPreview(previews);
|
||||
prepareChartData(previews);
|
||||
};
|
||||
|
||||
const prepareChartData = (previews: PhasePreview[]) => {
|
||||
// Données pour le graphique de répartition budgétaire
|
||||
const budgetData = {
|
||||
labels: previews.map(p => p.nom.substring(0, 15) + '...'),
|
||||
datasets: [{
|
||||
data: previews.map(p => p.budget),
|
||||
backgroundColor: [
|
||||
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0',
|
||||
'#9966FF', '#FF9F40', '#FF6384', '#C9CBCF'
|
||||
],
|
||||
borderWidth: 0
|
||||
}]
|
||||
};
|
||||
|
||||
// Données pour le planning (Gantt simplifié)
|
||||
const planningData = {
|
||||
labels: previews.map(p => p.nom.substring(0, 10)),
|
||||
datasets: [{
|
||||
label: 'Durée (jours)',
|
||||
data: previews.map(p => p.duree),
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.5)',
|
||||
borderColor: 'rgba(54, 162, 235, 1)',
|
||||
borderWidth: 1
|
||||
}]
|
||||
};
|
||||
|
||||
setChartData({ budget: budgetData, planning: planningData });
|
||||
};
|
||||
|
||||
// Validation budgétaire
|
||||
const validerBudgetPhases = async () => {
|
||||
if (phasesPreview.length === 0 || chargementValidation) return;
|
||||
|
||||
setChargementValidation(true);
|
||||
try {
|
||||
const budgetTotal = phasesPreview.reduce((sum, p) => sum + (p.budget || 0), 0);
|
||||
if (budgetTotal > 0) {
|
||||
const validation = await budgetCoherenceService.validerBudgetPhases(
|
||||
chantier.id.toString(),
|
||||
budgetTotal
|
||||
);
|
||||
setValidationBudget(validation);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erreur lors de la validation budgétaire:', error);
|
||||
setValidationBudget(null);
|
||||
} finally {
|
||||
setChargementValidation(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Templates pour les tableaux
|
||||
const statusTemplate = (rowData: PhasePreview) => {
|
||||
const severityMap = {
|
||||
'ready': 'success',
|
||||
'pending': 'warning',
|
||||
'blocked': 'danger'
|
||||
} as const;
|
||||
|
||||
const labelMap = {
|
||||
'ready': 'Prête',
|
||||
'pending': 'En attente',
|
||||
'blocked': 'Bloquée'
|
||||
};
|
||||
|
||||
return (
|
||||
<Tag
|
||||
value={labelMap[rowData.status]}
|
||||
severity={severityMap[rowData.status]}
|
||||
icon={`pi ${rowData.status === 'ready' ? 'pi-check' : rowData.status === 'blocked' ? 'pi-times' : 'pi-clock'}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const dateTemplate = (rowData: PhasePreview, field: 'dateDebut' | 'dateFin') => (
|
||||
<span>{rowData[field].toLocaleDateString('fr-FR')}</span>
|
||||
);
|
||||
|
||||
const budgetTemplate = (rowData: PhasePreview) => (
|
||||
<span className="font-semibold">
|
||||
{new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0
|
||||
}).format(rowData.budget)}
|
||||
</span>
|
||||
);
|
||||
|
||||
const sousPhaseTemplate = (rowData: PhasePreview) => (
|
||||
<Badge
|
||||
value={rowData.sousPhases.length}
|
||||
severity="info"
|
||||
className="mr-2"
|
||||
/>
|
||||
);
|
||||
|
||||
const prerequisTemplate = (rowData: PhasePreview) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{rowData.prerequis.slice(0, 2).map((prereq, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block bg-orange-50 text-orange-700 px-2 py-1 border-round text-xs"
|
||||
>
|
||||
{prereq}
|
||||
</span>
|
||||
))}
|
||||
{rowData.prerequis.length > 2 && (
|
||||
<span className="inline-block bg-gray-100 text-gray-600 px-2 py-1 border-round text-xs">
|
||||
+{rowData.prerequis.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Timeline des phases
|
||||
const timelineEvents = phasesPreview.map((phase, index) => ({
|
||||
status: phase.status === 'ready' ? 'success' : phase.status === 'blocked' ? 'danger' : 'warning',
|
||||
date: phase.dateDebut.toLocaleDateString('fr-FR'),
|
||||
icon: phase.status === 'ready' ? 'pi-check' : phase.status === 'blocked' ? 'pi-times' : 'pi-clock',
|
||||
color: phase.status === 'ready' ? '#22c55e' : phase.status === 'blocked' ? '#ef4444' : '#f59e0b',
|
||||
title: phase.nom,
|
||||
subtitle: `${phase.duree} jours • ${new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', minimumFractionDigits: 0 }).format(phase.budget)}`
|
||||
}));
|
||||
|
||||
// Statistiques récapitulatives
|
||||
const stats = {
|
||||
totalPhases: phasesPreview.length,
|
||||
totalSousPhases: phasesPreview.reduce((sum, p) => sum + p.sousPhases.length, 0),
|
||||
dureeTotal: phasesPreview.reduce((sum, p) => sum + p.duree, 0),
|
||||
budgetTotal: phasesPreview.reduce((sum, p) => sum + p.budget, 0),
|
||||
phasesReady: phasesPreview.filter(p => p.status === 'ready').length,
|
||||
phasesBlocked: phasesPreview.filter(p => p.status === 'blocked').length,
|
||||
dateDebut: phasesPreview.length > 0 ? phasesPreview[0].dateDebut : null,
|
||||
dateFin: phasesPreview.length > 0 ? phasesPreview[phasesPreview.length - 1].dateFin : null
|
||||
};
|
||||
|
||||
const budgetAvecMarges = configuration.optionsAvancees.appliquerMarges ?
|
||||
stats.budgetTotal * (1 + configuration.optionsAvancees.taux.margeCommerciale / 100) *
|
||||
(1 + configuration.optionsAvancees.taux.alea / 100) *
|
||||
(1 + configuration.optionsAvancees.taux.tva / 100) :
|
||||
stats.budgetTotal;
|
||||
|
||||
// Panel de récapitulatif exécutif
|
||||
const recapitulatifExecutif = () => (
|
||||
<Card title="Récapitulatif Exécutif" className="mb-4">
|
||||
<div className="grid">
|
||||
<div className="col-12 md:col-3">
|
||||
<div className="text-center p-3 surface-100 border-round">
|
||||
<i className="pi pi-building text-primary text-3xl mb-3"></i>
|
||||
<h6 className="text-color m-0 mb-2">Chantier</h6>
|
||||
<div className="text-color font-bold">{chantier.nom}</div>
|
||||
<small className="text-color-secondary">
|
||||
{configuration.typeChantier?.nom || chantier.typeChantier}
|
||||
</small>
|
||||
{chantier.montantPrevu && (
|
||||
<div className="text-xs text-color-secondary mt-1">
|
||||
Budget: {chantier.montantPrevu.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 md:col-3">
|
||||
<div className="text-center p-3 surface-100 border-round">
|
||||
<i className="pi pi-sitemap text-primary text-3xl mb-3"></i>
|
||||
<h6 className="text-color m-0 mb-2">Phases</h6>
|
||||
<div className="text-color font-bold text-xl">{stats.totalPhases}</div>
|
||||
<small className="text-color-secondary">+ {stats.totalSousPhases} sous-phases</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 md:col-3">
|
||||
<div className="text-center p-3 surface-100 border-round">
|
||||
<i className="pi pi-calendar text-primary text-3xl mb-3"></i>
|
||||
<h6 className="text-color m-0 mb-2">Planning</h6>
|
||||
<div className="text-color font-bold text-xl">{stats.dureeTotal}</div>
|
||||
<small className="text-color-secondary">jours ouvrés</small>
|
||||
{stats.dateDebut && stats.dateFin && (
|
||||
<div className="mt-2 text-xs text-color-secondary">
|
||||
{stats.dateDebut.toLocaleDateString('fr-FR')} → {stats.dateFin.toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 md:col-3">
|
||||
<div className="text-center p-3 surface-100 border-round">
|
||||
<i className="pi pi-euro text-primary text-3xl mb-3"></i>
|
||||
<h6 className="text-color m-0 mb-2">Budget</h6>
|
||||
<div className="text-color font-bold text-lg">
|
||||
{new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0
|
||||
}).format(budgetAvecMarges)}
|
||||
</div>
|
||||
<small className="text-color-secondary">
|
||||
{configuration.optionsAvancees.appliquerMarges ? 'avec marges' : 'hors marges'}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alertes et validations */}
|
||||
<Divider />
|
||||
<div className="grid">
|
||||
<div className="col-12 md:col-4">
|
||||
{stats.phasesBlocked > 0 ? (
|
||||
<Message
|
||||
severity="warn"
|
||||
text={`${stats.phasesBlocked} phase(s) bloquée(s) par des prérequis manquants`}
|
||||
/>
|
||||
) : (
|
||||
<Message
|
||||
severity="success"
|
||||
text="Toutes les phases sont prêtes à être générées"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-12 md:col-4">
|
||||
{chargementValidation ? (
|
||||
<Message
|
||||
severity="info"
|
||||
text="Validation budgétaire en cours..."
|
||||
/>
|
||||
) : validationBudget ? (
|
||||
<Message
|
||||
severity={validationBudget.valide ? "success" : "warn"}
|
||||
text={validationBudget.message}
|
||||
/>
|
||||
) : (
|
||||
<Message
|
||||
severity="info"
|
||||
text="Validation budgétaire indisponible"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-12 md:col-4">
|
||||
{configuration.optionsAvancees.calculerBudgetAuto ? (
|
||||
<Message
|
||||
severity="info"
|
||||
text="Budgets calculés automatiquement avec coefficients"
|
||||
/>
|
||||
) : (
|
||||
<Message
|
||||
severity="info"
|
||||
text="Budgets basés sur la saisie utilisateur"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommandation budgétaire si nécessaire */}
|
||||
{validationBudget && !validationBudget.valide && validationBudget.recommandation && (
|
||||
<div className="mt-3">
|
||||
<Message
|
||||
severity="warn"
|
||||
text={
|
||||
validationBudget.recommandation === 'METTRE_A_JOUR_CHANTIER'
|
||||
? `💡 Recommandation : Mettre à jour le budget du chantier à ${validationBudget.nouveauBudgetSuggere ? new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(validationBudget.nouveauBudgetSuggere) : 'calculer'}`
|
||||
: validationBudget.recommandation === 'AJUSTER_PHASES'
|
||||
? '💡 Recommandation : Ajuster les budgets des phases pour correspondre au budget du chantier'
|
||||
: 'Vérification budgétaire recommandée'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex align-items-center gap-3 mb-4">
|
||||
<i className="pi pi-eye text-primary text-2xl"></i>
|
||||
<div>
|
||||
<h4 className="m-0">Prévisualisation & Génération</h4>
|
||||
<p className="m-0 text-color-secondary">
|
||||
Vérifiez la configuration finale avant génération
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recapitulatifExecutif()}
|
||||
|
||||
<TabView activeIndex={activeTabIndex} onTabChange={(e) => setActiveTabIndex(e.index)}>
|
||||
<TabPanel header="Planning Détaillé" leftIcon="pi pi-calendar">
|
||||
<div className="grid">
|
||||
<div className="col-12 lg:col-8">
|
||||
<Card title="Tableau des Phases">
|
||||
<DataTable
|
||||
value={phasesPreview}
|
||||
paginator={false}
|
||||
emptyMessage="Aucune phase à afficher"
|
||||
className="p-datatable-sm"
|
||||
>
|
||||
<Column
|
||||
field="ordre"
|
||||
header="#"
|
||||
style={{ width: '60px' }}
|
||||
body={(rowData) => <Badge value={rowData.ordre} />}
|
||||
/>
|
||||
<Column
|
||||
field="nom"
|
||||
header="Phase"
|
||||
style={{ minWidth: '200px' }}
|
||||
/>
|
||||
<Column
|
||||
field="dateDebut"
|
||||
header="Début"
|
||||
body={(rowData) => dateTemplate(rowData, 'dateDebut')}
|
||||
style={{ width: '100px' }}
|
||||
/>
|
||||
<Column
|
||||
field="dateFin"
|
||||
header="Fin"
|
||||
body={(rowData) => dateTemplate(rowData, 'dateFin')}
|
||||
style={{ width: '100px' }}
|
||||
/>
|
||||
<Column
|
||||
field="duree"
|
||||
header="Durée"
|
||||
body={(rowData) => `${rowData.duree}j`}
|
||||
style={{ width: '80px' }}
|
||||
/>
|
||||
<Column
|
||||
field="budget"
|
||||
header="Budget"
|
||||
body={budgetTemplate}
|
||||
style={{ width: '120px' }}
|
||||
/>
|
||||
<Column
|
||||
field="sousPhases"
|
||||
header="S.-phases"
|
||||
body={sousPhaseTemplate}
|
||||
style={{ width: '90px' }}
|
||||
/>
|
||||
<Column
|
||||
field="status"
|
||||
header="Statut"
|
||||
body={statusTemplate}
|
||||
style={{ width: '100px' }}
|
||||
/>
|
||||
</DataTable>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="col-12 lg:col-4">
|
||||
<Card title="Timeline">
|
||||
<Timeline
|
||||
value={timelineEvents}
|
||||
content={(item) => (
|
||||
<div>
|
||||
<div className="font-semibold text-sm">{item.title}</div>
|
||||
<small className="text-color-secondary">{item.subtitle}</small>
|
||||
</div>
|
||||
)}
|
||||
className="w-full"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel header="Analyse Budgétaire" leftIcon="pi pi-chart-pie">
|
||||
<div className="grid">
|
||||
<div className="col-12 lg:col-6">
|
||||
<Card title="Répartition Budgétaire par Phase">
|
||||
{chartData.budget && (
|
||||
<Chart
|
||||
type="doughnut"
|
||||
data={chartData.budget}
|
||||
style={{ height: '300px' }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
<div className="col-12 lg:col-6">
|
||||
<Card title="Durée par Phase">
|
||||
{chartData.planning && (
|
||||
<Chart
|
||||
type="bar"
|
||||
data={chartData.planning}
|
||||
style={{ height: '300px' }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{configuration.optionsAvancees.appliquerMarges && (
|
||||
<Card title="Détail des Marges Appliquées" className="mt-4">
|
||||
<div className="grid">
|
||||
<div className="col-12 md:col-6">
|
||||
<div className="flex justify-content-between mb-2">
|
||||
<span>Budget base (phases):</span>
|
||||
<span className="font-semibold">
|
||||
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(stats.budgetTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-content-between mb-2">
|
||||
<span>+ Marge commerciale ({configuration.optionsAvancees.taux.margeCommerciale}%):</span>
|
||||
<span className="text-blue-600">
|
||||
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(
|
||||
stats.budgetTotal * configuration.optionsAvancees.taux.margeCommerciale / 100
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-content-between mb-2">
|
||||
<span>+ Aléa/Imprévus ({configuration.optionsAvancees.taux.alea}%):</span>
|
||||
<span className="text-orange-600">
|
||||
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(
|
||||
stats.budgetTotal * (1 + configuration.optionsAvancees.taux.margeCommerciale / 100) *
|
||||
configuration.optionsAvancees.taux.alea / 100
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-content-between mb-2">
|
||||
<span>+ TVA ({configuration.optionsAvancees.taux.tva}%):</span>
|
||||
<span className="text-purple-600">
|
||||
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(
|
||||
stats.budgetTotal * (1 + configuration.optionsAvancees.taux.margeCommerciale / 100) *
|
||||
(1 + configuration.optionsAvancees.taux.alea / 100) *
|
||||
configuration.optionsAvancees.taux.tva / 100
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="flex justify-content-between">
|
||||
<span className="font-bold">Total TTC:</span>
|
||||
<span className="font-bold text-green-600 text-lg">
|
||||
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(budgetAvecMarges)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 md:col-6">
|
||||
<ProgressBar
|
||||
value={85}
|
||||
showValue={false}
|
||||
className="mb-2"
|
||||
style={{ height: '20px' }}
|
||||
/>
|
||||
<small className="text-color-secondary">
|
||||
Marge totale appliquée: {(((budgetAvecMarges - stats.budgetTotal) / stats.budgetTotal) * 100).toFixed(1)}%
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel header="Cohérence Budgétaire" leftIcon="pi pi-calculator">
|
||||
<Card title="Analyse Budgétaire Détaillée">
|
||||
{chargementValidation ? (
|
||||
<div className="text-center p-4">
|
||||
<ProgressBar mode="indeterminate" style={{ height: '6px' }} />
|
||||
<p className="mt-3">Vérification de la cohérence budgétaire...</p>
|
||||
</div>
|
||||
) : validationBudget ? (
|
||||
<div>
|
||||
<div className="grid">
|
||||
<div className="col-12 md:col-6">
|
||||
<h6>Résumé de la validation</h6>
|
||||
<div className="p-3 border-round" style={{
|
||||
backgroundColor: validationBudget.valide ? '#f0f9ff' : '#fefce8',
|
||||
border: `1px solid ${validationBudget.valide ? '#0ea5e9' : '#eab308'}`
|
||||
}}>
|
||||
<div className="flex align-items-center gap-2 mb-2">
|
||||
<i className={`pi ${validationBudget.valide ? 'pi-check-circle text-green-600' : 'pi-exclamation-triangle text-yellow-600'} text-lg`}></i>
|
||||
<span className="font-semibold">
|
||||
{validationBudget.valide ? 'Budget cohérent' : 'Attention budgétaire'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="m-0 text-sm">{validationBudget.message}</p>
|
||||
|
||||
{validationBudget.recommandation && (
|
||||
<div className="mt-3 p-2 bg-white border-round">
|
||||
<strong>Recommandation :</strong>
|
||||
<br />
|
||||
{validationBudget.recommandation === 'METTRE_A_JOUR_CHANTIER' &&
|
||||
`Mettre à jour le budget du chantier à ${validationBudget.nouveauBudgetSuggere ? new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(validationBudget.nouveauBudgetSuggere) : 'calculer'}`
|
||||
}
|
||||
{validationBudget.recommandation === 'AJUSTER_PHASES' &&
|
||||
'Ajuster les budgets des phases pour correspondre au budget du chantier'
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 md:col-6">
|
||||
<h6>Répartition budgétaire</h6>
|
||||
<div className="text-sm">
|
||||
<div className="flex justify-content-between mb-2">
|
||||
<span>Budget total des phases :</span>
|
||||
<span className="font-semibold">
|
||||
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(stats.budgetTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-content-between mb-2">
|
||||
<span>Nombre de phases :</span>
|
||||
<span className="font-semibold">{stats.totalPhases}</span>
|
||||
</div>
|
||||
<div className="flex justify-content-between mb-2">
|
||||
<span>Budget moyen par phase :</span>
|
||||
<span className="font-semibold">
|
||||
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(stats.totalPhases > 0 ? stats.budgetTotal / stats.totalPhases : 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Message severity="warn" text="Validation budgétaire non disponible" />
|
||||
)}
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel header="Détails Techniques" leftIcon="pi pi-cog">
|
||||
<Accordion>
|
||||
{phasesPreview.map((phase, index) => (
|
||||
<AccordionTab
|
||||
key={phase.id}
|
||||
header={
|
||||
<div className="flex align-items-center gap-3 w-full">
|
||||
<Badge value={phase.ordre} />
|
||||
<span className="font-semibold">{phase.nom}</span>
|
||||
{statusTemplate(phase)}
|
||||
<span className="ml-auto text-color-secondary">
|
||||
{phase.duree}j • {new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(phase.budget)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid">
|
||||
<div className="col-12 md:col-6">
|
||||
<h6>Informations générales</h6>
|
||||
<div className="flex flex-column gap-2">
|
||||
<div>
|
||||
<span className="font-semibold">Catégorie:</span>
|
||||
<Tag value={phase.categorie} severity="info" className="ml-2" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Période:</span>
|
||||
<span className="ml-2">
|
||||
{phase.dateDebut.toLocaleDateString('fr-FR')} → {phase.dateFin.toLocaleDateString('fr-FR')}
|
||||
</span>
|
||||
</div>
|
||||
{phase.prerequis.length > 0 && (
|
||||
<div>
|
||||
<span className="font-semibold">Prérequis:</span>
|
||||
<div className="mt-1">
|
||||
{prerequisTemplate(phase)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 md:col-6">
|
||||
<h6>Compétences requises</h6>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{phase.competences.map((comp, idx) => (
|
||||
<Badge key={idx} value={comp} severity="info" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{phase.sousPhases.length > 0 && (
|
||||
<>
|
||||
<h6 className="mt-4">Sous-phases ({phase.sousPhases.length})</h6>
|
||||
<div className="flex flex-column gap-2">
|
||||
{phase.sousPhases.map((sp, idx) => (
|
||||
<div key={sp.id} className="p-2 bg-gray-50 border-round">
|
||||
<div className="flex justify-content-between">
|
||||
<span className="font-semibold text-sm">{sp.nom}</span>
|
||||
<span className="text-sm text-color-secondary">
|
||||
{sp.duree}j • {new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(sp.budget)}
|
||||
</span>
|
||||
</div>
|
||||
<small className="text-color-secondary">
|
||||
{sp.dateDebut.toLocaleDateString('fr-FR')} → {sp.dateFin.toLocaleDateString('fr-FR')}
|
||||
</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTab>
|
||||
))}
|
||||
</Accordion>
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreviewGenerationStep;
|
||||
497
components/phases/wizard/TemplateSelectionStep.tsx
Normal file
497
components/phases/wizard/TemplateSelectionStep.tsx
Normal file
@@ -0,0 +1,497 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Étape 1: Sélection et configuration du template de phases
|
||||
* Interface de choix du type de chantier avec preview des phases incluses
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Badge } from 'primereact/badge';
|
||||
import { Skeleton } from 'primereact/skeleton';
|
||||
import { ScrollPanel } from 'primereact/scrollpanel';
|
||||
import { Divider } from 'primereact/divider';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Message } from 'primereact/message';
|
||||
import { TypeChantierTemplate, WizardConfiguration } from '../PhaseGenerationWizard';
|
||||
import { Chantier } from '../../../types/btp';
|
||||
|
||||
interface TemplateSelectionStepProps {
|
||||
templates: TypeChantierTemplate[];
|
||||
loading: boolean;
|
||||
configuration: WizardConfiguration;
|
||||
onConfigurationChange: (config: WizardConfiguration) => void;
|
||||
chantier?: Chantier;
|
||||
}
|
||||
|
||||
const TemplateSelectionStep: React.FC<TemplateSelectionStepProps> = ({
|
||||
templates,
|
||||
loading,
|
||||
configuration,
|
||||
onConfigurationChange,
|
||||
chantier
|
||||
}) => {
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
const [categorieFilter, setCategorieFilter] = useState('');
|
||||
const [complexiteFilter, setComplexiteFilter] = useState('');
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<TypeChantierTemplate | null>(configuration.typeChantier);
|
||||
|
||||
// Options de filtrage
|
||||
const categorieOptions = [
|
||||
{ label: 'Toutes catégories', value: '' },
|
||||
{ label: 'Résidentiel', value: 'RESIDENTIEL' },
|
||||
{ label: 'Commercial', value: 'COMMERCIAL' },
|
||||
{ label: 'Industriel', value: 'INDUSTRIEL' },
|
||||
{ label: 'Infrastructure', value: 'INFRASTRUCTURE' },
|
||||
{ label: 'Rénovation', value: 'RENOVATION' }
|
||||
];
|
||||
|
||||
const complexiteOptions = [
|
||||
{ label: 'Toutes complexités', value: '' },
|
||||
{ label: 'Simple', value: 'SIMPLE' },
|
||||
{ label: 'Moyenne', value: 'MOYENNE' },
|
||||
{ label: 'Complexe', value: 'COMPLEXE' },
|
||||
{ label: 'Expert', value: 'EXPERT' }
|
||||
];
|
||||
|
||||
// Filtrer les templates
|
||||
const filteredTemplates = templates.filter(template => {
|
||||
let matches = true;
|
||||
|
||||
if (searchFilter) {
|
||||
const search = searchFilter.toLowerCase();
|
||||
matches = matches && (
|
||||
template.nom.toLowerCase().includes(search) ||
|
||||
template.description.toLowerCase().includes(search) ||
|
||||
template.tags.some(tag => tag.toLowerCase().includes(search))
|
||||
);
|
||||
}
|
||||
|
||||
if (categorieFilter) {
|
||||
matches = matches && template.categorie === categorieFilter;
|
||||
}
|
||||
|
||||
if (complexiteFilter) {
|
||||
matches = matches && template.complexiteMetier === complexiteFilter;
|
||||
}
|
||||
|
||||
return matches;
|
||||
});
|
||||
|
||||
// Sélectionner un template
|
||||
const selectTemplate = (template: TypeChantierTemplate) => {
|
||||
setSelectedTemplate(template);
|
||||
|
||||
const newConfig = {
|
||||
...configuration,
|
||||
typeChantier: template,
|
||||
phasesSelectionnees: template.phases, // Par défaut, toutes les phases sont sélectionnées
|
||||
// Préserver les valeurs du chantier si elles existent, sinon utiliser celles du template
|
||||
budgetGlobal: configuration.budgetGlobal || template.budgetGlobalEstime,
|
||||
dureeGlobale: configuration.dureeGlobale || template.dureeGlobaleEstimee
|
||||
};
|
||||
|
||||
onConfigurationChange(newConfig);
|
||||
};
|
||||
|
||||
// Template de carte de template
|
||||
const templateCard = (template: TypeChantierTemplate) => {
|
||||
const isSelected = selectedTemplate?.id === template.id;
|
||||
|
||||
const complexiteSeverityMap = {
|
||||
'SIMPLE': 'success',
|
||||
'MOYENNE': 'info',
|
||||
'COMPLEXE': 'warning',
|
||||
'EXPERT': 'danger'
|
||||
} as const;
|
||||
|
||||
const cardHeader = (
|
||||
<div className="flex justify-content-between align-items-start">
|
||||
<div className="flex-1">
|
||||
<h5 className="m-0 mb-2">{template.nom}</h5>
|
||||
<p className="text-color-secondary text-sm m-0 mb-3 line-height-3">
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<i className="pi pi-check-circle text-green-500 text-2xl ml-2"></i>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const cardFooter = (
|
||||
<div className="flex justify-content-between align-items-center">
|
||||
<div className="flex align-items-center gap-2">
|
||||
<Tag
|
||||
value={template.categorie}
|
||||
severity="info"
|
||||
className="text-xs"
|
||||
/>
|
||||
<Tag
|
||||
value={template.complexiteMetier}
|
||||
severity={complexiteSeverityMap[template.complexiteMetier]}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
label={isSelected ? "Sélectionné" : "Sélectionner"}
|
||||
icon={isSelected ? "pi pi-check" : "pi pi-arrow-right"}
|
||||
className={isSelected ? "p-button-text p-button-rounded p-button-success" : "p-button-text p-button-rounded"}
|
||||
size="small"
|
||||
onClick={() => selectTemplate(template)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={template.id}
|
||||
header={cardHeader}
|
||||
footer={cardFooter}
|
||||
className={`h-full cursor-pointer transition-all duration-200 ${
|
||||
isSelected
|
||||
? 'border-primary-500 shadow-4'
|
||||
: 'hover:shadow-2 border-transparent'
|
||||
}`}
|
||||
onClick={() => selectTemplate(template)}
|
||||
>
|
||||
<div className="grid">
|
||||
<div className="col-12 md:col-6">
|
||||
<div className="flex align-items-center gap-2 mb-2">
|
||||
<i className="pi pi-sitemap text-primary"></i>
|
||||
<span className="font-semibold">{template.nombreTotalPhases} phases</span>
|
||||
</div>
|
||||
<div className="flex align-items-center gap-2 mb-2">
|
||||
<i className="pi pi-clock text-orange-500"></i>
|
||||
<span>{template.dureeGlobaleEstimee} jours</span>
|
||||
</div>
|
||||
<div className="flex align-items-center gap-2">
|
||||
<i className="pi pi-euro text-green-500"></i>
|
||||
<span>
|
||||
{new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0
|
||||
}).format(template.budgetGlobalEstime)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 md:col-6">
|
||||
<div className="flex align-items-center gap-1 mb-2">
|
||||
<span className="text-sm text-color-secondary">Catégories:</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{template.phases.slice(0, 3).map((phase, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
value={phase.categorieMetier}
|
||||
severity="info"
|
||||
className="text-xs"
|
||||
/>
|
||||
))}
|
||||
{template.phases.length > 3 && (
|
||||
<Badge
|
||||
value={`+${template.phases.length - 3}`}
|
||||
severity="secondary"
|
||||
className="text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{template.tags.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{template.tags.slice(0, 2).map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block bg-blue-50 text-blue-700 px-2 py-1 border-round text-xs"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{template.tags.length > 2 && (
|
||||
<span className="inline-block bg-gray-100 text-gray-600 px-2 py-1 border-round text-xs">
|
||||
+{template.tags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Prévisualisation du template sélectionné
|
||||
const previewPanel = () => {
|
||||
if (!selectedTemplate) return null;
|
||||
|
||||
return (
|
||||
<Card className="mt-4">
|
||||
<div className="flex align-items-center gap-3 mb-4">
|
||||
<i className="pi pi-eye text-primary text-2xl"></i>
|
||||
<h5 className="m-0">Prévisualisation: {selectedTemplate.nom}</h5>
|
||||
</div>
|
||||
|
||||
<div className="grid">
|
||||
<div className="col-12 md:col-4">
|
||||
<Card className="bg-blue-50 h-full">
|
||||
<div className="text-center">
|
||||
<i className="pi pi-sitemap text-blue-600 text-3xl mb-3"></i>
|
||||
<h6 className="text-blue-800 m-0 mb-2">Phases incluses</h6>
|
||||
<div className="text-blue-900 font-bold text-2xl">
|
||||
{selectedTemplate.nombreTotalPhases}
|
||||
</div>
|
||||
<small className="text-blue-600">
|
||||
phases + sous-phases
|
||||
</small>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="col-12 md:col-4">
|
||||
<Card className="bg-orange-50 h-full">
|
||||
<div className="text-center">
|
||||
<i className="pi pi-clock text-orange-600 text-3xl mb-3"></i>
|
||||
<h6 className="text-orange-800 m-0 mb-2">Durée estimée</h6>
|
||||
<div className="text-orange-900 font-bold text-2xl">
|
||||
{selectedTemplate.dureeGlobaleEstimee}
|
||||
</div>
|
||||
<small className="text-orange-600">
|
||||
jours ouvrés
|
||||
</small>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="col-12 md:col-4">
|
||||
<Card className="bg-green-50 h-full">
|
||||
<div className="text-center">
|
||||
<i className="pi pi-euro text-green-600 text-3xl mb-3"></i>
|
||||
<h6 className="text-green-800 m-0 mb-2">Budget estimé</h6>
|
||||
<div className="text-green-900 font-bold text-xl">
|
||||
{new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0
|
||||
}).format(selectedTemplate.budgetGlobalEstime)}
|
||||
</div>
|
||||
<small className="text-green-600">
|
||||
estimation globale
|
||||
</small>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="grid">
|
||||
<div className="col-12 md:col-6">
|
||||
<h6 className="text-primary mb-3">
|
||||
<i className="pi pi-list mr-2"></i>
|
||||
Phases principales ({selectedTemplate.phases.length})
|
||||
</h6>
|
||||
<ScrollPanel style={{ width: '100%', height: '200px' }}>
|
||||
{selectedTemplate.phases.map((phase, index) => (
|
||||
<div key={phase.id} className="flex align-items-center gap-2 mb-2 p-2 bg-gray-50 border-round">
|
||||
<Badge value={index + 1} className="bg-primary" />
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-sm">{phase.nom}</div>
|
||||
<small className="text-color-secondary">
|
||||
{phase.dureeEstimee}j • {phase.sousPhases.length} sous-phases
|
||||
</small>
|
||||
</div>
|
||||
<Tag
|
||||
value={phase.categorieMetier}
|
||||
severity="info"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollPanel>
|
||||
</div>
|
||||
<div className="col-12 md:col-6">
|
||||
<h6 className="text-primary mb-3">
|
||||
<i className="pi pi-tags mr-2"></i>
|
||||
Caractéristiques du template
|
||||
</h6>
|
||||
<div className="flex flex-column gap-3">
|
||||
<div>
|
||||
<span className="font-semibold">Catégorie:</span>
|
||||
<Tag value={selectedTemplate.categorie} severity="info" className="ml-2" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Complexité:</span>
|
||||
<Tag
|
||||
value={selectedTemplate.complexiteMetier}
|
||||
severity={
|
||||
selectedTemplate.complexiteMetier === 'SIMPLE' ? 'success' :
|
||||
selectedTemplate.complexiteMetier === 'MOYENNE' ? 'info' :
|
||||
selectedTemplate.complexiteMetier === 'COMPLEXE' ? 'warning' : 'danger'
|
||||
}
|
||||
className="ml-2"
|
||||
/>
|
||||
</div>
|
||||
{selectedTemplate.tags.length > 0 && (
|
||||
<div>
|
||||
<span className="font-semibold mb-2 block">Tags:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedTemplate.tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block bg-blue-50 text-blue-700 px-2 py-1 border-round text-xs"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex align-items-center gap-3 mb-4">
|
||||
<i className="pi pi-th-large text-primary text-2xl"></i>
|
||||
<h4 className="m-0">Sélection du Template de Chantier</h4>
|
||||
</div>
|
||||
|
||||
{/* Informations du chantier */}
|
||||
{chantier && (
|
||||
<Card className="mb-4" style={{ backgroundColor: 'var(--surface-50)', border: '1px solid var(--primary-200)' }}>
|
||||
<div className="flex align-items-center gap-3 mb-3">
|
||||
<i className="pi pi-info-circle text-primary"></i>
|
||||
<h6 className="m-0 text-color">Informations du chantier</h6>
|
||||
</div>
|
||||
<div className="grid">
|
||||
<div className="col-12 md:col-6">
|
||||
<div className="flex flex-column gap-2">
|
||||
<div className="text-color">
|
||||
<span className="font-semibold">Nom :</span> {chantier.nom}
|
||||
</div>
|
||||
{chantier.typeChantier && (
|
||||
<div className="text-color">
|
||||
<span className="font-semibold">Type :</span> {chantier.typeChantier}
|
||||
</div>
|
||||
)}
|
||||
{chantier.surface && (
|
||||
<div className="text-color">
|
||||
<span className="font-semibold">Surface :</span> {chantier.surface} m²
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 md:col-6">
|
||||
<div className="flex flex-column gap-2">
|
||||
{chantier.montantPrevu && (
|
||||
<div className="text-color">
|
||||
<span className="font-semibold">Budget prévu :</span> {chantier.montantPrevu.toLocaleString('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{chantier.dateDebut && (
|
||||
<div className="text-color">
|
||||
<span className="font-semibold">Date de début :</span> {new Date(chantier.dateDebut).toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
)}
|
||||
{chantier.dateFinPrevue && (
|
||||
<div className="text-color">
|
||||
<span className="font-semibold">Date de fin prévue :</span> {new Date(chantier.dateFinPrevue).toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 p-2 surface-ground border-round">
|
||||
<small className="text-primary">
|
||||
<i className="pi pi-lightbulb mr-1"></i>
|
||||
Ces informations seront utilisées pour personnaliser les phases générées
|
||||
</small>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Message
|
||||
severity="info"
|
||||
text="Choisissez le type de chantier qui correspond le mieux à votre projet. Le template sélectionné déterminera les phases qui seront générées automatiquement."
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{/* Filtres */}
|
||||
<Card className="mb-4">
|
||||
<div className="grid">
|
||||
<div className="col-12 md:col-4">
|
||||
<span className="p-input-icon-left w-full">
|
||||
<i className="pi pi-search" />
|
||||
<InputText
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
placeholder="Rechercher un template..."
|
||||
className="w-full"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-12 md:col-4">
|
||||
<Dropdown
|
||||
value={categorieFilter}
|
||||
options={categorieOptions}
|
||||
onChange={(e) => setCategorieFilter(e.value)}
|
||||
placeholder="Filtrer par catégorie"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 md:col-4">
|
||||
<Dropdown
|
||||
value={complexiteFilter}
|
||||
options={complexiteOptions}
|
||||
onChange={(e) => setComplexiteFilter(e.value)}
|
||||
placeholder="Filtrer par complexité"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Liste des templates */}
|
||||
{loading ? (
|
||||
<div className="grid">
|
||||
{[1, 2, 3, 4].map((item) => (
|
||||
<div key={item} className="col-12 md:col-6 xl:col-4">
|
||||
<Card>
|
||||
<Skeleton width="100%" height="200px" />
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<Message
|
||||
severity="warn"
|
||||
text="Aucun template trouvé avec les critères de recherche actuels."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{filteredTemplates.map((template) => (
|
||||
<div key={template.id} className="col-12 md:col-6 xl:col-4">
|
||||
{templateCard(template)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prévisualisation */}
|
||||
{previewPanel()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateSelectionStep;
|
||||
136
components/ui/ActionButton.tsx
Normal file
136
components/ui/ActionButton.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
|
||||
interface ActionButtonProps {
|
||||
icon: string;
|
||||
tooltip: string;
|
||||
onClick: () => void;
|
||||
color?: 'default' | 'green' | 'blue' | 'orange' | 'red' | 'purple' | 'teal';
|
||||
disabled?: boolean;
|
||||
size?: 'small' | 'normal' | 'large';
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant de bouton d'action standardisé pour toute l'application
|
||||
* Utilise le style p-button-text avec des couleurs personnalisées
|
||||
*/
|
||||
export const ActionButton: React.FC<ActionButtonProps> = ({
|
||||
icon,
|
||||
tooltip,
|
||||
onClick,
|
||||
color = 'default',
|
||||
disabled = false,
|
||||
size = 'small'
|
||||
}) => {
|
||||
// Classes de couleur pour les icônes
|
||||
const colorClasses = {
|
||||
default: '',
|
||||
green: 'text-green-500',
|
||||
blue: 'text-blue-500',
|
||||
orange: 'text-orange-500',
|
||||
red: 'text-red-500',
|
||||
purple: 'text-purple-500',
|
||||
teal: 'text-teal-500'
|
||||
};
|
||||
|
||||
// Classes de taille
|
||||
const sizeClasses = {
|
||||
small: 'p-button-sm',
|
||||
normal: '',
|
||||
large: 'p-button-lg'
|
||||
};
|
||||
|
||||
const buttonClasses = [
|
||||
'p-button-rounded',
|
||||
'p-button-text',
|
||||
sizeClasses[size],
|
||||
colorClasses[color]
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon={icon}
|
||||
className={buttonClasses}
|
||||
tooltip={tooltip}
|
||||
tooltipOptions={{ position: 'top' }}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Boutons d'actions prédéfinis pour les cas d'usage courants
|
||||
export const ViewButton: React.FC<Omit<ActionButtonProps, 'icon' | 'color'>> = (props) => (
|
||||
<ActionButton icon="pi pi-eye" color="default" {...props} />
|
||||
);
|
||||
|
||||
export const EditButton: React.FC<Omit<ActionButtonProps, 'icon' | 'color'>> = (props) => (
|
||||
<ActionButton icon="pi pi-pencil" color="orange" {...props} />
|
||||
);
|
||||
|
||||
export const DeleteButton: React.FC<Omit<ActionButtonProps, 'icon' | 'color'>> = (props) => (
|
||||
<ActionButton icon="pi pi-trash" color="red" {...props} />
|
||||
);
|
||||
|
||||
export const StartButton: React.FC<Omit<ActionButtonProps, 'icon' | 'color'>> = (props) => (
|
||||
<ActionButton icon="pi pi-play" color="green" {...props} />
|
||||
);
|
||||
|
||||
export const CompleteButton: React.FC<Omit<ActionButtonProps, 'icon' | 'color'>> = (props) => (
|
||||
<ActionButton icon="pi pi-check" color="green" {...props} />
|
||||
);
|
||||
|
||||
export const ProgressButton: React.FC<Omit<ActionButtonProps, 'icon' | 'color'>> = (props) => (
|
||||
<ActionButton icon="pi pi-percentage" color="blue" {...props} />
|
||||
);
|
||||
|
||||
export const PauseButton: React.FC<Omit<ActionButtonProps, 'icon' | 'color'>> = (props) => (
|
||||
<ActionButton icon="pi pi-pause" color="orange" {...props} />
|
||||
);
|
||||
|
||||
export const ResumeButton: React.FC<Omit<ActionButtonProps, 'icon' | 'color'>> = (props) => (
|
||||
<ActionButton icon="pi pi-play" color="teal" {...props} />
|
||||
);
|
||||
|
||||
export const DownloadButton: React.FC<Omit<ActionButtonProps, 'icon' | 'color'>> = (props) => (
|
||||
<ActionButton icon="pi pi-download" color="blue" {...props} />
|
||||
);
|
||||
|
||||
export const PrintButton: React.FC<Omit<ActionButtonProps, 'icon' | 'color'>> = (props) => (
|
||||
<ActionButton icon="pi pi-print" color="purple" {...props} />
|
||||
);
|
||||
|
||||
export const BudgetPlanButton: React.FC<Omit<ActionButtonProps, 'icon' | 'color'>> = (props) => (
|
||||
<ActionButton icon="pi pi-calculator" color="purple" {...props} />
|
||||
);
|
||||
|
||||
export const BudgetTrackButton: React.FC<Omit<ActionButtonProps, 'icon' | 'color'>> = (props) => (
|
||||
<ActionButton icon="pi pi-chart-line" color="teal" {...props} />
|
||||
);
|
||||
|
||||
/**
|
||||
* Container pour grouper les boutons d'actions
|
||||
*/
|
||||
interface ActionButtonGroupProps {
|
||||
children: React.ReactNode;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
gap?: '1' | '2' | '3';
|
||||
}
|
||||
|
||||
export const ActionButtonGroup: React.FC<ActionButtonGroupProps> = ({
|
||||
children,
|
||||
align = 'center',
|
||||
gap = '1'
|
||||
}) => {
|
||||
const alignClasses = {
|
||||
left: 'justify-content-start',
|
||||
center: 'justify-content-center',
|
||||
right: 'justify-content-end'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex ${alignClasses[align]} gap-${gap}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
41
components/ui/CFASymbol.tsx
Normal file
41
components/ui/CFASymbol.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface CFASymbolProps {
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const CFASymbol: React.FC<CFASymbolProps> = ({
|
||||
size = 'medium',
|
||||
className = '',
|
||||
style = {}
|
||||
}) => {
|
||||
const sizeMap = {
|
||||
small: '16px',
|
||||
medium: '20px',
|
||||
large: '24px'
|
||||
};
|
||||
|
||||
const symbolStyle = {
|
||||
height: sizeMap[size],
|
||||
width: 'auto',
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'middle',
|
||||
...style
|
||||
};
|
||||
|
||||
return (
|
||||
<img
|
||||
src="/layout/images/logo/logo-cfa.png"
|
||||
alt="CFA"
|
||||
className={`cfa-symbol ${className}`}
|
||||
style={symbolStyle}
|
||||
title="Franc CFA"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CFASymbol;
|
||||
66
components/ui/LionsDevLogo.tsx
Normal file
66
components/ui/LionsDevLogo.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface LionsDevLogoProps {
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
showText?: boolean;
|
||||
}
|
||||
|
||||
const LionsDevLogo: React.FC<LionsDevLogoProps> = ({
|
||||
size = 'medium',
|
||||
className = '',
|
||||
style = {},
|
||||
showText = false
|
||||
}) => {
|
||||
const sizeMap = {
|
||||
small: '20px',
|
||||
medium: '32px',
|
||||
large: '48px'
|
||||
};
|
||||
|
||||
const logoStyle = {
|
||||
height: sizeMap[size],
|
||||
width: 'auto',
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'middle',
|
||||
...style
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`lions-dev-logo flex align-items-center ${className}`}>
|
||||
<div
|
||||
style={{
|
||||
height: sizeMap[size],
|
||||
width: sizeMap[size],
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: showText ? '8px' : '0',
|
||||
border: '1px solid #fbbf24',
|
||||
...style
|
||||
}}
|
||||
title="Développé par Lions Dev"
|
||||
>
|
||||
<span style={{
|
||||
color: '#fbbf24',
|
||||
fontWeight: 'bold',
|
||||
fontSize: size === 'small' ? '8px' : size === 'medium' ? '12px' : '16px'
|
||||
}}>
|
||||
🦁
|
||||
</span>
|
||||
</div>
|
||||
{showText && (
|
||||
<span className="text-sm text-600">
|
||||
Développé par Lions Dev
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LionsDevLogo;
|
||||
46
components/ui/LoadingSpinner.tsx
Normal file
46
components/ui/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
color?: 'primary' | 'secondary' | 'white';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
size = 'medium',
|
||||
color = 'primary',
|
||||
className = '',
|
||||
}) => {
|
||||
// Tailles du spinner
|
||||
const sizeClasses = {
|
||||
small: 'w-4 h-4',
|
||||
medium: 'w-8 h-8',
|
||||
large: 'w-12 h-12',
|
||||
};
|
||||
|
||||
// Couleurs du spinner
|
||||
const colorClasses = {
|
||||
primary: 'border-blue-600',
|
||||
secondary: 'border-gray-600',
|
||||
white: 'border-white',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
inline-block animate-spin rounded-full border-2 border-solid border-current border-r-transparent
|
||||
${sizeClasses[size]}
|
||||
${colorClasses[color]}
|
||||
${className}
|
||||
`}
|
||||
role="status"
|
||||
aria-label="Chargement..."
|
||||
>
|
||||
<span className="sr-only">Chargement...</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingSpinner;
|
||||
151
components/ui/README.md
Normal file
151
components/ui/README.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Composants UI Standardisés - BTPXpress
|
||||
|
||||
## ActionButton - Boutons d'Actions Standardisés
|
||||
|
||||
### Vue d'ensemble
|
||||
Les composants `ActionButton` fournissent une approche cohérente pour tous les boutons d'actions dans l'application BTPXpress. Ils utilisent le style `p-button-text` pour un rendu subtil et professionnel, similaire au template Atlantis React.
|
||||
|
||||
### Utilisation de base
|
||||
|
||||
```tsx
|
||||
import { ActionButton, ActionButtonGroup } from '../../components/ui/ActionButton';
|
||||
|
||||
// Bouton personnalisé
|
||||
<ActionButton
|
||||
icon="pi pi-eye"
|
||||
tooltip="Voir détails"
|
||||
onClick={() => handleView()}
|
||||
color="blue"
|
||||
/>
|
||||
|
||||
// Groupe de boutons
|
||||
<ActionButtonGroup>
|
||||
<ViewButton tooltip="Voir" onClick={() => handleView()} />
|
||||
<EditButton tooltip="Modifier" onClick={() => handleEdit()} />
|
||||
<DeleteButton tooltip="Supprimer" onClick={() => handleDelete()} />
|
||||
</ActionButtonGroup>
|
||||
```
|
||||
|
||||
### Boutons prédéfinis disponibles
|
||||
|
||||
- **ViewButton**: `pi pi-eye` - Couleur par défaut (gris)
|
||||
- **EditButton**: `pi pi-pencil` - Orange
|
||||
- **DeleteButton**: `pi pi-trash` - Rouge
|
||||
- **StartButton**: `pi pi-play` - Vert
|
||||
- **CompleteButton**: `pi pi-check` - Vert
|
||||
- **ProgressButton**: `pi pi-percentage` - Bleu
|
||||
- **PauseButton**: `pi pi-pause` - Orange
|
||||
- **ResumeButton**: `pi pi-play` - Teal
|
||||
- **DownloadButton**: `pi pi-download` - Bleu
|
||||
- **PrintButton**: `pi pi-print` - Violet
|
||||
|
||||
### Props ActionButton
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| icon | string | - | Icône PrimeIcons (ex: "pi pi-eye") |
|
||||
| tooltip | string | - | Texte du tooltip |
|
||||
| onClick | function | - | Fonction appelée au clic |
|
||||
| color | string | 'default' | Couleur: 'default', 'green', 'blue', 'orange', 'red', 'purple', 'teal' |
|
||||
| disabled | boolean | false | Désactiver le bouton |
|
||||
| size | string | 'small' | Taille: 'small', 'normal', 'large' |
|
||||
|
||||
### Props ActionButtonGroup
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| children | ReactNode | - | Boutons enfants |
|
||||
| align | string | 'center' | Alignement: 'left', 'center', 'right' |
|
||||
| gap | string | '1' | Espacement: '1', '2', '3' |
|
||||
|
||||
### Exemples d'utilisation dans les tableaux
|
||||
|
||||
#### DataTable avec actions contextuelles
|
||||
```tsx
|
||||
const actionBodyTemplate = (rowData: any) => {
|
||||
return (
|
||||
<ActionButtonGroup>
|
||||
<ViewButton
|
||||
tooltip="Voir détails"
|
||||
onClick={() => viewDetails(rowData)}
|
||||
/>
|
||||
|
||||
{rowData.status === 'DRAFT' && (
|
||||
<EditButton
|
||||
tooltip="Modifier"
|
||||
onClick={() => editItem(rowData)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rowData.canDelete && (
|
||||
<DeleteButton
|
||||
tooltip="Supprimer"
|
||||
onClick={() => confirmDelete(rowData)}
|
||||
/>
|
||||
)}
|
||||
</ActionButtonGroup>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
#### Actions en en-tête de page
|
||||
```tsx
|
||||
<ActionButtonGroup align="right" gap="2">
|
||||
<DownloadButton
|
||||
tooltip="Exporter PDF"
|
||||
onClick={handleExportPDF}
|
||||
/>
|
||||
<PrintButton
|
||||
tooltip="Imprimer"
|
||||
onClick={handlePrint}
|
||||
/>
|
||||
</ActionButtonGroup>
|
||||
```
|
||||
|
||||
### Standards visuels
|
||||
|
||||
- **Style**: Tous les boutons utilisent `p-button-text` pour un rendu subtil
|
||||
- **Forme**: Boutons ronds (`p-button-rounded`)
|
||||
- **Taille**: Par défaut petite (`p-button-sm`)
|
||||
- **Tooltips**: Toujours positionnés en haut
|
||||
- **Couleurs**: Système cohérent basé sur l'action
|
||||
- Actions neutres: Gris par défaut
|
||||
- Actions positives: Vert (démarrer, valider, compléter)
|
||||
- Actions d'information: Bleu (voir, télécharger)
|
||||
- Actions de modification: Orange (éditer, pauser)
|
||||
- Actions destructives: Rouge (supprimer)
|
||||
- Actions spéciales: Violet (imprimer), Teal (reprendre)
|
||||
|
||||
### Intégration dans les pages existantes
|
||||
|
||||
Pour appliquer ces standards dans vos pages :
|
||||
|
||||
1. **Importez les composants nécessaires**
|
||||
```tsx
|
||||
import { ActionButtonGroup, ViewButton, EditButton, DeleteButton } from '../../components/ui/ActionButton';
|
||||
```
|
||||
|
||||
2. **Remplacez les boutons existants**
|
||||
```tsx
|
||||
// Ancien style
|
||||
<Button icon="pi pi-eye" className="p-button-text" onClick={handleView} />
|
||||
<Button icon="pi pi-pencil" className="p-button-warning" onClick={handleEdit} />
|
||||
|
||||
// Nouveau style standardisé
|
||||
<ActionButtonGroup>
|
||||
<ViewButton tooltip="Voir" onClick={handleView} />
|
||||
<EditButton tooltip="Modifier" onClick={handleEdit} />
|
||||
</ActionButtonGroup>
|
||||
```
|
||||
|
||||
3. **Adaptez selon le contexte**
|
||||
- Utilisez les boutons prédéfinis quand possible
|
||||
- Créez des boutons personnalisés avec `ActionButton` si nécessaire
|
||||
- Groupez les actions logiquement avec `ActionButtonGroup`
|
||||
|
||||
### Maintenance et évolution
|
||||
|
||||
- **Cohérence**: Tous les boutons d'actions de l'application doivent utiliser ces composants
|
||||
- **Extensibilité**: Ajoutez de nouveaux boutons prédéfinis selon les besoins
|
||||
- **Accessibilité**: Les tooltips et l'état disabled sont automatiquement gérés
|
||||
- **Responsive**: Les boutons s'adaptent automatiquement aux différentes tailles d'écran
|
||||
Reference in New Issue
Block a user