Initial commit

This commit is contained in:
dahoud
2025-10-01 01:39:07 +00:00
commit b430bf3b96
826 changed files with 255287 additions and 0 deletions

View File

@@ -0,0 +1,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>
);
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>
);
};

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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';

View 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;

View 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;

View 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;

View 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()
})
})

View 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()
})
})

View 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>
);
};

View 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;

View 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;

View 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">&nbsp;</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;

View 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">&nbsp;</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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>
);
};

View 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;

View 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;

View 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
View 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