v1.0 - Version stable: multi-PC, détection UI-DETR-1, 3 modes exécution

- Frontend v4 accessible sur réseau local (192.168.1.40)
- Ports ouverts: 3002 (frontend), 5001 (backend), 5004 (dashboard)
- Ollama GPU fonctionnel
- Self-healing interactif
- Dashboard confiance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Dom
2026-01-29 11:23:51 +01:00
parent 21bfa3b337
commit a27b74cf22
1595 changed files with 412691 additions and 400 deletions

View File

@@ -0,0 +1,325 @@
/**
* Fournisseur d'Accessibilité - Conformité WCAG 2.1 niveau AA
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce composant fournit les fonctionnalités d'accessibilité nécessaires
* pour assurer la conformité aux standards WCAG 2.1 niveau AA.
*/
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { Box } from '@mui/material';
interface AccessibilitySettings {
// Préférences utilisateur
highContrast: boolean;
reducedMotion: boolean;
largeText: boolean;
screenReaderMode: boolean;
// Paramètres de navigation
focusVisible: boolean;
skipLinks: boolean;
// Paramètres d'affichage
colorBlindnessMode: 'none' | 'protanopia' | 'deuteranopia' | 'tritanopia';
fontSize: 'small' | 'medium' | 'large' | 'extra-large';
// Paramètres d'interaction
clickDelay: number; // ms
hoverDelay: number; // ms
}
interface AccessibilityContextType {
settings: AccessibilitySettings;
updateSettings: (updates: Partial<AccessibilitySettings>) => void;
announceToScreenReader: (message: string, priority?: 'polite' | 'assertive') => void;
focusElement: (elementId: string) => void;
isKeyboardNavigation: boolean;
}
const defaultSettings: AccessibilitySettings = {
highContrast: false,
reducedMotion: false,
largeText: false,
screenReaderMode: false,
focusVisible: true,
skipLinks: true,
colorBlindnessMode: 'none',
fontSize: 'medium',
clickDelay: 0,
hoverDelay: 500,
};
const AccessibilityContext = createContext<AccessibilityContextType | null>(null);
interface AccessibilityProviderProps {
children: ReactNode;
}
/**
* Fournisseur d'Accessibilité
*/
export const AccessibilityProvider: React.FC<AccessibilityProviderProps> = ({ children }) => {
const [settings, setSettings] = useState<AccessibilitySettings>(defaultSettings);
const [isKeyboardNavigation, setIsKeyboardNavigation] = useState(false);
const [screenReaderMessage, setScreenReaderMessage] = useState<{
message: string;
priority: 'polite' | 'assertive';
} | null>(null);
// Détecter les préférences système
useEffect(() => {
const mediaQueries = {
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)'),
highContrast: window.matchMedia('(prefers-contrast: high)'),
largeText: window.matchMedia('(prefers-reduced-data: reduce)'), // Approximation
};
const updateFromSystem = () => {
setSettings(prev => ({
...prev,
reducedMotion: mediaQueries.reducedMotion.matches,
highContrast: mediaQueries.highContrast.matches,
}));
};
// Écouter les changements
Object.values(mediaQueries).forEach(mq => {
mq.addEventListener('change', updateFromSystem);
});
// Initialiser
updateFromSystem();
return () => {
Object.values(mediaQueries).forEach(mq => {
mq.removeEventListener('change', updateFromSystem);
});
};
}, []);
// Détecter la navigation au clavier
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Tab') {
setIsKeyboardNavigation(true);
}
};
const handleMouseDown = () => {
setIsKeyboardNavigation(false);
};
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('mousedown', handleMouseDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('mousedown', handleMouseDown);
};
}, []);
// Appliquer les styles d'accessibilité
useEffect(() => {
const root = document.documentElement;
// Contraste élevé
if (settings.highContrast) {
root.style.setProperty('--primary-color', '#000000');
root.style.setProperty('--background-color', '#ffffff');
root.style.setProperty('--text-color', '#000000');
root.style.setProperty('--border-color', '#000000');
} else {
root.style.removeProperty('--primary-color');
root.style.removeProperty('--background-color');
root.style.removeProperty('--text-color');
root.style.removeProperty('--border-color');
}
// Taille de police
const fontSizeMap = {
'small': '14px',
'medium': '16px',
'large': '18px',
'extra-large': '20px',
};
root.style.fontSize = fontSizeMap[settings.fontSize];
// Mouvement réduit
if (settings.reducedMotion) {
root.style.setProperty('--animation-duration', '0.01ms');
root.style.setProperty('--transition-duration', '0.01ms');
} else {
root.style.removeProperty('--animation-duration');
root.style.removeProperty('--transition-duration');
}
// Focus visible
if (settings.focusVisible && isKeyboardNavigation) {
root.classList.add('keyboard-navigation');
} else {
root.classList.remove('keyboard-navigation');
}
}, [settings, isKeyboardNavigation]);
// Fonction pour mettre à jour les paramètres
const updateSettings = (updates: Partial<AccessibilitySettings>) => {
setSettings(prev => ({ ...prev, ...updates }));
};
// Fonction pour annoncer aux lecteurs d'écran
const announceToScreenReader = (message: string, priority: 'polite' | 'assertive' = 'polite') => {
setScreenReaderMessage({ message, priority });
// Effacer le message après un délai
setTimeout(() => {
setScreenReaderMessage(null);
}, 1000);
};
// Fonction pour donner le focus à un élément
const focusElement = (elementId: string) => {
const element = document.getElementById(elementId);
if (element) {
element.focus();
// Annoncer le changement de focus
const label = element.getAttribute('aria-label') || element.textContent || 'Élément';
announceToScreenReader(`Focus sur ${label}`);
}
};
const contextValue: AccessibilityContextType = {
settings,
updateSettings,
announceToScreenReader,
focusElement,
isKeyboardNavigation,
};
return (
<AccessibilityContext.Provider value={contextValue}>
{/* Liens de saut pour la navigation au clavier */}
{settings.skipLinks && (
<Box
sx={{
position: 'absolute',
top: -40,
left: 6,
zIndex: 9999,
'&:focus-within': {
top: 6,
},
}}
>
<a
href="#main-content"
style={{
background: '#000',
color: '#fff',
padding: '8px 16px',
textDecoration: 'none',
borderRadius: '4px',
}}
onFocus={() => announceToScreenReader('Lien de saut vers le contenu principal')}
>
Aller au contenu principal
</a>
</Box>
)}
{/* Zone live pour les annonces aux lecteurs d'écran */}
<div
role="status"
aria-live={screenReaderMessage?.priority || 'polite'}
aria-atomic="true"
style={{
position: 'absolute',
left: '-10000px',
width: '1px',
height: '1px',
overflow: 'hidden',
}}
>
{screenReaderMessage?.message}
</div>
{/* Contenu principal avec ID et rôle pour les liens de saut */}
<main id="main-content" role="main" tabIndex={-1}>
{children}
</main>
{/* Styles CSS d'accessibilité injectés */}
<style>{`
/* Focus visible pour la navigation au clavier */
.keyboard-navigation *:focus {
outline: 2px solid #2196f3 !important;
outline-offset: 2px !important;
}
/* Contraste élevé */
${settings.highContrast ? `
* {
background-color: var(--background-color, white) !important;
color: var(--text-color, black) !important;
border-color: var(--border-color, black) !important;
}
button, input, select, textarea {
border: 2px solid black !important;
}
` : ''}
/* Mouvement réduit */
${settings.reducedMotion ? `
*, *::before, *::after {
animation-duration: var(--animation-duration, 0.01ms) !important;
animation-iteration-count: 1 !important;
transition-duration: var(--transition-duration, 0.01ms) !important;
scroll-behavior: auto !important;
}
` : ''}
/* Filtres pour daltonisme */
${settings.colorBlindnessMode !== 'none' ? `
html {
filter: ${getColorBlindnessFilter(settings.colorBlindnessMode)};
}
` : ''}
/* Texte large */
${settings.largeText ? `
* {
font-size: 1.2em !important;
line-height: 1.5 !important;
}
` : ''}
`}</style>
</AccessibilityContext.Provider>
);
};
// Fonction utilitaire pour les filtres de daltonisme
function getColorBlindnessFilter(mode: string): string {
switch (mode) {
case 'protanopia':
return 'url(#protanopia)';
case 'deuteranopia':
return 'url(#deuteranopia)';
case 'tritanopia':
return 'url(#tritanopia)';
default:
return 'none';
}
}
// Hook pour utiliser le contexte d'accessibilité
export const useAccessibility = (): AccessibilityContextType => {
const context = useContext(AccessibilityContext);
if (!context) {
throw new Error('useAccessibility doit être utilisé dans un AccessibilityProvider');
}
return context;
};
export default AccessibilityProvider;

View File

@@ -0,0 +1,379 @@
/**
* Composant StepNode - Nœud personnalisé pour les étapes de workflow
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Composant de nœud personnalisé avec états visuels d'exécution,
* indicateurs d'erreur et style français. Optimisé pour les performances.
*
* EXTENSION VWB : Support des animations et états visuels avancés pour les actions VisionOnly.
*/
import React, { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import {
Box,
Typography,
Chip,
Tooltip,
} from '@mui/material';
import {
PlayArrow as RunningIcon,
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Warning as WarningIcon,
Pause as PauseIcon,
SkipNext as SkipNextIcon,
} from '@mui/icons-material';
// Import des types partagés
import { StepNodeData, StepExecutionState, ValidationError, StepType } from '../../types';
// Import de l'extension VWB
import VWBStepNodeExtension from './VWBStepNodeExtension';
// Import du service VWB pour la détection
// import { useVWBExecutionService } from '../../services/vwbExecutionService';
// Couleurs par état d'exécution
const executionStateColors = {
[StepExecutionState.IDLE]: '#9e9e9e',
[StepExecutionState.RUNNING]: '#2196f3',
[StepExecutionState.SUCCESS]: '#4caf50',
[StepExecutionState.ERROR]: '#f44336',
[StepExecutionState.SKIPPED]: '#9e9e9e',
[StepExecutionState.PAUSED]: '#9c27b0',
};
// Icônes par état d'exécution
const executionStateIcons = {
[StepExecutionState.IDLE]: null,
[StepExecutionState.RUNNING]: <RunningIcon fontSize="small" />,
[StepExecutionState.SUCCESS]: <SuccessIcon fontSize="small" />,
[StepExecutionState.ERROR]: <ErrorIcon fontSize="small" />,
[StepExecutionState.SKIPPED]: <SkipNextIcon fontSize="small" />,
[StepExecutionState.PAUSED]: <PauseIcon fontSize="small" />,
};
// Labels français par type d'étape
const stepTypeLabels: Record<StepType, string> = {
click: 'Cliquer',
type: 'Saisir',
wait: 'Attendre',
condition: 'Condition',
extract: 'Extraire',
scroll: 'Défiler',
navigate: 'Naviguer',
screenshot: 'Capture',
};
/**
* Composant StepNode personnalisé avec support des actions VWB
*/
const StepNode: React.FC<NodeProps> = ({ data, selected }) => {
const stepData = data as StepNodeData;
// Service VWB pour détecter les actions VWB (fonction locale)
const isVWBStep = (step: any) => Boolean(
step.data?.isVWBCatalogAction ||
step.data?.vwbActionId ||
step.action_id?.startsWith('vwb_') ||
step.action_id?.includes('catalog_')
);
// Vérification de sécurité pour les données
if (!stepData) {
return (
<Box sx={{ p: 2, border: '1px solid red', borderRadius: 1 }}>
<Typography color="error">Données manquantes</Typography>
</Box>
);
}
// Créer un objet Step temporaire pour la détection VWB
const tempStep = {
id: 'temp',
type: stepData.stepType,
name: stepData.label,
data: stepData,
position: { x: 0, y: 0 },
executionState: stepData.executionState,
validationErrors: stepData.validationErrors || []
};
// Détecter si c'est une action VWB
const isVWBAction = stepData.isVWBCatalogAction || isVWBStep(tempStep);
// Si c'est une action VWB, utiliser l'extension VWB
if (isVWBAction) {
return <VWBStepNodeExtension data={data} selected={selected} />;
}
// Sinon, utiliser le rendu standard (code existant)
return <StandardStepNode data={data} selected={selected} />;
};
/**
* Composant StepNode Standard (code existant refactorisé)
*/
interface StandardStepNodeProps {
data: any;
selected: boolean;
}
const StandardStepNode: React.FC<StandardStepNodeProps> = ({ data, selected }) => {
const stepData = data as StepNodeData;
const {
label,
stepType,
executionState = StepExecutionState.IDLE,
validationErrors = [],
isSelected = false,
isVWBCatalogAction = false,
} = stepData || {};
// Vérification de sécurité pour les données
if (!stepData) {
return (
<Box sx={{ p: 2, border: '1px solid red', borderRadius: 1 }}>
<Typography color="error">Données manquantes</Typography>
</Box>
);
}
// Déterminer la couleur de bordure selon l'état
const getBorderColor = () => {
if (validationErrors.some((e: ValidationError) => e.severity === 'error')) {
return '#f44336'; // Rouge pour les erreurs
}
if (validationErrors.some((e: ValidationError) => e.severity === 'warning')) {
return '#ff9800'; // Orange pour les avertissements
}
if (selected || isSelected) {
return '#1976d2'; // Bleu pour la sélection
}
return executionStateColors[executionState as StepExecutionState];
};
// Déterminer la couleur de fond selon l'état
const getBackgroundColor = () => {
switch (executionState) {
case StepExecutionState.RUNNING:
return '#e3f2fd';
case StepExecutionState.SUCCESS:
return '#e8f5e8';
case StepExecutionState.ERROR:
return '#ffebee';
case StepExecutionState.SKIPPED:
return '#fff3e0';
default:
return '#ffffff';
}
};
// Messages d'erreur pour le tooltip
const errorMessages = validationErrors
.filter((e: ValidationError) => e.severity === 'error')
.map((e: ValidationError) => e.message)
.join(', ');
const warningMessages = validationErrors
.filter((e: ValidationError) => e.severity === 'warning')
.map((e: ValidationError) => e.message)
.join(', ');
// Style commun pour les handles
const handleStyle = {
background: getBorderColor(),
width: 10,
height: 10,
border: '2px solid white',
};
return (
<Box
sx={{
minWidth: 120,
minHeight: 50,
maxWidth: 180,
backgroundColor: getBackgroundColor(),
border: `2px solid ${getBorderColor()}`,
borderRadius: 2,
boxShadow: selected || isSelected ? '0 4px 12px rgba(0,0,0,0.15)' : '0 2px 8px rgba(0,0,0,0.1)',
position: 'relative',
transition: 'all 0.2s ease-in-out',
'&:hover': {
boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
transform: 'translateY(-1px)',
},
}}
>
{/* Poignées de connexion - Haut (entrées) */}
<Handle
type="target"
position={Position.Top}
id="top"
style={handleStyle}
/>
{/* Poignées de connexion - Gauche (entrée alternative) */}
<Handle
type="target"
position={Position.Left}
id="left"
style={handleStyle}
/>
{/* Poignées de connexion - Droite (sortie alternative) */}
<Handle
type="source"
position={Position.Right}
id="right"
style={handleStyle}
/>
{/* Poignées de connexion - Bas (sortie principale) */}
<Handle
type="source"
position={Position.Bottom}
id="bottom"
style={handleStyle}
/>
{/* Contenu principal */}
<Box sx={{ p: 1.5 }}>
{/* En-tête avec type et état */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Chip
label={stepTypeLabels[stepType as StepType] || stepType}
size="small"
variant="outlined"
sx={{
fontSize: '0.75rem',
height: 20,
borderColor: getBorderColor(),
color: getBorderColor(),
}}
/>
{/* Badge VWB pour les actions du catalogue */}
{isVWBCatalogAction && (
<Chip
label="VWB"
size="small"
color="primary"
sx={{
fontSize: '0.65rem',
height: 16,
minWidth: 32,
'& .MuiChip-label': {
px: 0.5,
},
}}
/>
)}
</Box>
{/* Icône d'état d'exécution */}
{executionStateIcons[executionState as StepExecutionState] && (
<Box sx={{ color: executionStateColors[executionState as StepExecutionState] }}>
{executionStateIcons[executionState as StepExecutionState]}
</Box>
)}
</Box>
{/* Nom de l'étape */}
<Typography
variant="body2"
sx={{
fontWeight: 500,
color: '#333',
textAlign: 'center',
wordBreak: 'break-word',
lineHeight: 1.2,
}}
>
{label}
</Typography>
{/* Indicateurs d'erreur/avertissement */}
{validationErrors.length > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 1, gap: 0.5 }}>
{validationErrors.some((e: ValidationError) => e.severity === 'error') && (
<Tooltip title={`Erreurs: ${errorMessages}`} arrow>
<ErrorIcon sx={{ fontSize: 16, color: '#f44336' }} />
</Tooltip>
)}
{validationErrors.some((e: ValidationError) => e.severity === 'warning') && (
<Tooltip title={`Avertissements: ${warningMessages}`} arrow>
<WarningIcon sx={{ fontSize: 16, color: '#ff9800' }} />
</Tooltip>
)}
</Box>
)}
</Box>
{/* Indicateur de sélection */}
{(selected || isSelected) && (
<Box
sx={{
position: 'absolute',
top: -2,
left: -2,
right: -2,
bottom: -2,
border: '2px solid #1976d2',
borderRadius: 2,
pointerEvents: 'none',
animation: 'pulse 2s infinite',
'@keyframes pulse': {
'0%': { opacity: 0.7 },
'50%': { opacity: 1 },
'100%': { opacity: 0.7 },
},
}}
/>
)}
{/* Animation de pulsation pour l'état en cours d'exécution */}
{executionState === StepExecutionState.RUNNING && (
<Box
sx={{
position: 'absolute',
top: -4,
left: -4,
right: -4,
bottom: -4,
border: '2px solid #2196f3',
borderRadius: 2,
pointerEvents: 'none',
animation: 'runningPulse 1s infinite',
'@keyframes runningPulse': {
'0%': { opacity: 0.3, transform: 'scale(1)' },
'50%': { opacity: 0.7, transform: 'scale(1.02)' },
'100%': { opacity: 0.3, transform: 'scale(1)' },
},
}}
/>
)}
</Box>
);
};
// Mémorisation du composant StepNode pour éviter les re-rendus inutiles
export default memo(StepNode, (prevProps, nextProps) => {
// Comparaison personnalisée pour optimiser les re-rendus
const prevData = prevProps.data as StepNodeData;
const nextData = nextProps.data as StepNodeData;
return (
prevProps.id === nextProps.id &&
prevProps.selected === nextProps.selected &&
prevData?.label === nextData?.label &&
prevData?.stepType === nextData?.stepType &&
prevData?.executionState === nextData?.executionState &&
prevData?.isSelected === nextData?.isSelected &&
prevData?.isVWBCatalogAction === nextData?.isVWBCatalogAction &&
prevData?.vwbActionId === nextData?.vwbActionId &&
JSON.stringify(prevData?.validationErrors) === JSON.stringify(nextData?.validationErrors)
);
});

View File

@@ -0,0 +1,229 @@
/**
* Intégration Canvas VWB - Gestion du drag-and-drop et des actions VisionOnly
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Ce composant étend le Canvas principal avec la gestion complète des actions VWB,
* incluant le drag-and-drop depuis la Palette, la création d'étapes et la synchronisation.
*/
import React, { useCallback, useMemo } from 'react';
import { useReactFlow, Node, Edge, Connection } from '@xyflow/react';
import { Box, Alert, Snackbar } from '@mui/material';
// Import des hooks d'intégration VWB
import { useVWBStepIntegration } from '../../hooks/useVWBStepIntegration';
// Import des types
import { Step, StepData, StepExecutionState, Position } from '../../types';
import { VWBCatalogAction } from '../../types/catalog';
interface VWBCanvasIntegrationProps {
onStepAdd?: (step: Step) => void;
onStepUpdate?: (stepId: string, updates: Partial<Step>) => void;
onError?: (error: string) => void;
children: React.ReactNode;
}
/**
* Composant d'intégration Canvas VWB
*/
const VWBCanvasIntegration: React.FC<VWBCanvasIntegrationProps> = ({
onStepAdd,
onStepUpdate,
onError,
children,
}) => {
const { screenToFlowPosition } = useReactFlow();
const { state: vwbState, methods: vwbMethods } = useVWBStepIntegration();
const [notification, setNotification] = React.useState<{
open: boolean;
message: string;
severity: 'success' | 'error' | 'warning' | 'info';
}>({
open: false,
message: '',
severity: 'info',
});
// Gestionnaire de drop pour les actions VWB
const handleDrop = useCallback(async (event: React.DragEvent) => {
event.preventDefault();
try {
// Obtenir les données de drag
const dragData = event.dataTransfer.getData('application/reactflow');
if (!dragData) return;
// Calculer la position dans le flow
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
// Tenter de créer une étape VWB
const vwbStep = await vwbMethods.convertDragDataToVWBStep(dragData, position);
if (vwbStep) {
// C'est une action VWB, l'ajouter au workflow
onStepAdd?.(vwbStep);
setNotification({
open: true,
message: `Action VWB "${vwbStep.name}" ajoutée au workflow`,
severity: 'success',
});
} else {
// Pas une action VWB, laisser le Canvas principal gérer
// (Cette logique sera gérée par le composant parent)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Erreur lors de l\'ajout de l\'action VWB';
onError?.(errorMessage);
setNotification({
open: true,
message: errorMessage,
severity: 'error',
});
}
}, [screenToFlowPosition, vwbMethods, onStepAdd, onError]);
// Gestionnaire de dragover pour permettre le drop
const handleDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}, []);
// Fermer la notification
const handleCloseNotification = useCallback(() => {
setNotification(prev => ({ ...prev, open: false }));
}, []);
// Convertir une étape en nœud ReactFlow avec support VWB
const convertStepToNode = useCallback((step: Step): Node => {
const nodeData = {
label: step.name,
stepType: step.type,
executionState: step.executionState || StepExecutionState.IDLE,
validationErrors: step.validationErrors || [],
isSelected: false,
parameters: step.data.parameters,
isVWBCatalogAction: step.data.isVWBCatalogAction || false,
vwbActionId: step.data.vwbActionId,
};
return {
id: step.id,
type: 'stepNode', // Type de nœud personnalisé
position: step.position,
data: nodeData,
draggable: true,
};
}, []);
// Valider une étape VWB
const validateVWBStep = useCallback(async (step: Step) => {
if (!step.data.isVWBCatalogAction) return;
try {
const isValid = await vwbMethods.validateVWBStep(step);
if (!isValid) {
setNotification({
open: true,
message: `L'étape "${step.name}" contient des erreurs de configuration`,
severity: 'warning',
});
}
} catch (error) {
console.error('Erreur lors de la validation de l\'étape VWB:', error);
}
}, [vwbMethods]);
// Mémoriser les propriétés d'intégration
const integrationProps = useMemo(() => ({
onDrop: handleDrop,
onDragOver: handleDragOver,
convertStepToNode,
validateVWBStep,
isVWBIntegrationActive: true,
}), [handleDrop, handleDragOver, convertStepToNode, validateVWBStep]);
return (
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
{/* Passer les propriétés d'intégration aux enfants */}
{React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
return React.cloneElement(child, integrationProps);
}
return child;
})}
{/* Indicateur d'état du service VWB */}
{vwbState.error && (
<Alert
severity="warning"
sx={{
position: 'absolute',
top: 10,
right: 10,
zIndex: 1000,
maxWidth: 300,
}}
>
Service VWB indisponible : {vwbState.error}
</Alert>
)}
{/* Indicateur de chargement VWB */}
{vwbState.isLoading && (
<Box
sx={{
position: 'absolute',
top: 10,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
padding: 1,
borderRadius: 1,
boxShadow: 1,
}}
>
Chargement de l'action VWB...
</Box>
)}
{/* Notifications */}
<Snackbar
open={notification.open}
autoHideDuration={4000}
onClose={handleCloseNotification}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<Alert
onClose={handleCloseNotification}
severity={notification.severity}
sx={{ width: '100%' }}
>
{notification.message}
</Alert>
</Snackbar>
</Box>
);
};
export default VWBCanvasIntegration;
/**
* Hook pour utiliser l'intégration VWB dans un composant Canvas
*/
export const useVWBCanvasIntegration = () => {
const { state, methods } = useVWBStepIntegration();
return {
vwbState: state,
vwbMethods: methods,
isVWBAvailable: !state.error && !state.isLoading,
};
};

View File

@@ -0,0 +1,558 @@
/**
* Extension VWB pour StepNode - États visuels avancés pour les actions VisionOnly
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Cette extension ajoute des animations, indicateurs de progression et états visuels
* spécialisés pour les actions VWB avec feedback en temps réel.
*/
import React, { memo, useState, useEffect } from 'react';
import { Handle, Position } from '@xyflow/react';
import {
Box,
Typography,
Chip,
Tooltip,
LinearProgress,
CircularProgress,
Fade,
keyframes,
} from '@mui/material';
import {
PlayArrow as RunningIcon,
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Warning as WarningIcon,
Pause as PausedIcon,
Visibility as VWBIcon,
Speed as PerformanceIcon,
BugReport as DebugIcon,
Timer as TimerIcon,
} from '@mui/icons-material';
// Import des types
import {
StepNodeData,
StepExecutionState,
ValidationError,
Evidence
} from '../../types';
// Animations CSS-in-JS
const pulseAnimation = keyframes`
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.05);
opacity: 0.8;
}
100% {
transform: scale(1);
opacity: 1;
}
`;
const glowAnimation = keyframes`
0% {
box-shadow: 0 0 5px rgba(33, 150, 243, 0.5);
}
50% {
box-shadow: 0 0 20px rgba(33, 150, 243, 0.8);
}
100% {
box-shadow: 0 0 5px rgba(33, 150, 243, 0.5);
}
`;
const successPulse = keyframes`
0% {
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(76, 175, 80, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0);
}
`;
const errorShake = keyframes`
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-2px); }
20%, 40%, 60%, 80% { transform: translateX(2px); }
`;
// Interface étendue pour les données VWB
interface VWBStepNodeData extends StepNodeData {
// Données d'exécution VWB
executionProgress?: number;
executionDuration?: number;
evidence?: Evidence[];
vwbActionId?: string;
isVWBStep?: boolean;
// États d'exécution avancés
isPaused?: boolean;
isDebugging?: boolean;
retryCount?: number;
// Métriques de performance
averageExecutionTime?: number;
successRate?: number;
lastExecutionTime?: number;
}
// Couleurs étendues pour les états VWB
const vwbExecutionStateColors = {
[StepExecutionState.IDLE]: '#9e9e9e',
[StepExecutionState.RUNNING]: '#2196f3',
[StepExecutionState.SUCCESS]: '#4caf50',
[StepExecutionState.ERROR]: '#f44336',
[StepExecutionState.SKIPPED]: '#ff9800',
[StepExecutionState.PAUSED]: '#9c27b0',
};
// Icônes étendues pour les états VWB
const vwbExecutionStateIcons = {
[StepExecutionState.IDLE]: null,
[StepExecutionState.RUNNING]: <RunningIcon fontSize="small" />,
[StepExecutionState.SUCCESS]: <SuccessIcon fontSize="small" />,
[StepExecutionState.ERROR]: <ErrorIcon fontSize="small" />,
[StepExecutionState.SKIPPED]: <WarningIcon fontSize="small" />,
[StepExecutionState.PAUSED]: <PausedIcon fontSize="small" />,
};
/**
* Composant StepNode étendu pour les actions VWB
*/
interface VWBStepNodeExtensionProps {
data: any;
selected: boolean;
}
const VWBStepNodeExtension: React.FC<VWBStepNodeExtensionProps> = ({ data, selected }) => {
const stepData = data as VWBStepNodeData;
const [showProgress, setShowProgress] = useState(false);
const [animationKey, setAnimationKey] = useState(0);
const {
label,
stepType,
executionState = StepExecutionState.IDLE,
validationErrors = [],
isSelected = false,
isVWBCatalogAction = false,
isVWBStep = false,
executionProgress = 0,
executionDuration = 0,
evidence = [],
vwbActionId,
isPaused = false,
isDebugging = false,
retryCount = 0,
averageExecutionTime,
successRate,
} = stepData || {};
// Déclencher les animations lors des changements d'état
useEffect(() => {
if (executionState === StepExecutionState.RUNNING) {
setShowProgress(true);
} else {
setShowProgress(false);
}
// Déclencher une nouvelle animation
setAnimationKey(prev => prev + 1);
}, [executionState]);
// Vérification de sécurité
if (!stepData) {
return (
<Box sx={{ p: 2, border: '1px solid red', borderRadius: 1 }}>
<Typography color="error">Données manquantes</Typography>
</Box>
);
}
// Déterminer si c'est une action VWB
const isVWBAction = isVWBCatalogAction || isVWBStep || Boolean(vwbActionId);
// Couleur de bordure avec logique VWB
const getBorderColor = () => {
if (validationErrors.some((e: ValidationError) => e.severity === 'error')) {
return '#f44336';
}
if (validationErrors.some((e: ValidationError) => e.severity === 'warning')) {
return '#ff9800';
}
if (selected || isSelected) {
return isVWBAction ? '#1976d2' : '#1976d2';
}
return vwbExecutionStateColors[executionState as StepExecutionState];
};
// Couleur de fond avec animations
const getBackgroundColor = () => {
switch (executionState) {
case StepExecutionState.RUNNING:
return isVWBAction ? '#e3f2fd' : '#e3f2fd';
case StepExecutionState.SUCCESS:
return isVWBAction ? '#e8f5e8' : '#e8f5e8';
case StepExecutionState.ERROR:
return '#ffebee';
case StepExecutionState.PAUSED:
return '#f3e5f5';
case StepExecutionState.SKIPPED:
return '#fff3e0';
default:
return isVWBAction ? '#fafafa' : '#ffffff';
}
};
// Styles d'animation selon l'état
const getAnimationStyles = () => {
const baseStyles = {
transition: 'all 0.3s ease-in-out',
};
switch (executionState) {
case StepExecutionState.RUNNING:
return {
...baseStyles,
animation: `${pulseAnimation} 2s infinite, ${glowAnimation} 3s infinite`,
};
case StepExecutionState.SUCCESS:
return {
...baseStyles,
animation: `${successPulse} 1s ease-out`,
};
case StepExecutionState.ERROR:
return {
...baseStyles,
animation: `${errorShake} 0.5s ease-in-out`,
};
default:
return baseStyles;
}
};
// Messages d'erreur pour les tooltips
const errorMessages = validationErrors
.filter((e: ValidationError) => e.severity === 'error')
.map((e: ValidationError) => e.message)
.join(', ');
const warningMessages = validationErrors
.filter((e: ValidationError) => e.severity === 'warning')
.map((e: ValidationError) => e.message)
.join(', ');
// Formater la durée
const formatDuration = (ms: number): string => {
if (ms < 1000) return `${ms}ms`;
const seconds = Math.floor(ms / 1000);
return `${seconds}s`;
};
// Contenu du tooltip étendu
const getTooltipContent = () => {
const parts = [];
if (isVWBAction) {
parts.push(`Action VWB: ${vwbActionId || stepType}`);
}
if (executionDuration > 0) {
parts.push(`Durée: ${formatDuration(executionDuration)}`);
}
if (evidence.length > 0) {
parts.push(`${evidence.length} Evidence générées`);
}
if (retryCount > 0) {
parts.push(`${retryCount} tentatives`);
}
if (successRate !== undefined) {
parts.push(`Taux de succès: ${Math.round(successRate)}%`);
}
if (errorMessages) {
parts.push(`Erreurs: ${errorMessages}`);
}
if (warningMessages) {
parts.push(`Avertissements: ${warningMessages}`);
}
return parts.join('\n');
};
// Style commun pour les handles
const handleStyle = {
background: getBorderColor(),
width: 10,
height: 10,
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
};
return (
<Tooltip title={getTooltipContent()} arrow placement="top">
<Box
key={animationKey}
sx={{
minWidth: isVWBAction ? 140 : 120,
minHeight: isVWBAction ? 55 : 50,
maxWidth: 180,
backgroundColor: getBackgroundColor(),
border: `2px solid ${getBorderColor()}`,
borderRadius: 2,
boxShadow: selected || isSelected
? '0 4px 12px rgba(0,0,0,0.15)'
: '0 2px 8px rgba(0,0,0,0.1)',
position: 'relative',
overflow: 'hidden',
...getAnimationStyles(),
'&:hover': {
boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
transform: 'translateY(-1px)',
},
}}
>
{/* Barre de progression pour les actions en cours */}
{showProgress && executionState === StepExecutionState.RUNNING && (
<Box sx={{ position: 'absolute', top: 0, left: 0, right: 0 }}>
<LinearProgress
variant={executionProgress > 0 ? 'determinate' : 'indeterminate'}
value={executionProgress}
sx={{
height: 3,
backgroundColor: 'rgba(0,0,0,0.1)',
'& .MuiLinearProgress-bar': {
backgroundColor: isVWBAction ? '#1976d2' : '#2196f3',
},
}}
/>
</Box>
)}
{/* Poignées de connexion - Haut (entrée principale) */}
<Handle
type="target"
position={Position.Top}
id="top"
style={handleStyle}
/>
{/* Poignées de connexion - Gauche (entrée alternative) */}
<Handle
type="target"
position={Position.Left}
id="left"
style={handleStyle}
/>
{/* Poignées de connexion - Droite (sortie alternative) */}
<Handle
type="source"
position={Position.Right}
id="right"
style={handleStyle}
/>
{/* Poignées de connexion - Bas (sortie principale) */}
<Handle
type="source"
position={Position.Bottom}
id="bottom"
style={handleStyle}
/>
{/* Contenu principal */}
<Box sx={{ p: 1.5, pt: showProgress ? 2 : 1.5 }}>
{/* En-tête avec type et badges */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Chip
label={stepType}
size="small"
variant="outlined"
sx={{
fontSize: '0.75rem',
height: 20,
borderColor: getBorderColor(),
color: getBorderColor(),
}}
/>
{/* Badge VWB */}
{isVWBAction && (
<Chip
label="VWB"
size="small"
color="primary"
icon={<VWBIcon sx={{ fontSize: '0.7rem' }} />}
sx={{
fontSize: '0.65rem',
height: 18,
minWidth: 40,
'& .MuiChip-label': {
px: 0.5,
},
}}
/>
)}
{/* Badge Debug */}
{isDebugging && (
<Chip
label="DEBUG"
size="small"
color="warning"
icon={<DebugIcon sx={{ fontSize: '0.6rem' }} />}
sx={{
fontSize: '0.6rem',
height: 16,
}}
/>
)}
{/* Badge Evidence */}
{evidence.length > 0 && (
<Chip
label={evidence.length}
size="small"
color="info"
sx={{
fontSize: '0.6rem',
height: 16,
minWidth: 20,
}}
/>
)}
</Box>
{/* Métriques de performance pour VWB */}
{isVWBAction && (averageExecutionTime || successRate !== undefined) && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{averageExecutionTime && (
<Chip
label={formatDuration(averageExecutionTime)}
size="small"
variant="outlined"
icon={<TimerIcon sx={{ fontSize: '0.6rem' }} />}
sx={{
fontSize: '0.6rem',
height: 16,
color: 'text.secondary',
borderColor: 'text.secondary',
}}
/>
)}
{successRate !== undefined && (
<Chip
label={`${Math.round(successRate)}%`}
size="small"
variant="outlined"
icon={<PerformanceIcon sx={{ fontSize: '0.6rem' }} />}
sx={{
fontSize: '0.6rem',
height: 16,
color: successRate >= 90 ? 'success.main' : 'warning.main',
borderColor: successRate >= 90 ? 'success.main' : 'warning.main',
}}
/>
)}
</Box>
)}
</Box>
{/* Icône d'état avec animation */}
<Fade in={Boolean(vwbExecutionStateIcons[executionState as StepExecutionState])} timeout={300}>
<Box sx={{
color: vwbExecutionStateColors[executionState as StepExecutionState],
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}>
{executionState === StepExecutionState.RUNNING && (
<CircularProgress size={16} thickness={4} />
)}
{vwbExecutionStateIcons[executionState as StepExecutionState]}
</Box>
</Fade>
</Box>
{/* Label de l'étape */}
<Typography
variant="body2"
sx={{
fontWeight: 500,
color: 'text.primary',
lineHeight: 1.2,
wordBreak: 'break-word',
fontSize: isVWBAction ? '0.85rem' : '0.8rem',
}}
>
{label}
</Typography>
{/* Informations d'exécution en temps réel */}
{executionState === StepExecutionState.RUNNING && executionDuration > 0 && (
<Typography
variant="caption"
sx={{
color: 'text.secondary',
display: 'block',
mt: 0.5,
fontSize: '0.7rem',
}}
>
{formatDuration(executionDuration)}
{executionProgress > 0 && ` (${Math.round(executionProgress)}%)`}
</Typography>
)}
{/* Compteur de retry */}
{retryCount > 0 && (
<Typography
variant="caption"
sx={{
color: 'warning.main',
display: 'block',
mt: 0.5,
fontSize: '0.7rem',
}}
>
Tentative {retryCount}
</Typography>
)}
</Box>
{/* Indicateur de pause */}
{isPaused && (
<Box
sx={{
position: 'absolute',
top: 4,
right: 4,
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: 'warning.main',
animation: `${pulseAnimation} 1s infinite`,
}}
/>
)}
</Box>
</Tooltip>
);
};
export default memo(VWBStepNodeExtension);

View File

@@ -0,0 +1,709 @@
/**
* Composant Canvas Principal - Interface de création de workflows visuels
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce composant gère le rendu visuel des workflows avec @xyflow/react,
* la sélection d'étapes, le déplacement en temps réel et la minimap.
* Optimisé pour maintenir 60fps lors des interactions.
*/
import React, { useCallback, useEffect, useMemo, useRef, memo } from 'react';
import {
ReactFlow,
Node,
Edge,
addEdge,
Connection,
useNodesState,
useEdgesState,
Controls,
MiniMap,
Background,
BackgroundVariant,
NodeTypes,
EdgeTypes,
MarkerType,
useReactFlow,
} from '@xyflow/react';
import { Box, Paper, Typography } from '@mui/material';
import '@xyflow/react/dist/style.css';
// Composants de nœuds personnalisés
import StepNode from './StepNode';
// Import des types partagés
import {
CanvasProps,
Step,
StepExecutionState,
StepNodeData,
StepType
} from '../../types';
// Types de nœuds personnalisés - mémorisés pour éviter les re-créations
const nodeTypes: NodeTypes = {
stepNode: StepNode,
};
// Types d'arêtes personnalisés - mémorisés pour éviter les re-créations
const edgeTypes: EdgeTypes = {};
// Options par défaut pour les arêtes - mémorisées pour éviter les re-créations
const defaultEdgeOptions = {
type: 'smoothstep',
markerEnd: {
type: MarkerType.ArrowClosed,
width: 20,
height: 20,
color: '#1976d2',
},
style: {
strokeWidth: 2,
stroke: '#1976d2',
},
// Permettre la sélection et suppression des edges
focusable: true,
deletable: true,
// Style au survol
interactionWidth: 20, // Zone de clic plus large
};
// Styles mémorisés pour éviter les re-créations d'objets
const canvasStyles = {
width: '100%',
height: '100%',
backgroundColor: '#fafafa',
position: 'relative' as const,
// Styles pour les flèches (edges) sélectionnées - ROUGE VIVE pour éviter confusion
'& .react-flow__edge.selected .react-flow__edge-path': {
stroke: '#f44336 !important', // Rouge Material UI
strokeWidth: '4px !important',
},
'& .react-flow__edge.selected .react-flow__edge-interaction': {
stroke: '#f44336 !important',
},
'& .react-flow__edge.selected marker': {
fill: '#f44336 !important',
},
// Style au survol des edges pour feedback visuel
'& .react-flow__edge:hover .react-flow__edge-path': {
stroke: '#ff9800 !important', // Orange au survol
strokeWidth: '3px !important',
},
'& .react-flow__edge:hover marker': {
fill: '#ff9800 !important',
},
} as const;
const paperStyles = {
width: '100%',
height: '100%',
overflow: 'hidden',
position: 'relative' as const,
} as const;
const emptyStateBoxStyles = {
position: 'absolute' as const,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center' as const,
zIndex: 1000,
pointerEvents: 'none' as const,
} as const;
const emptyStatePaperStyles = {
p: 4,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
backdropFilter: 'blur(4px)',
} as const;
const executionIndicatorBoxStyles = {
position: 'absolute' as const,
top: 16,
right: 16,
zIndex: 1000,
} as const;
const executionIndicatorPaperStyles = {
p: 2,
backgroundColor: '#2196f3',
color: 'white',
display: 'flex',
alignItems: 'center',
gap: 1,
} as const;
const executionIndicatorAnimationStyles = {
width: 12,
height: 12,
borderRadius: '50%',
backgroundColor: 'white',
animation: 'pulse 1s infinite',
'@keyframes pulse': {
'0%': { opacity: 1 },
'50%': { opacity: 0.5 },
'100%': { opacity: 1 },
},
} as const;
/**
* Composant Canvas principal pour l'édition de workflows
*/
const Canvas: React.FC<CanvasProps> = ({
workflow,
selectedStep,
executionState,
onStepSelect,
onStepMove,
onConnection,
onConnectionDelete,
onStepAdd,
onStepDelete,
}) => {
const { fitView, getViewport } = useReactFlow();
// Conversion des étapes du workflow en nœuds ReactFlow
const initialNodes: Node[] = useMemo(() => {
if (!workflow?.steps) return [];
return workflow.steps.map((step) => ({
id: step.id,
type: 'stepNode',
position: step.position,
data: {
label: step.name,
stepType: step.type,
executionState: step.executionState || StepExecutionState.IDLE,
validationErrors: step.validationErrors || [],
isSelected: selectedStep?.id === step.id,
parameters: step.data?.parameters || {},
// Préserver les flags VWB
isVWBCatalogAction: step.data?.isVWBCatalogAction || false,
vwbActionId: step.data?.vwbActionId || undefined,
} as StepNodeData,
selected: selectedStep?.id === step.id,
}));
}, [workflow?.steps, selectedStep]);
// Conversion des connexions du workflow en arêtes ReactFlow
const initialEdges: Edge[] = useMemo(() => {
if (!workflow?.connections) return [];
return workflow.connections.map((connection) => ({
id: connection.id,
source: connection.source,
target: connection.target,
...defaultEdgeOptions,
}));
}, [workflow?.connections]);
// États locaux pour les nœuds et connexions
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const canvasRef = useRef<HTMLDivElement>(null);
// Gestionnaire d'événements clavier pour le Canvas
const handleKeyDown = useCallback((event: KeyboardEvent) => {
// Ignorer si l'utilisateur tape dans un champ de saisie
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
switch (event.key) {
case 'Delete':
case 'Backspace':
// Supprimer les nœuds sélectionnés
const selectedNodes = nodes.filter(node => node.selected);
if (selectedNodes.length > 0 && onStepDelete) {
event.preventDefault();
selectedNodes.forEach(node => onStepDelete(node.id));
}
break;
case 'Escape':
// Désélectionner tous les nœuds
event.preventDefault();
setNodes(nodes => nodes.map(node => ({ ...node, selected: false })));
if (onStepSelect) {
onStepSelect(null);
}
break;
case 'a':
if (event.ctrlKey || event.metaKey) {
// Sélectionner tous les nœuds
event.preventDefault();
setNodes(nodes => nodes.map(node => ({ ...node, selected: true })));
}
break;
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowLeft':
case 'ArrowRight':
// Déplacer les nœuds sélectionnés
const selectedNodesForMove = nodes.filter(node => node.selected);
if (selectedNodesForMove.length > 0) {
event.preventDefault();
const moveDistance = event.shiftKey ? 50 : 10;
selectedNodesForMove.forEach(node => {
const newPosition = { ...node.position };
switch (event.key) {
case 'ArrowUp': newPosition.y -= moveDistance; break;
case 'ArrowDown': newPosition.y += moveDistance; break;
case 'ArrowLeft': newPosition.x -= moveDistance; break;
case 'ArrowRight': newPosition.x += moveDistance; break;
}
if (onStepMove) {
onStepMove(node.id, newPosition);
}
});
}
break;
case 'Tab':
// Navigation entre les nœuds
if (nodes.length > 0) {
event.preventDefault();
const currentSelectedIndex = nodes.findIndex(node => node.selected);
let nextIndex: number;
if (event.shiftKey) {
// Tab + Shift : précédent
nextIndex = currentSelectedIndex <= 0 ? nodes.length - 1 : currentSelectedIndex - 1;
} else {
// Tab : suivant
nextIndex = currentSelectedIndex >= nodes.length - 1 ? 0 : currentSelectedIndex + 1;
}
const nextNode = nodes[nextIndex];
if (nextNode && onStepSelect) {
// Désélectionner tous et sélectionner le suivant
setNodes(nodes => nodes.map((node, index) => ({
...node,
selected: index === nextIndex
})));
// Trouver l'étape correspondante
const step = workflow?.steps.find(s => s.id === nextNode.id);
if (step) {
onStepSelect(step);
}
}
}
break;
case 'Enter':
// Activer l'édition du nœud sélectionné
const selectedNode = nodes.find(node => node.selected);
if (selectedNode) {
event.preventDefault();
const step = workflow?.steps.find(s => s.id === selectedNode.id);
if (step && onStepSelect) {
onStepSelect(step);
}
}
break;
}
}, [nodes, setNodes, onStepDelete, onStepSelect, onStepMove, workflow?.steps]);
// Attacher/détacher les gestionnaires d'événements clavier
useEffect(() => {
const canvas = canvasRef.current;
if (canvas) {
canvas.addEventListener('keydown', handleKeyDown);
// Rendre le canvas focusable
canvas.setAttribute('tabindex', '0');
return () => {
canvas.removeEventListener('keydown', handleKeyDown);
};
}
}, [handleKeyDown]);
// Synchroniser les nœuds avec les changements du workflow - optimisé
useEffect(() => {
console.log('🔄 [Canvas] Synchronisation nœuds:', {
currentCount: nodes.length,
newCount: initialNodes.length,
workflowId: workflow?.id,
});
// Forcer la mise à jour si le nombre ou les IDs changent
const currentNodeIds = nodes.map(n => n.id).sort().join(',');
const newNodeIds = initialNodes.map(n => n.id).sort().join(',');
if (currentNodeIds !== newNodeIds || nodes.length !== initialNodes.length) {
console.log('✅ [Canvas] Mise à jour des nœuds:', initialNodes.length);
setNodes(initialNodes);
// Ajuster la vue après chargement
setTimeout(() => fitView({ padding: 0.2 }), 100);
}
}, [initialNodes, setNodes, nodes, workflow?.id, fitView]);
// Synchroniser les arêtes avec les changements du workflow - optimisé
useEffect(() => {
// Éviter les re-rendus inutiles en comparant les données
const currentEdgeIds = edges.map(e => e.id).sort().join(',');
const newEdgeIds = initialEdges.map(e => e.id).sort().join(',');
if (currentEdgeIds !== newEdgeIds || edges.length !== initialEdges.length) {
setEdges(initialEdges);
}
}, [initialEdges, setEdges, edges]);
// Gestionnaire de connexion entre étapes
const onConnect = useCallback(
(params: Connection) => {
const newEdge = {
...params,
...defaultEdgeOptions,
};
setEdges((eds) => addEdge(newEdge, eds));
if (onConnection && params.source && params.target) {
onConnection(params.source, params.target);
}
},
[onConnection, setEdges]
);
// Gestionnaire de sélection d'étape
const onNodeClick = useCallback(
(event: React.MouseEvent, node: Node) => {
if (onStepSelect) {
const nodeData = node.data as StepNodeData;
const step: Step = {
id: node.id,
type: nodeData?.stepType || 'click',
name: nodeData?.label || '',
position: node.position,
data: {
label: nodeData?.label || '',
stepType: nodeData?.stepType || 'click',
parameters: nodeData?.parameters || {},
isSelected: true,
// Préserver les flags VWB s'ils existent
...(nodeData?.isVWBCatalogAction && {
isVWBCatalogAction: true,
vwbActionId: nodeData?.vwbActionId || nodeData?.stepType,
}),
},
executionState: nodeData?.executionState,
validationErrors: nodeData?.validationErrors,
};
console.log('🖱️ [Canvas] Sélection étape:', {
id: step.id,
type: step.type,
isVWB: step.data.isVWBCatalogAction,
vwbActionId: step.data.vwbActionId
});
onStepSelect(step);
}
},
[onStepSelect]
);
// Gestionnaire de double-clic pour édition d'étape
const onNodeDoubleClick = useCallback(
(event: React.MouseEvent, node: Node) => {
console.log('Double-clic sur étape:', node.id);
// Sélectionner l'étape et ouvrir le panneau de propriétés
if (onStepSelect) {
const nodeData = node.data as StepNodeData;
const step: Step = {
id: node.id,
type: nodeData?.stepType || 'click',
name: nodeData?.label || '',
position: node.position,
data: {
label: nodeData?.label || '',
stepType: nodeData?.stepType || 'click',
parameters: nodeData?.parameters || {},
isSelected: true,
// Préserver les flags VWB s'ils existent
...(nodeData?.isVWBCatalogAction && {
isVWBCatalogAction: true,
vwbActionId: nodeData?.vwbActionId || nodeData?.stepType,
}),
},
executionState: nodeData?.executionState,
validationErrors: nodeData?.validationErrors,
};
onStepSelect(step);
}
},
[onStepSelect]
);
// Gestionnaire de déplacement d'étape
const onNodeDragStop = useCallback(
(event: React.MouseEvent, node: Node) => {
if (onStepMove) {
onStepMove(node.id, node.position);
}
},
[onStepMove]
);
// Gestionnaire de suppression de nœud
const onNodesDelete = useCallback(
(nodesToDelete: Node[]) => {
if (onStepDelete) {
nodesToDelete.forEach((node) => {
onStepDelete(node.id);
});
}
},
[onStepDelete]
);
// Gestionnaire de suppression de connexion (edge)
const onEdgesDelete = useCallback(
(edgesToDelete: Edge[]) => {
if (onConnectionDelete) {
edgesToDelete.forEach((edge) => {
onConnectionDelete(edge.id);
});
}
},
[onConnectionDelete]
);
// Liste des actions VWB connues du catalogue
const knownVWBActions = useMemo(() => [
'click_anchor', 'type_text', 'type_secret', 'wait_for_anchor',
'extract_text', 'screenshot_evidence', 'scroll_to_anchor',
'focus_anchor', 'hotkey', 'navigate_to_url', 'browser_back',
'verify_element_exists', 'verify_text_content'
], []);
// Fonction pour détecter si un type est une action VWB
const isVWBActionType = useCallback((stepType: string): boolean => {
// Vérifie si c'est un type connu du catalogue
if (knownVWBActions.includes(stepType)) return true;
// Vérifie les patterns VWB
if (stepType.startsWith('catalog:')) return true;
if (stepType.includes('_anchor')) return true;
if (stepType.includes('_text') && stepType !== 'type') return true;
if (stepType.includes('_secret')) return true;
return false;
}, [knownVWBActions]);
// Noms lisibles pour les actions VWB
const getVWBActionName = useCallback((actionId: string): string => {
const names: Record<string, string> = {
'click_anchor': 'Cliquer sur Ancre',
'type_text': 'Saisir Texte',
'type_secret': 'Saisir Texte Secret',
'wait_for_anchor': 'Attendre Ancre',
'extract_text': 'Extraire Texte',
'screenshot_evidence': 'Capture Evidence',
'scroll_to_anchor': 'Défiler vers Ancre',
'focus_anchor': 'Focaliser Ancre',
'hotkey': 'Raccourci Clavier',
'navigate_to_url': 'Naviguer vers URL',
'browser_back': 'Retour Navigateur',
'verify_element_exists': 'Vérifier Existence',
'verify_text_content': 'Vérifier Contenu Texte',
};
return names[actionId] || actionId.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
}, []);
// Gestionnaire de drop depuis la palette
const onDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
let stepType = event.dataTransfer.getData('application/reactflow');
if (!stepType || !onStepAdd) return;
// Gérer le format "catalog:action_id" de la palette
let actualType = stepType;
let isFromCatalog = false;
if (stepType.startsWith('catalog:')) {
actualType = stepType.replace('catalog:', '');
isFromCatalog = true;
}
// Obtenir la position relative au ReactFlow
const reactFlowBounds = event.currentTarget.getBoundingClientRect();
const { x: viewportX, y: viewportY, zoom } = getViewport();
// Calculer la position en tenant compte du zoom et du pan
const position = {
x: (event.clientX - reactFlowBounds.left - viewportX) / zoom,
y: (event.clientY - reactFlowBounds.top - viewportY) / zoom,
};
// Détecter si c'est une action VWB
const isVWB = isFromCatalog || isVWBActionType(actualType);
const stepName = isVWB ? getVWBActionName(actualType) : `Nouvelle étape ${actualType}`;
const newStep: Omit<Step, 'id'> = {
type: actualType as StepType,
name: stepName,
position,
data: {
label: stepName,
stepType: actualType as StepType,
parameters: {},
// Marquer les actions VWB avec les flags appropriés
...(isVWB && {
isVWBCatalogAction: true,
vwbActionId: actualType,
}),
},
};
console.log('📦 [Canvas] Création étape:', {
type: actualType,
isVWB,
isFromCatalog,
data: newStep.data
});
onStepAdd(newStep);
},
[onStepAdd, getViewport, isVWBActionType, getVWBActionName]
);
const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}, []);
// Déterminer si la minimap doit être affichée
const shouldShowMinimap = nodes.length > 20;
// Message d'état vide
const EmptyState = () => (
<Box sx={emptyStateBoxStyles}>
<Paper elevation={2} sx={emptyStatePaperStyles}>
<Typography variant="h6" color="text.secondary" gutterBottom>
Espace de travail vide
</Typography>
<Typography variant="body2" color="text.secondary">
Glissez des étapes depuis la palette pour commencer à créer votre workflow
</Typography>
</Paper>
</Box>
);
return (
<Box
ref={canvasRef}
sx={canvasStyles}
onDrop={onDrop}
onDragOver={onDragOver}
role="application"
aria-label="Zone de création de workflow"
tabIndex={0}
>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onNodeDragStop={onNodeDragStop}
onNodesDelete={onNodesDelete}
onEdgesDelete={onEdgesDelete}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
defaultEdgeOptions={defaultEdgeOptions}
fitView
attributionPosition="bottom-left"
deleteKeyCode={['Backspace', 'Delete']}
multiSelectionKeyCode={['Meta', 'Ctrl']}
edgesFocusable={true}
edgesReconnectable={true}
panOnScroll
selectionOnDrag
panOnDrag={[1, 2]} // Clic milieu et droit pour panoramique
selectNodesOnDrag={false}
>
{/* Grille d'alignement */}
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="#e0e0e0"
/>
{/* Contrôles de navigation */}
<Controls
position="top-left"
showZoom={true}
showFitView={true}
showInteractive={true}
style={{
backgroundColor: '#ffffff',
border: '1px solid #e0e0e0',
borderRadius: '8px',
}}
/>
{/* Minimap pour navigation dans les gros workflows */}
{shouldShowMinimap && (
<MiniMap
position="bottom-right"
zoomable
pannable
style={{
backgroundColor: '#ffffff',
border: '1px solid #e0e0e0',
borderRadius: '8px',
}}
nodeColor={(node) => {
switch (node.data?.executionState) {
case StepExecutionState.RUNNING:
return '#2196f3';
case StepExecutionState.SUCCESS:
return '#4caf50';
case StepExecutionState.ERROR:
return '#f44336';
default:
return '#9e9e9e';
}
}}
/>
)}
</ReactFlow>
{/* État vide */}
{nodes.length === 0 && <EmptyState />}
{/* Indicateur d'exécution */}
{executionState?.status === 'running' && (
<Box sx={executionIndicatorBoxStyles}>
<Paper elevation={3} sx={executionIndicatorPaperStyles}>
<Box sx={executionIndicatorAnimationStyles} />
<Typography variant="body2">
Exécution en cours...
</Typography>
</Paper>
</Box>
)}
</Box>
);
};
// Mémorisation du composant Canvas pour éviter les re-rendus inutiles
export default memo(Canvas, (prevProps, nextProps) => {
// Comparaison personnalisée pour optimiser les re-rendus
return (
prevProps.workflow?.id === nextProps.workflow?.id &&
prevProps.workflow?.steps?.length === nextProps.workflow?.steps?.length &&
prevProps.selectedStep?.id === nextProps.selectedStep?.id &&
prevProps.executionState?.status === nextProps.executionState?.status &&
JSON.stringify(prevProps.workflow?.connections) === JSON.stringify(nextProps.workflow?.connections)
);
});

View File

@@ -106,7 +106,7 @@ const CoachingSuggestionCard: React.FC<CoachingSuggestionCardProps> = ({
{suggestion.screenshotPath && (
<div className="suggestion-screenshot">
<img
src={`http://localhost:5000${suggestion.screenshotPath}`}
src={`http://localhost:5001${suggestion.screenshotPath}`}
alt="Target element"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';

View File

@@ -143,7 +143,7 @@ export const CoachingPanel: React.FC<CoachingPanelProps> = ({
const startSession = useCallback(
async (wfId: string) => {
try {
const response = await fetch(`${serverUrl || 'http://localhost:5000'}/api/executions/coaching`, {
const response = await fetch(`${serverUrl || 'http://localhost:5001'}/api/executions/coaching`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow_id: wfId }),

View File

@@ -0,0 +1,220 @@
/**
* Indicateur de Connexion API - Affichage stable de l'état de connexion
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
*
* Ce composant affiche l'état de connexion de l'API de manière non-intrusive
* et stable, sans provoquer de "sauts" de page.
*/
import React, { memo, useState, useCallback } from 'react';
import {
Box,
Chip,
Tooltip,
IconButton,
Snackbar,
Alert,
} from '@mui/material';
import {
CloudDone as OnlineIcon,
CloudOff as OfflineIcon,
Refresh as RefreshIcon,
Sync as CheckingIcon,
} from '@mui/icons-material';
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
interface ConnectionIndicatorProps {
/** Afficher en mode compact (icône seule) */
compact?: boolean;
/** Position de l'indicateur */
position?: 'inline' | 'fixed';
/** Afficher le bouton de rafraîchissement */
showRefreshButton?: boolean;
/** Callback quand l'état change */
onStatusChange?: (isOnline: boolean) => void;
}
/**
* Composant d'indicateur de connexion API
* Mémorisé pour éviter les re-rendus inutiles
*/
const ConnectionIndicator: React.FC<ConnectionIndicatorProps> = memo(({
compact = false,
position = 'inline',
showRefreshButton = true,
onStatusChange,
}) => {
const { status, isOnline, isOffline, isChecking, statusMessage, forceCheck } = useConnectionStatus({
onStatusChange: (newStatus) => {
if (onStatusChange) {
onStatusChange(newStatus === 'online');
}
},
});
const [isRefreshing, setIsRefreshing] = useState(false);
const [showNotification, setShowNotification] = useState(false);
const [notificationMessage, setNotificationMessage] = useState('');
// Gestionnaire de rafraîchissement
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
try {
const result = await forceCheck();
setNotificationMessage(result ? 'Connexion rétablie' : 'API toujours hors ligne');
setShowNotification(true);
} catch (error) {
setNotificationMessage('Erreur lors de la vérification');
setShowNotification(true);
} finally {
setIsRefreshing(false);
}
}, [forceCheck]);
// Fermer la notification
const handleCloseNotification = useCallback(() => {
setShowNotification(false);
}, []);
// Déterminer l'icône et la couleur
const getIconAndColor = () => {
if (isChecking || isRefreshing) {
return {
icon: <CheckingIcon sx={{ animation: 'spin 1s linear infinite' }} />,
color: 'default' as const,
label: 'Vérification...',
};
}
if (isOnline) {
return {
icon: <OnlineIcon />,
color: 'success' as const,
label: 'API connectée',
};
}
return {
icon: <OfflineIcon />,
color: 'warning' as const,
label: 'Mode hors ligne',
};
};
const { icon, color, label } = getIconAndColor();
// Style pour l'animation de rotation
const spinKeyframes = `
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`;
// Rendu compact (icône seule)
if (compact) {
return (
<>
<style>{spinKeyframes}</style>
<Tooltip title={statusMessage} arrow>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
}}
>
<Chip
icon={icon}
size="small"
color={color}
variant="outlined"
sx={{
minWidth: 'auto',
'& .MuiChip-label': { display: 'none' },
'& .MuiChip-icon': { margin: 0 },
}}
/>
{showRefreshButton && isOffline && (
<IconButton
size="small"
onClick={handleRefresh}
disabled={isRefreshing}
sx={{ padding: 0.5 }}
>
<RefreshIcon fontSize="small" />
</IconButton>
)}
</Box>
</Tooltip>
</>
);
}
// Rendu complet
return (
<>
<style>{spinKeyframes}</style>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
...(position === 'fixed' && {
position: 'fixed',
bottom: 16,
right: 16,
zIndex: 1000,
}),
}}
>
<Tooltip title={`${statusMessage} - Cliquer pour rafraîchir`} arrow>
<Chip
icon={icon}
label={label}
size="small"
color={color}
variant="outlined"
onClick={handleRefresh}
sx={{ cursor: 'pointer' }}
/>
</Tooltip>
{showRefreshButton && (
<Tooltip title="Vérifier la connexion" arrow>
<IconButton
size="small"
onClick={handleRefresh}
disabled={isRefreshing}
color="primary"
>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
{/* Notification de changement d'état */}
<Snackbar
open={showNotification}
autoHideDuration={3000}
onClose={handleCloseNotification}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
onClose={handleCloseNotification}
severity={isOnline ? 'success' : 'warning'}
variant="filled"
sx={{ width: '100%' }}
>
{notificationMessage}
</Alert>
</Snackbar>
</>
);
});
ConnectionIndicator.displayName = 'ConnectionIndicator';
export default ConnectionIndicator;

View File

@@ -0,0 +1,448 @@
/**
* Composant d'Aide Contextuelle - Assistance intelligente en français
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce composant fournit une aide contextuelle intelligente qui s'adapte
* à l'action en cours de l'utilisateur et propose des conseils pertinents.
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Paper,
Typography,
IconButton,
Collapse,
List,
ListItem,
ListItemIcon,
ListItemText,
Chip,
Button,
Divider,
} from '@mui/material';
import {
Help as HelpIcon,
Close as CloseIcon,
Lightbulb as LightbulbIcon,
Info as InfoIcon,
Warning as WarningIcon,
CheckCircle as CheckCircleIcon,
KeyboardArrowUp as ArrowUpIcon,
KeyboardArrowDown as ArrowDownIcon,
} from '@mui/icons-material';
interface ContextualHelpProps {
context: 'canvas' | 'palette' | 'properties' | 'validation' | 'execution' | 'variables';
selectedStepType?: string;
currentErrors?: string[];
isVisible?: boolean;
onClose?: () => void;
}
interface HelpTip {
id: string;
type: 'tip' | 'info' | 'warning' | 'success';
title: string;
content: string;
action?: {
label: string;
onClick: () => void;
};
}
// Conseils contextuels par zone de l'interface
const contextualTips: Record<string, HelpTip[]> = {
canvas: [
{
id: 'canvas_basics',
type: 'tip',
title: 'Premiers pas sur le canvas',
content: 'Glissez des étapes depuis la palette vers cette zone pour construire votre workflow. Utilisez la molette pour zoomer et cliquez-glissez pour vous déplacer.'
},
{
id: 'canvas_connections',
type: 'info',
title: 'Connecter les étapes',
content: 'Cliquez sur le point de sortie d\'une étape (cercle à droite) et glissez vers le point d\'entrée d\'une autre étape pour les connecter.'
},
{
id: 'canvas_selection',
type: 'tip',
title: 'Sélection multiple',
content: 'Maintenez Ctrl et cliquez sur plusieurs étapes pour les sélectionner. Vous pouvez ensuite les déplacer ensemble ou les supprimer.'
}
],
palette: [
{
id: 'palette_categories',
type: 'info',
title: 'Organisation par catégories',
content: 'Les étapes sont organisées en catégories logiques : Actions Web pour interagir avec les pages, Logique pour les conditions, etc.'
},
{
id: 'palette_search',
type: 'tip',
title: 'Recherche rapide',
content: 'Utilisez le champ de recherche pour trouver rapidement une étape par son nom ou sa description.'
},
{
id: 'palette_drag',
type: 'tip',
title: 'Glisser-déposer',
content: 'Glissez une étape depuis la palette vers le canvas pour l\'ajouter à votre workflow. L\'étape apparaîtra à l\'endroit où vous la relâchez.'
}
],
properties: [
{
id: 'properties_required',
type: 'warning',
title: 'Paramètres obligatoires',
content: 'Les paramètres marqués d\'un astérisque (*) sont obligatoires. Le workflow ne pourra pas s\'exécuter sans eux.'
},
{
id: 'properties_variables',
type: 'tip',
title: 'Utilisation des variables',
content: 'Vous pouvez utiliser des variables dans les champs texte avec la syntaxe ${nom_variable}. Cela rend vos workflows réutilisables.'
},
{
id: 'properties_visual',
type: 'info',
title: 'Sélection visuelle',
content: 'Pour les paramètres "Élément cible", utilisez le bouton "Sélectionner un élément" pour choisir visuellement sur une capture d\'écran.'
}
],
validation: [
{
id: 'validation_errors',
type: 'warning',
title: 'Correction des erreurs',
content: 'Les erreurs en rouge empêchent l\'exécution. Cliquez sur une erreur pour aller directement à l\'étape concernée.'
},
{
id: 'validation_warnings',
type: 'info',
title: 'Avertissements',
content: 'Les avertissements en orange n\'empêchent pas l\'exécution mais peuvent indiquer des problèmes potentiels.'
},
{
id: 'validation_cycles',
type: 'warning',
title: 'Éviter les boucles infinies',
content: 'Vérifiez que vos connexions ne créent pas de cycles (A → B → A) qui empêcheraient l\'exécution normale.'
}
],
execution: [
{
id: 'execution_states',
type: 'info',
title: 'États d\'exécution',
content: 'Pendant l\'exécution, les étapes changent de couleur : bleu (en cours), vert (réussie), rouge (échouée).'
},
{
id: 'execution_pause',
type: 'tip',
title: 'Contrôle de l\'exécution',
content: 'Vous pouvez mettre en pause, arrêter ou redémarrer l\'exécution à tout moment avec les boutons de contrôle.'
},
{
id: 'execution_debug',
type: 'tip',
title: 'Débogage',
content: 'En cas d\'erreur, consultez les détails dans le panneau d\'exécution pour comprendre ce qui s\'est passé.'
}
],
variables: [
{
id: 'variables_naming',
type: 'tip',
title: 'Nommage des variables',
content: 'Utilisez des noms descriptifs pour vos variables : "nom_utilisateur" plutôt que "var1". Évitez les espaces et caractères spéciaux.'
},
{
id: 'variables_types',
type: 'info',
title: 'Types de variables',
content: 'Définissez le bon type pour chaque variable (texte, nombre, booléen) pour éviter les erreurs de validation.'
},
{
id: 'variables_scope',
type: 'tip',
title: 'Portée des variables',
content: 'Les variables sont disponibles dans tout le workflow une fois créées. Elles peuvent être modifiées par les étapes d\'extraction.'
}
]
};
// Conseils spécifiques par type d'étape
const stepSpecificTips: Record<string, HelpTip[]> = {
click: [
{
id: 'click_types',
type: 'info',
title: 'Types de clic',
content: 'Utilisez le clic gauche pour la plupart des actions, le clic droit pour les menus contextuels, et le double-clic pour les sélections.'
},
{
id: 'click_timing',
type: 'tip',
title: 'Timing des clics',
content: 'Si un clic ne fonctionne pas, ajoutez une étape d\'attente avant pour laisser le temps à la page de se charger.'
}
],
type: [
{
id: 'type_clear',
type: 'tip',
title: 'Vider avant saisie',
content: 'Activez "Vider le champ d\'abord" si vous voulez remplacer le contenu existant plutôt que l\'ajouter.'
},
{
id: 'type_variables',
type: 'info',
title: 'Texte dynamique',
content: 'Utilisez ${nom_variable} pour insérer des valeurs dynamiques dans le texte à saisir.'
}
],
wait: [
{
id: 'wait_duration',
type: 'tip',
title: 'Durée d\'attente',
content: 'Utilisez des durées courtes (0.5-2 secondes) pour les interactions rapides, plus longues (3-10 secondes) pour les chargements de page.'
}
],
condition: [
{
id: 'condition_operators',
type: 'info',
title: 'Opérateurs disponibles',
content: 'Utilisez ==, !=, >, <, >=, <= pour comparer des valeurs. Exemple : ${age} >= 18 ou ${status} == "actif".'
}
]
};
/**
* Composant d'Aide Contextuelle
*/
const ContextualHelp: React.FC<ContextualHelpProps> = ({
context,
selectedStepType,
currentErrors = [],
isVisible = true,
onClose,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const [currentTips, setCurrentTips] = useState<HelpTip[]>([]);
// Gestionnaire d'événements clavier pour l'aide contextuelle
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
switch (event.key) {
case 'h':
// Raccourci pour basculer l'aide (Ctrl+H)
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
setIsExpanded(prev => !prev);
}
break;
case 'Escape':
// Fermer l'aide
event.preventDefault();
setIsExpanded(false);
if (onClose) onClose();
break;
case '?':
// Raccourci pour ouvrir l'aide
event.preventDefault();
setIsExpanded(true);
break;
}
}, [onClose]);
// Mettre à jour les conseils selon le contexte
useEffect(() => {
let tips: HelpTip[] = [];
// Ajouter les conseils contextuels
if (contextualTips[context]) {
tips.push(...contextualTips[context]);
}
// Ajouter les conseils spécifiques à l'étape sélectionnée
if (selectedStepType && stepSpecificTips[selectedStepType]) {
tips.push(...stepSpecificTips[selectedStepType]);
}
// Ajouter des conseils basés sur les erreurs actuelles
if (currentErrors.length > 0) {
tips.unshift({
id: 'current_errors',
type: 'warning',
title: `${currentErrors.length} erreur(s) détectée(s)`,
content: 'Corrigez les erreurs signalées en rouge pour pouvoir exécuter votre workflow.'
});
}
setCurrentTips(tips);
}, [context, selectedStepType, currentErrors]);
// Obtenir l'icône selon le type de conseil
const getTipIcon = (type: HelpTip['type']) => {
switch (type) {
case 'tip':
return <LightbulbIcon color="primary" />;
case 'info':
return <InfoIcon color="info" />;
case 'warning':
return <WarningIcon color="warning" />;
case 'success':
return <CheckCircleIcon color="success" />;
default:
return <HelpIcon />;
}
};
// Obtenir la couleur selon le type
const getTipColor = (type: HelpTip['type']) => {
switch (type) {
case 'tip':
return 'primary';
case 'info':
return 'info';
case 'warning':
return 'warning';
case 'success':
return 'success';
default:
return 'default';
}
};
if (!isVisible || currentTips.length === 0) {
return null;
}
return (
<Paper
elevation={3}
sx={{
position: 'fixed',
bottom: 20,
right: 20,
width: 320,
maxHeight: 400,
zIndex: 1000,
overflow: 'hidden',
}}
>
{/* En-tête */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 2,
backgroundColor: 'primary.main',
color: 'primary.contrastText',
cursor: 'pointer',
}}
onClick={() => setIsExpanded(!isExpanded)}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<HelpIcon />
<Typography variant="subtitle2">
Aide contextuelle
</Typography>
<Chip
label={currentTips.length}
size="small"
sx={{
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: 'inherit',
}}
/>
</Box>
<Box>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
sx={{ color: 'inherit', mr: 1 }}
>
{isExpanded ? <ArrowDownIcon /> : <ArrowUpIcon />}
</IconButton>
{onClose && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
sx={{ color: 'inherit' }}
>
<CloseIcon />
</IconButton>
)}
</Box>
</Box>
{/* Contenu */}
<Collapse in={isExpanded}>
<Box
sx={{ maxHeight: 300, overflow: 'auto' }}
onKeyDown={handleKeyDown}
tabIndex={0}
>
<List dense>
{currentTips.map((tip, index) => (
<React.Fragment key={tip.id}>
<ListItem alignItems="flex-start">
<ListItemIcon sx={{ mt: 0.5 }}>
{getTipIcon(tip.type)}
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="subtitle2">
{tip.title}
</Typography>
<Chip
label={tip.type === 'tip' ? 'Conseil' : tip.type === 'info' ? 'Info' : tip.type === 'warning' ? 'Attention' : 'Succès'}
size="small"
color={getTipColor(tip.type) as any}
variant="outlined"
/>
</Box>
}
secondary={
<Box>
<Typography variant="body2" color="text.secondary">
{tip.content}
</Typography>
{tip.action && (
<Button
size="small"
onClick={tip.action.onClick}
sx={{ mt: 1 }}
>
{tip.action.label}
</Button>
)}
</Box>
}
/>
</ListItem>
{index < currentTips.length - 1 && <Divider />}
</React.Fragment>
))}
</List>
</Box>
</Collapse>
</Paper>
);
};
export default ContextualHelp;

View File

@@ -0,0 +1,359 @@
/**
* Styles CSS pour le composant DebugPanel
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*/
.debug-panel {
position: fixed;
top: 20px;
right: 20px;
width: 400px;
max-height: calc(100vh - 40px);
background-color: white;
border: 2px solid #1976d2;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 9999;
overflow: hidden;
display: flex;
flex-direction: column;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.debug-panel-header {
padding: 16px;
background-color: #1976d2;
color: white;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #1565c0;
}
.debug-panel-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
margin: 0;
}
.debug-panel-controls {
display: flex;
align-items: center;
gap: 8px;
}
.debug-panel-content {
flex: 1;
overflow: auto;
padding: 8px;
}
.debug-panel-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
background-color: #1976d2;
color: white;
border: none;
border-radius: 50%;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
}
.debug-panel-toggle:hover {
background-color: #1565c0;
transform: scale(1.05);
}
.debug-section {
margin-bottom: 16px;
border: 1px solid #e0e0e0;
border-radius: 4px;
overflow: hidden;
}
.debug-section-header {
padding: 12px 16px;
background-color: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
transition: background-color 0.2s ease;
}
.debug-section-header:hover {
background-color: #eeeeee;
}
.debug-section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
margin: 0;
}
.debug-section-content {
padding: 16px;
background-color: white;
}
.debug-info-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.debug-info-table th,
.debug-info-table td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.debug-info-table th {
background-color: #f5f5f5;
font-weight: 600;
}
.debug-info-table tr:last-child td {
border-bottom: none;
}
.debug-chip {
display: inline-block;
padding: 4px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
text-align: center;
white-space: nowrap;
}
.debug-chip-success {
background-color: #e8f5e8;
color: #2e7d32;
border: 1px solid #c8e6c9;
}
.debug-chip-error {
background-color: #ffebee;
color: #c62828;
border: 1px solid #ffcdd2;
}
.debug-chip-warning {
background-color: #fff3e0;
color: #ef6c00;
border: 1px solid #ffcc02;
}
.debug-chip-info {
background-color: #e3f2fd;
color: #1976d2;
border: 1px solid #bbdefb;
}
.debug-chip-default {
background-color: #f5f5f5;
color: #616161;
border: 1px solid #e0e0e0;
}
.debug-detection-methods {
display: flex;
flex-direction: column;
gap: 8px;
}
.debug-detection-method {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.debug-detection-icon {
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
}
.debug-detection-icon-success {
background-color: #4caf50;
color: white;
}
.debug-detection-icon-disabled {
background-color: #bdbdbd;
color: white;
}
.debug-parameters-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
.debug-parameter-chip {
padding: 2px 6px;
background-color: #e3f2fd;
color: #1976d2;
border: 1px solid #bbdefb;
border-radius: 8px;
font-size: 10px;
font-weight: 500;
}
.debug-alert {
padding: 12px;
border-radius: 4px;
margin: 8px 0;
font-size: 12px;
line-height: 1.4;
}
.debug-alert-info {
background-color: #e3f2fd;
color: #1976d2;
border: 1px solid #bbdefb;
}
.debug-alert-warning {
background-color: #fff3e0;
color: #ef6c00;
border: 1px solid #ffcc02;
}
.debug-alert-error {
background-color: #ffebee;
color: #c62828;
border: 1px solid #ffcdd2;
}
.debug-alert-success {
background-color: #e8f5e8;
color: #2e7d32;
border: 1px solid #c8e6c9;
}
.debug-auto-refresh {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: white;
}
.debug-auto-refresh input[type="checkbox"] {
margin: 0;
}
/* Animations */
@keyframes debugPanelSlideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes debugPanelSlideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
.debug-panel-enter {
animation: debugPanelSlideIn 0.3s ease-out;
}
.debug-panel-exit {
animation: debugPanelSlideOut 0.3s ease-in;
}
/* Responsive */
@media (max-width: 768px) {
.debug-panel {
width: calc(100vw - 40px);
max-width: 400px;
}
.debug-panel-toggle {
width: 48px;
height: 48px;
}
}
/* Scrollbar personnalisée */
.debug-panel-content::-webkit-scrollbar {
width: 6px;
}
.debug-panel-content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.debug-panel-content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.debug-panel-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* Mode sombre (optionnel) */
@media (prefers-color-scheme: dark) {
.debug-panel {
background-color: #2d2d2d;
border-color: #1976d2;
color: #ffffff;
}
.debug-section-header {
background-color: #3d3d3d;
border-bottom-color: #4d4d4d;
}
.debug-section-content {
background-color: #2d2d2d;
}
.debug-info-table th {
background-color: #3d3d3d;
}
.debug-info-table th,
.debug-info-table td {
border-bottom-color: #4d4d4d;
}
}

View File

@@ -0,0 +1,547 @@
/**
* Composant DebugPanel - Visualisation des données d'étapes en temps réel
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*
* Ce composant affiche des informations de débogage détaillées pour les étapes
* sélectionnées, permettant de diagnostiquer les problèmes de propriétés.
*/
import React, { useState, useEffect, useMemo } from 'react';
import {
Box,
Typography,
Accordion,
AccordionSummary,
AccordionDetails,
Chip,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Alert,
Switch,
FormControlLabel,
Divider,
IconButton,
Tooltip,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
BugReport as BugReportIcon,
Visibility as VisibilityIcon,
Code as CodeIcon,
Settings as SettingsIcon,
Warning as WarningIcon,
CheckCircle as CheckCircleIcon,
Error as ErrorIcon,
} from '@mui/icons-material';
// Import des hooks d'intégration VWB
import { useVWBStepIntegration, useIsVWBStep, useVWBActionId } from '../../hooks/useVWBStepIntegration';
// Import des types
import { Step, StepType, Variable } from '../../types';
interface DebugPanelProps {
selectedStep: Step | null;
variables: Variable[];
isVisible?: boolean;
onToggleVisibility?: (visible: boolean) => void;
}
interface StepAnalysis {
stepInfo: {
id: string;
name: string;
type: string;
hasData: boolean;
dataKeys: string[];
};
typeAnalysis: {
isStandardType: boolean;
isVWBAction: boolean;
hasConfiguration: boolean;
configurationSource: string;
};
vwbAnalysis: {
isVWBCatalogAction: boolean;
hasVWBActionId: boolean;
vwbActionId: string | null;
detectionMethods: Record<string, boolean>;
};
parametersAnalysis: {
hasParameters: boolean;
parameterCount: number;
parameterKeys: string[];
missingRequired: string[];
};
validationAnalysis: {
hasErrors: boolean;
errorCount: number;
warningCount: number;
errors: Array<{ parameter: string; message: string; severity: string }>;
};
}
/**
* Composant DebugPanel
*/
const DebugPanel: React.FC<DebugPanelProps> = ({
selectedStep,
variables,
isVisible = false,
onToggleVisibility,
}) => {
const [expanded, setExpanded] = useState<string[]>(['stepInfo']);
const [autoRefresh, setAutoRefresh] = useState(true);
// Utilisation des hooks d'intégration VWB
const { methods: vwbMethods } = useVWBStepIntegration();
const isVWBStep = useIsVWBStep(selectedStep);
const vwbActionId = useVWBActionId(selectedStep);
// Configuration des paramètres par type d'étape (copie locale pour analyse)
const stepParametersConfig: Record<string, any> = useMemo(() => ({
click: [
{ name: 'target', type: 'visual', required: true },
{ name: 'clickType', type: 'select', defaultValue: 'left' },
],
type: [
{ name: 'target', type: 'visual', required: true },
{ name: 'text', type: 'text', required: true },
{ name: 'clearFirst', type: 'boolean', defaultValue: true },
],
wait: [
{ name: 'duration', type: 'number', required: true, min: 0.1, max: 60, defaultValue: 1 },
],
condition: [
{ name: 'condition', type: 'text', required: true },
],
extract: [
{ name: 'target', type: 'visual', required: true },
{ name: 'attribute', type: 'select', defaultValue: 'text' },
],
scroll: [
{ name: 'direction', type: 'select', defaultValue: 'down' },
{ name: 'amount', type: 'number', defaultValue: 300, min: 1 },
],
navigate: [
{ name: 'url', type: 'text', required: true },
],
screenshot: [
{ name: 'filename', type: 'text' },
],
}), []);
// Analyse complète de l'étape sélectionnée
const stepAnalysis: StepAnalysis | null = useMemo(() => {
if (!selectedStep) return null;
// Analyse des informations de base
const stepInfo = {
id: selectedStep.id,
name: selectedStep.name || 'Sans nom',
type: selectedStep.type as string,
hasData: Boolean(selectedStep.data),
dataKeys: selectedStep.data ? Object.keys(selectedStep.data) : [],
};
// Analyse du type d'étape
const stepTypeString = selectedStep.type as string;
const isStandardType = stepTypeString in stepParametersConfig;
const hasConfiguration = isStandardType || Boolean(selectedStep.data?.isVWBCatalogAction);
// Méthodes de détection VWB
const detectionMethods = {
hasVWBFlag: Boolean(selectedStep.data?.isVWBCatalogAction),
hasVWBActionId: Boolean(selectedStep.data?.vwbActionId),
typeStartsWithVWB: stepTypeString.startsWith('vwb_'),
typeContainsAnchor: stepTypeString.includes('_anchor'),
typeContainsText: stepTypeString.includes('_text'),
typeContainsSecret: stepTypeString.includes('_secret'),
isKnownVWBAction: [
'click_anchor', 'type_text', 'type_secret', 'wait_for_anchor',
'extract_text', 'screenshot_evidence', 'scroll_to_anchor',
'focus_anchor', 'hotkey', 'navigate_to_url', 'browser_back',
'verify_element_exists', 'verify_text_content'
].includes(stepTypeString),
useIsVWBStepHook: isVWBStep,
};
const isVWBAction = Object.values(detectionMethods).some(method => method);
const typeAnalysis = {
isStandardType,
isVWBAction,
hasConfiguration,
configurationSource: isVWBAction ? 'VWBActionProperties' : isStandardType ? 'stepParametersConfig' : 'Aucune',
};
const vwbAnalysis = {
isVWBCatalogAction: Boolean(selectedStep.data?.isVWBCatalogAction),
hasVWBActionId: Boolean(selectedStep.data?.vwbActionId),
vwbActionId: selectedStep.data?.vwbActionId || null,
detectionMethods,
};
// Analyse des paramètres
const parameters = selectedStep.data?.parameters || {};
const parameterKeys = Object.keys(parameters);
const expectedConfig = stepParametersConfig[stepTypeString] || [];
const requiredParams = expectedConfig.filter((p: any) => p.required).map((p: any) => p.name);
const missingRequired = requiredParams.filter((param: string) => !(param in parameters));
const parametersAnalysis = {
hasParameters: parameterKeys.length > 0,
parameterCount: parameterKeys.length,
parameterKeys,
missingRequired,
};
// Analyse de validation
const validationErrors = selectedStep.validationErrors || [];
const errors = validationErrors.map(error => ({
parameter: error.parameter || 'Général',
message: error.message,
severity: error.severity || 'error',
}));
const validationAnalysis = {
hasErrors: validationErrors.length > 0,
errorCount: errors.filter(e => e.severity === 'error').length,
warningCount: errors.filter(e => e.severity === 'warning').length,
errors,
};
return {
stepInfo,
typeAnalysis,
vwbAnalysis,
parametersAnalysis,
validationAnalysis,
};
}, [selectedStep, stepParametersConfig, isVWBStep]);
// Gestion de l'expansion des accordéons
const handleAccordionChange = (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
setExpanded(prev =>
isExpanded
? [...prev, panel]
: prev.filter(p => p !== panel)
);
};
// Auto-refresh des données
useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(() => {
// Force un re-render pour mettre à jour les données en temps réel
// (les données sont déjà réactives via les props)
}, 1000);
return () => clearInterval(interval);
}, [autoRefresh]);
if (!isVisible) {
return (
<Box sx={{ position: 'fixed', top: 20, right: 20, zIndex: 9999 }}>
<Tooltip title="Ouvrir le panneau de débogage">
<IconButton
onClick={() => onToggleVisibility?.(true)}
sx={{
backgroundColor: 'primary.main',
color: 'white',
'&:hover': { backgroundColor: 'primary.dark' },
}}
>
<BugReportIcon />
</IconButton>
</Tooltip>
</Box>
);
}
return (
<Box
sx={{
position: 'fixed',
top: 20,
right: 20,
width: 400,
maxHeight: 'calc(100vh - 40px)',
backgroundColor: 'background.paper',
border: '2px solid',
borderColor: 'primary.main',
borderRadius: 2,
boxShadow: 3,
zIndex: 9999,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
{/* En-tête */}
<Box
sx={{
p: 2,
backgroundColor: 'primary.main',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<BugReportIcon />
<Typography variant="h6">Debug Panel</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FormControlLabel
control={
<Switch
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
size="small"
sx={{ color: 'white' }}
/>
}
label="Auto"
sx={{ color: 'white', m: 0 }}
/>
<IconButton
onClick={() => onToggleVisibility?.(false)}
sx={{ color: 'white' }}
size="small"
>
<VisibilityIcon />
</IconButton>
</Box>
</Box>
{/* Contenu */}
<Box sx={{ flex: 1, overflow: 'auto', p: 1 }}>
{!selectedStep ? (
<Alert severity="info" sx={{ m: 1 }}>
Aucune étape sélectionnée
</Alert>
) : !stepAnalysis ? (
<Alert severity="error" sx={{ m: 1 }}>
Erreur d'analyse de l'étape
</Alert>
) : (
<>
{/* Informations de base */}
<Accordion
expanded={expanded.includes('stepInfo')}
onChange={handleAccordionChange('stepInfo')}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SettingsIcon fontSize="small" />
<Typography variant="subtitle2">Informations de base</Typography>
<Chip
label={stepAnalysis.stepInfo.type}
size="small"
color="primary"
variant="outlined"
/>
</Box>
</AccordionSummary>
<AccordionDetails>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableBody>
<TableRow>
<TableCell><strong>ID</strong></TableCell>
<TableCell>{stepAnalysis.stepInfo.id}</TableCell>
</TableRow>
<TableRow>
<TableCell><strong>Nom</strong></TableCell>
<TableCell>{stepAnalysis.stepInfo.name}</TableCell>
</TableRow>
<TableRow>
<TableCell><strong>Type</strong></TableCell>
<TableCell>{stepAnalysis.stepInfo.type}</TableCell>
</TableRow>
<TableRow>
<TableCell><strong>Données</strong></TableCell>
<TableCell>
{stepAnalysis.stepInfo.hasData ? (
<Chip label={`${stepAnalysis.stepInfo.dataKeys.length} clés`} size="small" color="success" />
) : (
<Chip label="Aucune" size="small" color="default" />
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</AccordionDetails>
</Accordion>
{/* Analyse du type */}
<Accordion
expanded={expanded.includes('typeAnalysis')}
onChange={handleAccordionChange('typeAnalysis')}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CodeIcon fontSize="small" />
<Typography variant="subtitle2">Analyse du type</Typography>
{stepAnalysis.typeAnalysis.hasConfiguration ? (
<CheckCircleIcon fontSize="small" color="success" />
) : (
<ErrorIcon fontSize="small" color="error" />
)}
</Box>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip
label={stepAnalysis.typeAnalysis.isStandardType ? 'Type Standard' : 'Type Non-Standard'}
size="small"
color={stepAnalysis.typeAnalysis.isStandardType ? 'success' : 'default'}
/>
<Chip
label={stepAnalysis.typeAnalysis.isVWBAction ? 'Action VWB' : 'Non-VWB'}
size="small"
color={stepAnalysis.typeAnalysis.isVWBAction ? 'primary' : 'default'}
/>
</Box>
<Typography variant="body2">
<strong>Source de configuration :</strong> {stepAnalysis.typeAnalysis.configurationSource}
</Typography>
</Box>
</AccordionDetails>
</Accordion>
{/* Analyse VWB */}
<Accordion
expanded={expanded.includes('vwbAnalysis')}
onChange={handleAccordionChange('vwbAnalysis')}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<BugReportIcon fontSize="small" />
<Typography variant="subtitle2">Analyse VWB</Typography>
<Chip
label={`${Object.values(stepAnalysis.vwbAnalysis.detectionMethods).filter(Boolean).length}/8`}
size="small"
color="info"
/>
</Box>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{stepAnalysis.vwbAnalysis.vwbActionId && (
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
<strong>Action ID :</strong> {stepAnalysis.vwbAnalysis.vwbActionId}
</Alert>
)}
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
Méthodes de détection :
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{Object.entries(stepAnalysis.vwbAnalysis.detectionMethods).map(([method, detected]) => (
<Box key={method} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{detected ? (
<CheckCircleIcon fontSize="small" color="success" />
) : (
<ErrorIcon fontSize="small" color="disabled" />
)}
<Typography variant="body2" sx={{ fontSize: '0.8rem' }}>
{method}
</Typography>
</Box>
))}
</Box>
</Box>
</AccordionDetails>
</Accordion>
{/* Analyse des paramètres */}
<Accordion
expanded={expanded.includes('parametersAnalysis')}
onChange={handleAccordionChange('parametersAnalysis')}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SettingsIcon fontSize="small" />
<Typography variant="subtitle2">Paramètres</Typography>
<Chip
label={stepAnalysis.parametersAnalysis.parameterCount}
size="small"
color={stepAnalysis.parametersAnalysis.hasParameters ? 'success' : 'default'}
/>
{stepAnalysis.parametersAnalysis.missingRequired.length > 0 && (
<WarningIcon fontSize="small" color="warning" />
)}
</Box>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{stepAnalysis.parametersAnalysis.hasParameters ? (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{stepAnalysis.parametersAnalysis.parameterKeys.map(key => (
<Chip key={key} label={key} size="small" variant="outlined" />
))}
</Box>
) : (
<Typography variant="body2" color="text.secondary">
Aucun paramètre configuré
</Typography>
)}
{stepAnalysis.parametersAnalysis.missingRequired.length > 0 && (
<Alert severity="warning" sx={{ fontSize: '0.8rem' }}>
<strong>Paramètres requis manquants :</strong>{' '}
{stepAnalysis.parametersAnalysis.missingRequired.join(', ')}
</Alert>
)}
</Box>
</AccordionDetails>
</Accordion>
{/* Analyse de validation */}
{stepAnalysis.validationAnalysis.hasErrors && (
<Accordion
expanded={expanded.includes('validationAnalysis')}
onChange={handleAccordionChange('validationAnalysis')}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<WarningIcon fontSize="small" />
<Typography variant="subtitle2">Validation</Typography>
<Chip
label={`${stepAnalysis.validationAnalysis.errorCount} erreurs`}
size="small"
color="error"
/>
</Box>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{stepAnalysis.validationAnalysis.errors.map((error, index) => (
<Alert key={index} severity={error.severity as any} sx={{ fontSize: '0.8rem' }}>
<strong>{error.parameter} :</strong> {error.message}
</Alert>
))}
</Box>
</AccordionDetails>
</Accordion>
)}
</>
)}
</Box>
</Box>
);
};
export default DebugPanel;

View File

@@ -0,0 +1,592 @@
/**
* Composant Onglet de Documentation - Documentation interactive pour chaque outil
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce composant affiche la documentation contextuelle avec guides étape par étape,
* exemples visuels et exemples interactifs en français.
*/
import React, { useState, useCallback } from 'react';
import {
Box,
Typography,
Tabs,
Tab,
Card,
CardContent,
Button,
Stepper,
Step,
StepLabel,
StepContent,
Alert,
Chip,
Divider,
List,
ListItem,
ListItemIcon,
ListItemText,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@mui/material';
import {
Help as HelpIcon,
PlayArrow as PlayIcon,
ExpandMore as ExpandMoreIcon,
Lightbulb as TipIcon,
Warning as WarningIcon,
CheckCircle as CheckIcon,
} from '@mui/icons-material';
// Import du composant Glossaire
import Glossary from '../Glossary';
// Import des types partagés
import { DocumentationTabProps } from '../../types';
interface DocumentationContent {
title: string;
sections: DocumentationSection[];
examples: InteractiveExample[];
glossary: GlossaryTerm[];
}
interface DocumentationSection {
id: string;
title: string;
content: string;
steps?: DocumentationStep[];
tips?: string[];
warnings?: string[];
}
interface DocumentationStep {
title: string;
description: string;
image?: string;
action?: () => void;
}
interface InteractiveExample {
id: string;
title: string;
description: string;
steps: string[];
onTry: () => void;
}
interface GlossaryTerm {
term: string;
definition: string;
}
// Contenu de documentation par outil
const documentationContent: Record<string, DocumentationContent> = {
canvas: {
title: 'Canvas - Espace de travail',
sections: [
{
id: 'overview',
title: 'Vue d\'ensemble',
content: 'Le Canvas est votre espace de travail principal pour créer des workflows visuels. Vous pouvez y glisser des étapes depuis la palette, les connecter et les organiser.',
steps: [
{
title: 'Glisser une étape',
description: 'Glissez une étape depuis la palette vers le canvas',
action: () => {
// Déclencher un tutoriel interactif pour le drag-and-drop
window.dispatchEvent(new CustomEvent('vwb:tutorial:dragDrop'));
},
},
{
title: 'Connecter les étapes',
description: 'Cliquez et glissez depuis le point de connexion d\'une étape vers une autre',
action: () => {
// Déclencher un tutoriel interactif pour les connexions
window.dispatchEvent(new CustomEvent('vwb:tutorial:connect'));
},
},
{
title: 'Sélectionner une étape',
description: 'Cliquez sur une étape pour la sélectionner et voir ses propriétés',
action: () => {
// Déclencher un tutoriel interactif pour la sélection
window.dispatchEvent(new CustomEvent('vwb:tutorial:select'));
},
},
],
tips: [
'Utilisez la grille pour aligner vos étapes proprement',
'La minimap apparaît automatiquement pour les gros workflows',
'Utilisez Ctrl+Z pour annuler une action',
'Double-cliquez sur une étape pour l\'éditer rapidement',
'Utilisez Shift+clic pour sélectionner plusieurs étapes',
],
},
{
id: 'navigation',
title: 'Navigation',
content: 'Le canvas offre plusieurs outils de navigation pour travailler efficacement avec vos workflows.',
steps: [
{
title: 'Zoom',
description: 'Utilisez la molette de la souris ou les contrôles pour zoomer',
action: () => {
// Déclencher un tutoriel interactif pour le zoom
window.dispatchEvent(new CustomEvent('vwb:tutorial:zoom'));
},
},
{
title: 'Panoramique',
description: 'Maintenez le clic droit et glissez pour déplacer la vue',
action: () => {
// Déclencher un tutoriel interactif pour le panoramique
window.dispatchEvent(new CustomEvent('vwb:tutorial:pan'));
},
},
{
title: 'Ajustement automatique',
description: 'Cliquez sur le bouton "Ajuster" pour voir tout le workflow',
action: () => {
// Déclencher un tutoriel interactif pour l'ajustement
window.dispatchEvent(new CustomEvent('vwb:tutorial:fitView'));
},
},
],
tips: [
'Utilisez la molette pour zoomer rapidement',
'La minimap permet de naviguer dans les gros workflows',
'Les raccourcis clavier accélèrent la navigation',
],
},
{
id: 'shortcuts',
title: 'Raccourcis clavier',
content: 'Maîtrisez les raccourcis clavier pour une utilisation plus efficace du canvas.',
steps: [
{
title: 'Ctrl+Z / Ctrl+Y',
description: 'Annuler / Refaire les dernières actions',
},
{
title: 'Suppr',
description: 'Supprimer les éléments sélectionnés',
},
{
title: 'Ctrl+A',
description: 'Sélectionner tous les éléments',
},
{
title: 'Ctrl+C / Ctrl+V',
description: 'Copier / Coller les éléments sélectionnés',
},
{
title: 'Espace + glisser',
description: 'Mode panoramique temporaire',
},
],
},
],
examples: [
{
id: 'first-workflow',
title: 'Créer votre premier workflow',
description: 'Apprenez à créer un workflow simple avec quelques étapes',
steps: [
'Glissez une étape "Cliquer" depuis la palette vers le canvas',
'Ajoutez une étape "Saisir du texte" en dessous',
'Connectez les deux étapes en glissant depuis le point de sortie vers l\'entrée',
'Configurez les paramètres dans le panneau de propriétés',
'Testez votre workflow avec le bouton d\'exécution',
],
onTry: () => {
// Déclencher un tutoriel interactif complet
window.dispatchEvent(new CustomEvent('vwb:tutorial:firstWorkflow'));
},
},
{
id: 'complex-workflow',
title: 'Workflow avec conditions',
description: 'Créez un workflow plus complexe avec des conditions et des boucles',
steps: [
'Créez une étape de démarrage',
'Ajoutez une condition "Si/Alors"',
'Connectez les branches "Vrai" et "Faux"',
'Ajoutez des actions différentes pour chaque branche',
'Testez les différents chemins d\'exécution',
],
onTry: () => {
// Déclencher un tutoriel pour les workflows complexes
window.dispatchEvent(new CustomEvent('vwb:tutorial:complexWorkflow'));
},
},
{
id: 'debugging-workflow',
title: 'Déboguer un workflow',
description: 'Apprenez à identifier et corriger les erreurs dans vos workflows',
steps: [
'Identifiez les étapes avec des erreurs (indicateurs rouges)',
'Vérifiez les paramètres manquants dans le panneau de propriétés',
'Corrigez les étapes déconnectées (surlignées en orange)',
'Utilisez l\'exécution pas à pas pour tester',
'Consultez les logs d\'erreur détaillés',
],
onTry: () => {
// Déclencher un tutoriel de débogage
window.dispatchEvent(new CustomEvent('vwb:tutorial:debugging'));
},
},
],
glossary: [
{ term: 'Étape', definition: 'Un élément d\'action dans votre workflow' },
{ term: 'Connexion', definition: 'Un lien entre deux étapes définissant l\'ordre d\'exécution' },
{ term: 'Minimap', definition: 'Une vue miniature du workflow pour la navigation' },
{ term: 'Point de connexion', definition: 'Zone cliquable sur une étape pour créer des liens' },
{ term: 'Grille d\'alignement', definition: 'Guide visuel pour positionner les étapes proprement' },
],
},
palette: {
title: 'Palette - Boîte à outils',
sections: [
{
id: 'categories',
title: 'Catégories d\'étapes',
content: 'La palette organise les étapes en catégories pour faciliter la recherche.',
steps: [
{
title: 'Actions Web',
description: 'Étapes pour interagir avec les pages web (cliquer, saisir, etc.)',
},
{
title: 'Logique',
description: 'Étapes de contrôle de flux (conditions, boucles)',
},
{
title: 'Données',
description: 'Étapes pour manipuler les données (extraire, transformer)',
},
{
title: 'Contrôle',
description: 'Étapes de contrôle d\'exécution (attendre, arrêter)',
},
],
},
{
id: 'search',
title: 'Recherche d\'étapes',
content: 'Utilisez la barre de recherche pour trouver rapidement une étape spécifique.',
tips: [
'Tapez le nom de l\'action que vous voulez effectuer',
'La recherche fonctionne sur les noms et descriptions',
'Les résultats sont filtrés en temps réel',
],
},
],
examples: [
{
id: 'find-step',
title: 'Trouver une étape rapidement',
description: 'Utilisez la recherche pour trouver l\'étape dont vous avez besoin',
steps: [
'Cliquez dans la barre de recherche',
'Tapez "clic" pour trouver les étapes de clic',
'Glissez l\'étape trouvée sur le canvas',
],
onTry: () => console.log('Exemple interactif: Recherche d\'étape'),
},
],
glossary: [
{ term: 'Catégorie', definition: 'Un groupe d\'étapes similaires' },
{ term: 'Tooltip', definition: 'Une infobulle explicative qui apparaît au survol' },
],
},
properties: {
title: 'Propriétés - Configuration des étapes',
sections: [
{
id: 'parameter-types',
title: 'Types de paramètres',
content: 'Chaque étape peut avoir différents types de paramètres à configurer.',
steps: [
{
title: 'Texte',
description: 'Champs de saisie libre pour du texte',
},
{
title: 'Nombre',
description: 'Champs numériques avec validation',
},
{
title: 'Booléen',
description: 'Interrupteurs pour les options vrai/faux',
},
{
title: 'Sélection',
description: 'Listes déroulantes avec options prédéfinies',
},
{
title: 'Sélection visuelle',
description: 'Boutons pour sélectionner des éléments à l\'écran',
},
],
},
{
id: 'validation',
title: 'Validation en temps réel',
content: 'Les paramètres sont validés automatiquement pendant la saisie.',
warnings: [
'Les paramètres obligatoires doivent être remplis',
'Les valeurs numériques doivent respecter les limites',
'Les sélections visuelles doivent être effectuées',
],
},
],
examples: [
{
id: 'configure-click',
title: 'Configurer une étape de clic',
description: 'Apprenez à configurer les paramètres d\'une étape de clic',
steps: [
'Sélectionnez une étape de clic sur le canvas',
'Cliquez sur "Sélectionner un élément" dans les propriétés',
'Choisissez le type de clic dans la liste déroulante',
],
onTry: () => console.log('Exemple interactif: Configuration de clic'),
},
],
glossary: [
{ term: 'Paramètre', definition: 'Une valeur configurable d\'une étape' },
{ term: 'Validation', definition: 'Vérification automatique de la validité des valeurs' },
],
},
};
/**
* Composant Onglet de Documentation
*/
const DocumentationTab: React.FC<DocumentationTabProps> = ({
toolName,
isActive,
onActivate,
}) => {
const [activeSection, setActiveSection] = useState(0);
const [activeStep, setActiveStep] = useState(0);
const content = documentationContent[toolName];
// Gestionnaire d'événements clavier pour la documentation
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
switch (event.key) {
case 'ArrowUp':
// Navigation vers la section précédente
event.preventDefault();
setActiveSection(prev => Math.max(0, prev - 1));
break;
case 'ArrowDown':
// Navigation vers la section suivante
event.preventDefault();
const maxSections = content?.sections.length || 0;
setActiveSection(prev => Math.min(maxSections - 1, prev + 1));
break;
case 'ArrowLeft':
// Navigation vers l'étape précédente
event.preventDefault();
setActiveStep(prev => Math.max(0, prev - 1));
break;
case 'ArrowRight':
// Navigation vers l'étape suivante
event.preventDefault();
const maxSteps = content?.sections[activeSection]?.steps?.length || 0;
setActiveStep(prev => Math.min(maxSteps - 1, prev + 1));
break;
case 'Home':
// Aller au début
event.preventDefault();
setActiveSection(0);
setActiveStep(0);
break;
case 'End':
// Aller à la fin
event.preventDefault();
const lastSection = (content?.sections.length || 1) - 1;
setActiveSection(lastSection);
break;
}
}, [activeSection, content]);
if (!content) {
return (
<Box sx={{ p: 2 }}>
<Alert severity="info">
Documentation non disponible pour cet outil.
</Alert>
</Box>
);
}
return (
<Box
sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}
role="tabpanel"
aria-label={`Documentation pour ${content.title}`}
onKeyDown={handleKeyDown}
tabIndex={0}
>
{/* En-tête */}
<Box sx={{ p: 2, borderBottom: '1px solid #e0e0e0' }}>
<Typography variant="h6" gutterBottom>
<HelpIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
{content.title}
</Typography>
</Box>
{/* Contenu avec onglets */}
<Box sx={{ flex: 1, overflow: 'hidden' }}>
<Tabs
value={activeSection}
onChange={(_, newValue) => setActiveSection(newValue)}
variant="scrollable"
scrollButtons="auto"
>
<Tab label="Guide" />
<Tab label="Exemples" />
<Tab label="Glossaire" />
</Tabs>
{/* Panneau Guide */}
{activeSection === 0 && (
<Box sx={{ p: 2, height: 'calc(100% - 48px)', overflow: 'auto' }}>
{content.sections.map((section, index) => (
<Accordion key={section.id} defaultExpanded={index === 0}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">{section.title}</Typography>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ mb: 2 }}>
<Typography variant="body1" paragraph>
{section.content}
</Typography>
{/* Étapes */}
{section.steps && (
<Stepper orientation="vertical" activeStep={activeStep}>
{section.steps.map((step, stepIndex) => (
<Step key={stepIndex}>
<StepLabel>{step.title}</StepLabel>
<StepContent>
<Typography variant="body2" color="text.secondary">
{step.description}
</Typography>
{step.action && (
<Button
size="small"
onClick={step.action}
sx={{ mt: 1 }}
>
Essayer
</Button>
)}
</StepContent>
</Step>
))}
</Stepper>
)}
{/* Conseils */}
{section.tips && section.tips.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
<TipIcon sx={{ mr: 1, verticalAlign: 'middle', fontSize: 'small' }} />
Conseils
</Typography>
<List dense>
{section.tips.map((tip, tipIndex) => (
<ListItem key={tipIndex}>
<ListItemIcon>
<CheckIcon color="success" fontSize="small" />
</ListItemIcon>
<ListItemText primary={tip} />
</ListItem>
))}
</List>
</Box>
)}
{/* Avertissements */}
{section.warnings && section.warnings.length > 0 && (
<Box sx={{ mt: 2 }}>
<Alert severity="warning" icon={<WarningIcon />}>
<Typography variant="subtitle2" gutterBottom>
Points d'attention
</Typography>
<List dense>
{section.warnings.map((warning, warningIndex) => (
<ListItem key={warningIndex} sx={{ py: 0 }}>
<ListItemText primary={warning} />
</ListItem>
))}
</List>
</Alert>
</Box>
)}
</Box>
</AccordionDetails>
</Accordion>
))}
</Box>
)}
{/* Panneau Exemples */}
{activeSection === 1 && (
<Box sx={{ p: 2, height: 'calc(100% - 48px)', overflow: 'auto' }}>
{content.examples.map((example) => (
<Card key={example.id} sx={{ mb: 2 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
{example.title}
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
{example.description}
</Typography>
<Typography variant="subtitle2" gutterBottom>
Étapes à suivre :
</Typography>
<List dense>
{example.steps.map((step, stepIndex) => (
<ListItem key={stepIndex}>
<ListItemIcon>
<Chip label={stepIndex + 1} size="small" color="primary" />
</ListItemIcon>
<ListItemText primary={step} />
</ListItem>
))}
</List>
<Button
variant="contained"
startIcon={<PlayIcon />}
onClick={example.onTry}
sx={{ mt: 2 }}
>
Essayer cet exemple
</Button>
</CardContent>
</Card>
))}
</Box>
)}
{/* Panneau Glossaire */}
{activeSection === 2 && (
<Box sx={{ height: 'calc(100% - 48px)', overflow: 'hidden' }}>
<Glossary />
</Box>
)}
</Box>
</Box>
);
};
export default DocumentationTab;

View File

@@ -0,0 +1,396 @@
/**
* Composant EvidenceDetail - Affichage détaillé d'une Evidence
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*/
import React, { useState, useRef, useCallback } from 'react';
import {
Box,
Typography,
IconButton,
Paper,
Chip,
Accordion,
AccordionSummary,
AccordionDetails,
Alert,
Button,
Tooltip,
Divider
} from '@mui/material';
import {
Close as CloseIcon,
ExpandMore as ExpandMoreIcon,
ZoomIn as ZoomInIcon,
ZoomOut as ZoomOutIcon,
Fullscreen as FullscreenIcon,
Download as DownloadIcon,
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Schedule as TimeIcon,
Visibility as ConfidenceIcon
} from '@mui/icons-material';
import { VWBEvidence, EvidenceUtils } from '../../types/evidence';
import ScreenshotViewer from './ScreenshotViewer';
interface EvidenceDetailProps {
evidence: VWBEvidence;
onClose?: () => void;
showMetadata?: boolean;
}
const EvidenceDetail: React.FC<EvidenceDetailProps> = ({
evidence,
onClose,
showMetadata = true
}) => {
// État local
const [zoom, setZoom] = useState(1);
const [expandedPanels, setExpandedPanels] = useState<string[]>(['screenshot']);
const screenshotRef = useRef<HTMLImageElement>(null);
// Gestion des panneaux accordéon
const handlePanelChange = (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
setExpandedPanels(prev =>
isExpanded
? [...prev, panel]
: prev.filter(p => p !== panel)
);
};
// Gestion du zoom
const handleZoomIn = useCallback(() => {
setZoom(prev => Math.min(prev * 1.2, 5));
}, []);
const handleZoomOut = useCallback(() => {
setZoom(prev => Math.max(prev / 1.2, 0.1));
}, []);
const handleZoomReset = useCallback(() => {
setZoom(1);
}, []);
// Téléchargement du screenshot
const handleDownloadScreenshot = useCallback(() => {
if (!evidence.screenshot_base64) return;
const link = document.createElement('a');
link.href = `data:image/png;base64,${evidence.screenshot_base64}`;
link.download = `evidence_${evidence.id}_${evidence.captured_at.replace(/[:.]/g, '-')}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, [evidence]);
// Plein écran
const handleFullscreen = useCallback(() => {
if (screenshotRef.current) {
if (screenshotRef.current.requestFullscreen) {
screenshotRef.current.requestFullscreen();
}
}
}, []);
// Rendu des métadonnées
const renderMetadata = () => {
if (!showMetadata) return null;
const metadata = evidence.metadata || {};
const metadataEntries = Object.entries(metadata);
if (metadataEntries.length === 0) {
return (
<Typography variant="body2" color="text.secondary">
Aucune métadonnée disponible
</Typography>
);
}
return (
<Box className="evidence-metadata-grid">
{metadataEntries.map(([key, value]) => (
<Box key={key} className="evidence-metadata-item">
<Typography variant="caption" className="evidence-metadata-item-label">
{key}
</Typography>
<Typography variant="body2" className="evidence-metadata-item-value">
{typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)}
</Typography>
</Box>
))}
</Box>
);
};
return (
<Box className="evidence-detail" sx={{ height: '100%', overflow: 'auto' }}>
{/* En-tête */}
<Box className="evidence-detail-header">
<Box>
<Typography variant="h6" className="evidence-detail-title">
{evidence.action_name || evidence.action_id}
</Typography>
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
ID: {evidence.id}
</Typography>
</Box>
{onClose && (
<IconButton
onClick={onClose}
className="evidence-detail-close"
sx={{ color: '#94a3b8' }}
>
<CloseIcon />
</IconButton>
)}
</Box>
{/* Informations principales */}
<Box mb={2}>
<Box display="flex" alignItems="center" gap={2} mb={2}>
<Chip
icon={evidence.success ? <SuccessIcon /> : <ErrorIcon />}
label={evidence.success ? 'SUCCÈS' : 'ERREUR'}
color={evidence.success ? 'success' : 'error'}
variant="filled"
/>
<Chip
icon={<TimeIcon />}
label={EvidenceUtils.formatExecutionTime(evidence.execution_time_ms)}
variant="outlined"
sx={{ color: '#94a3b8', borderColor: '#475569' }}
/>
{evidence.confidence_score && (
<Chip
icon={<ConfidenceIcon />}
label={EvidenceUtils.formatConfidence(evidence.confidence_score)}
variant="outlined"
sx={{ color: '#94a3b8', borderColor: '#475569' }}
/>
)}
</Box>
<Typography variant="body2" sx={{ color: '#94a3b8' }}>
<strong>Capturé le :</strong> {EvidenceUtils.formatDate(evidence.captured_at)}
</Typography>
</Box>
{/* Message d'erreur */}
{!evidence.success && evidence.error && (
<Alert severity="error" sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
{evidence.error.message}
</Typography>
{evidence.error.details && Object.keys(evidence.error.details).length > 0 && (
<Box mt={1}>
<Typography variant="caption" display="block">
Détails techniques :
</Typography>
<pre style={{ fontSize: '11px', margin: '4px 0', whiteSpace: 'pre-wrap' }}>
{JSON.stringify(evidence.error.details, null, 2)}
</pre>
</Box>
)}
</Alert>
)}
{/* Screenshot */}
{evidence.screenshot_base64 && (
<Accordion
expanded={expandedPanels.includes('screenshot')}
onChange={handlePanelChange('screenshot')}
sx={{ mb: 2, backgroundColor: '#0f172a', color: '#e2e8f0' }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
<Typography variant="subtitle2">
Screenshot d'Evidence
</Typography>
</AccordionSummary>
<AccordionDetails>
{/* Contrôles du screenshot */}
<Box display="flex" gap={1} mb={2} flexWrap="wrap">
<Tooltip title="Zoom avant">
<IconButton size="small" onClick={handleZoomIn} sx={{ color: '#94a3b8' }}>
<ZoomInIcon />
</IconButton>
</Tooltip>
<Tooltip title="Zoom arrière">
<IconButton size="small" onClick={handleZoomOut} sx={{ color: '#94a3b8' }}>
<ZoomOutIcon />
</IconButton>
</Tooltip>
<Tooltip title="Taille réelle">
<Button size="small" onClick={handleZoomReset} sx={{ color: '#94a3b8' }}>
{Math.round(zoom * 100)}%
</Button>
</Tooltip>
<Tooltip title="Plein écran">
<IconButton size="small" onClick={handleFullscreen} sx={{ color: '#94a3b8' }}>
<FullscreenIcon />
</IconButton>
</Tooltip>
<Tooltip title="Télécharger">
<IconButton size="small" onClick={handleDownloadScreenshot} sx={{ color: '#94a3b8' }}>
<DownloadIcon />
</IconButton>
</Tooltip>
</Box>
{/* Visualiseur de screenshot */}
<ScreenshotViewer
screenshot={evidence.screenshot_base64}
bbox={evidence.bbox}
clickPoint={evidence.click_point}
zoom={zoom}
onZoomChange={setZoom}
maxWidth={600}
maxHeight={400}
/>
</AccordionDetails>
</Accordion>
)}
{/* Détails techniques */}
<Accordion
expanded={expandedPanels.includes('technical')}
onChange={handlePanelChange('technical')}
sx={{ mb: 2, backgroundColor: '#0f172a', color: '#e2e8f0' }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
<Typography variant="subtitle2">
Détails Techniques
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="evidence-metadata-grid">
<Box className="evidence-metadata-item">
<Typography variant="caption" className="evidence-metadata-item-label">
Contrat
</Typography>
<Typography variant="body2" className="evidence-metadata-item-value">
{evidence.contract} v{evidence.version}
</Typography>
</Box>
<Box className="evidence-metadata-item">
<Typography variant="caption" className="evidence-metadata-item-label">
Action ID
</Typography>
<Typography variant="body2" className="evidence-metadata-item-value">
{evidence.action_id}
</Typography>
</Box>
<Box className="evidence-metadata-item">
<Typography variant="caption" className="evidence-metadata-item-label">
Temps d'exécution
</Typography>
<Typography variant="body2" className="evidence-metadata-item-value">
{evidence.execution_time_ms}ms
</Typography>
</Box>
{evidence.confidence_score && (
<Box className="evidence-metadata-item">
<Typography variant="caption" className="evidence-metadata-item-label">
Score de confiance
</Typography>
<Typography variant="body2" className="evidence-metadata-item-value">
{evidence.confidence_score.toFixed(3)}
</Typography>
</Box>
)}
{evidence.bbox && (
<Box className="evidence-metadata-item">
<Typography variant="caption" className="evidence-metadata-item-label">
Zone détectée
</Typography>
<Typography variant="body2" className="evidence-metadata-item-value">
{evidence.bbox.x}, {evidence.bbox.y} ({evidence.bbox.width}×{evidence.bbox.height})
</Typography>
</Box>
)}
{evidence.click_point && (
<Box className="evidence-metadata-item">
<Typography variant="caption" className="evidence-metadata-item-label">
Point de clic
</Typography>
<Typography variant="body2" className="evidence-metadata-item-value">
{evidence.click_point.x}, {evidence.click_point.y}
</Typography>
</Box>
)}
</Box>
</AccordionDetails>
</Accordion>
{/* Métadonnées personnalisées */}
{showMetadata && evidence.metadata && Object.keys(evidence.metadata).length > 0 && (
<Accordion
expanded={expandedPanels.includes('metadata')}
onChange={handlePanelChange('metadata')}
sx={{ mb: 2, backgroundColor: '#0f172a', color: '#e2e8f0' }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
<Typography variant="subtitle2">
Métadonnées Personnalisées
</Typography>
</AccordionSummary>
<AccordionDetails>
{renderMetadata()}
</AccordionDetails>
</Accordion>
)}
{/* Données brutes (pour debug) */}
<Accordion
expanded={expandedPanels.includes('raw')}
onChange={handlePanelChange('raw')}
sx={{ mb: 2, backgroundColor: '#0f172a', color: '#e2e8f0' }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
<Typography variant="subtitle2">
Données Brutes (Debug)
</Typography>
</AccordionSummary>
<AccordionDetails>
<Paper
sx={{
p: 2,
backgroundColor: '#1e293b',
border: '1px solid #334155',
maxHeight: 300,
overflow: 'auto'
}}
>
<pre style={{
fontSize: '11px',
margin: 0,
whiteSpace: 'pre-wrap',
color: '#e2e8f0',
fontFamily: 'monospace'
}}>
{JSON.stringify({
...evidence,
screenshot_base64: evidence.screenshot_base64 ? '[BASE64_DATA]' : null
}, null, 2)}
</pre>
</Paper>
</AccordionDetails>
</Accordion>
</Box>
);
};
export default EvidenceDetail;

View File

@@ -0,0 +1,406 @@
/**
* Composant EvidenceFilters - Filtres pour les Evidence
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*/
import React, { useState, useMemo } from 'react';
import {
Box,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Chip,
Button,
Slider,
Typography,
Accordion,
AccordionSummary,
AccordionDetails,
SelectChangeEvent
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
Clear as ClearIcon,
FilterList as FilterIcon
} from '@mui/icons-material';
import { VWBEvidence, EvidenceFilters } from '../../types/evidence';
interface EvidenceFiltersProps {
filters: EvidenceFilters;
onFiltersChange: (filters: Partial<EvidenceFilters>) => void;
onClearFilters: () => void;
evidences: VWBEvidence[];
}
const EvidenceFiltersComponent: React.FC<EvidenceFiltersProps> = ({
filters,
onFiltersChange,
onClearFilters,
evidences
}) => {
// État local
const [expandedPanels, setExpandedPanels] = useState<string[]>(['basic']);
// Types d'actions disponibles
const availableActionTypes = useMemo(() => {
const types = new Set<string>();
evidences.forEach(evidence => {
types.add(evidence.action_name || evidence.action_id);
});
return Array.from(types).sort();
}, [evidences]);
// Plages de valeurs pour les sliders
const valueRanges = useMemo(() => {
const executionTimes = evidences.map(e => e.execution_time_ms);
const confidenceScores = evidences.filter(e => e.confidence_score !== undefined).map(e => e.confidence_score!);
return {
executionTime: {
min: Math.min(...executionTimes, 0),
max: Math.max(...executionTimes, 60000)
},
confidence: {
min: Math.min(...confidenceScores, 0),
max: Math.max(...confidenceScores, 1)
}
};
}, [evidences]);
// Gestion des panneaux accordéon
const handlePanelChange = (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
setExpandedPanels(prev =>
isExpanded
? [...prev, panel]
: prev.filter(p => p !== panel)
);
};
// Gestion des types d'actions
const handleActionTypesChange = (event: SelectChangeEvent<string[]>) => {
const value = event.target.value;
onFiltersChange({
actionTypes: typeof value === 'string' ? value.split(',') : value
});
};
// Gestion du statut
const handleStatusChange = (event: SelectChangeEvent<string>) => {
onFiltersChange({
status: event.target.value as 'all' | 'success' | 'error'
});
};
// Gestion de la recherche textuelle
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
onFiltersChange({
searchText: event.target.value
});
};
// Gestion des dates
const handleStartDateChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const date = event.target.value ? new Date(event.target.value) : undefined;
onFiltersChange({
dateRange: {
...filters.dateRange,
start: date
}
});
};
const handleEndDateChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const date = event.target.value ? new Date(event.target.value) : undefined;
onFiltersChange({
dateRange: {
...filters.dateRange,
end: date
}
});
};
// Fonction utilitaire pour formater les dates
const formatDateForInput = (date: Date | undefined): string => {
if (!date) return '';
return date.toISOString().split('T')[0];
};
// Gestion des sliders
const handleConfidenceRangeChange = (event: Event, newValue: number | number[]) => {
const [min, max] = newValue as number[];
onFiltersChange({
confidenceRange: { min, max }
});
};
const handleExecutionTimeRangeChange = (event: Event, newValue: number | number[]) => {
const [min, max] = newValue as number[];
onFiltersChange({
executionTimeRange: { min, max }
});
};
// Vérification si des filtres sont appliqués
const hasActiveFilters = (
filters.actionTypes.length > 0 ||
filters.status !== 'all' ||
filters.searchText.trim() !== '' ||
filters.dateRange.start !== undefined ||
filters.dateRange.end !== undefined ||
filters.confidenceRange.min > valueRanges.confidence.min ||
filters.confidenceRange.max < valueRanges.confidence.max ||
filters.executionTimeRange.min > valueRanges.executionTime.min ||
filters.executionTimeRange.max < valueRanges.executionTime.max
);
return (
<Box className="evidence-filters">
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
<Box display="flex" alignItems="center" gap={1}>
<FilterIcon sx={{ color: '#1976d2' }} />
<Typography variant="h6" sx={{ color: '#e2e8f0', fontWeight: 600 }}>
Filtres
</Typography>
{hasActiveFilters && (
<Chip
label="Filtres actifs"
size="small"
color="primary"
variant="filled"
/>
)}
</Box>
{hasActiveFilters && (
<Button
startIcon={<ClearIcon />}
onClick={onClearFilters}
size="small"
sx={{ color: '#94a3b8' }}
>
Effacer
</Button>
)}
</Box>
{/* Filtres de base */}
<Accordion
expanded={expandedPanels.includes('basic')}
onChange={handlePanelChange('basic')}
sx={{ mb: 1, backgroundColor: '#334155', color: '#e2e8f0' }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
<Typography variant="subtitle2">Filtres de base</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="evidence-filters-row">
{/* Recherche textuelle */}
<TextField
label="Recherche"
placeholder="Rechercher dans les Evidence..."
value={filters.searchText}
onChange={handleSearchChange}
size="small"
sx={{
minWidth: 200,
'& .MuiOutlinedInput-root': {
backgroundColor: '#1e293b',
color: '#e2e8f0',
'& fieldset': { borderColor: '#475569' },
'&:hover fieldset': { borderColor: '#64748b' },
'&.Mui-focused fieldset': { borderColor: '#1976d2' }
},
'& .MuiInputLabel-root': { color: '#94a3b8' }
}}
/>
{/* Statut */}
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel sx={{ color: '#94a3b8' }}>Statut</InputLabel>
<Select
value={filters.status}
onChange={handleStatusChange}
label="Statut"
sx={{
backgroundColor: '#1e293b',
color: '#e2e8f0',
'& .MuiOutlinedInput-notchedOutline': { borderColor: '#475569' },
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: '#64748b' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: '#1976d2' }
}}
>
<MenuItem value="all">Tous</MenuItem>
<MenuItem value="success">Succès</MenuItem>
<MenuItem value="error">Erreurs</MenuItem>
</Select>
</FormControl>
{/* Types d'actions */}
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel sx={{ color: '#94a3b8' }}>Types d'actions</InputLabel>
<Select
multiple
value={filters.actionTypes}
onChange={handleActionTypesChange}
label="Types d'actions"
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value) => (
<Chip key={value} label={value} size="small" />
))}
</Box>
)}
sx={{
backgroundColor: '#1e293b',
color: '#e2e8f0',
'& .MuiOutlinedInput-notchedOutline': { borderColor: '#475569' },
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: '#64748b' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: '#1976d2' }
}}
>
{availableActionTypes.map((type) => (
<MenuItem key={type} value={type}>
{type}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
</AccordionDetails>
</Accordion>
{/* Filtres de date */}
<Accordion
expanded={expandedPanels.includes('dates')}
onChange={handlePanelChange('dates')}
sx={{ mb: 1, backgroundColor: '#334155', color: '#e2e8f0' }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
<Typography variant="subtitle2">Plage de dates</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="evidence-filters-row">
<TextField
label="Date de début"
type="date"
value={formatDateForInput(filters.dateRange.start)}
onChange={handleStartDateChange}
size="small"
InputLabelProps={{
shrink: true,
}}
sx={{
'& .MuiOutlinedInput-root': {
backgroundColor: '#1e293b',
color: '#e2e8f0',
'& fieldset': { borderColor: '#475569' },
'&:hover fieldset': { borderColor: '#64748b' },
'&.Mui-focused fieldset': { borderColor: '#1976d2' }
},
'& .MuiInputLabel-root': { color: '#94a3b8' }
}}
/>
<TextField
label="Date de fin"
type="date"
value={formatDateForInput(filters.dateRange.end)}
onChange={handleEndDateChange}
size="small"
InputLabelProps={{
shrink: true,
}}
sx={{
'& .MuiOutlinedInput-root': {
backgroundColor: '#1e293b',
color: '#e2e8f0',
'& fieldset': { borderColor: '#475569' },
'&:hover fieldset': { borderColor: '#64748b' },
'&.Mui-focused fieldset': { borderColor: '#1976d2' }
},
'& .MuiInputLabel-root': { color: '#94a3b8' }
}}
/>
</Box>
</AccordionDetails>
</Accordion>
{/* Filtres avancés */}
<Accordion
expanded={expandedPanels.includes('advanced')}
onChange={handlePanelChange('advanced')}
sx={{ mb: 1, backgroundColor: '#334155', color: '#e2e8f0' }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ color: '#94a3b8' }} />}>
<Typography variant="subtitle2">Filtres avancés</Typography>
</AccordionSummary>
<AccordionDetails>
<Box>
{/* Plage de confiance */}
{valueRanges.confidence.max > 0 && (
<Box mb={3}>
<Typography variant="caption" sx={{ color: '#94a3b8', mb: 1, display: 'block' }}>
Score de confiance: {Math.round(filters.confidenceRange.min * 100)}% - {Math.round(filters.confidenceRange.max * 100)}%
</Typography>
<Slider
value={[filters.confidenceRange.min, filters.confidenceRange.max]}
onChange={handleConfidenceRangeChange}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${Math.round(value * 100)}%`}
min={valueRanges.confidence.min}
max={valueRanges.confidence.max}
step={0.01}
sx={{
color: '#1976d2',
'& .MuiSlider-thumb': {
backgroundColor: '#1976d2'
},
'& .MuiSlider-track': {
backgroundColor: '#1976d2'
},
'& .MuiSlider-rail': {
backgroundColor: '#475569'
}
}}
/>
</Box>
)}
{/* Plage de temps d'exécution */}
<Box>
<Typography variant="caption" sx={{ color: '#94a3b8', mb: 1, display: 'block' }}>
Temps d'exécution: {filters.executionTimeRange.min}ms - {filters.executionTimeRange.max}ms
</Typography>
<Slider
value={[filters.executionTimeRange.min, filters.executionTimeRange.max]}
onChange={handleExecutionTimeRangeChange}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${value}ms`}
min={valueRanges.executionTime.min}
max={valueRanges.executionTime.max}
step={100}
sx={{
color: '#1976d2',
'& .MuiSlider-thumb': {
backgroundColor: '#1976d2'
},
'& .MuiSlider-track': {
backgroundColor: '#1976d2'
},
'& .MuiSlider-rail': {
backgroundColor: '#475569'
}
}}
/>
</Box>
</Box>
</AccordionDetails>
</Accordion>
</Box>
);
};
export default EvidenceFiltersComponent;

View File

@@ -0,0 +1,415 @@
/**
* Composant EvidenceList - Liste des Evidence avec filtrage et tri
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*/
import React, { useState, useMemo } from 'react';
import {
Box,
List,
ListItem,
ListItemButton,
ListItemText,
ListItemAvatar,
Avatar,
Typography,
Chip,
IconButton,
Menu,
MenuItem,
TextField,
InputAdornment,
Pagination,
FormControl,
InputLabel,
Select,
SelectChangeEvent
} from '@mui/material';
import {
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Schedule as TimeIcon,
Search as SearchIcon,
Sort as SortIcon,
FilterList as FilterIcon
} from '@mui/icons-material';
import { VWBEvidence, EvidenceFilters, EvidenceUtils } from '../../types/evidence';
interface EvidenceListProps {
evidences: VWBEvidence[];
selectedId?: string;
onSelect: (evidence: VWBEvidence) => void;
filters?: EvidenceFilters;
onFiltersChange?: (filters: Partial<EvidenceFilters>) => void;
sortBy: string;
sortOrder: 'asc' | 'desc';
onSortChange: (sortBy: string, sortOrder: 'asc' | 'desc') => void;
viewMode: 'list' | 'grid';
showFilters?: boolean;
itemsPerPage?: number;
}
const EvidenceList: React.FC<EvidenceListProps> = ({
evidences,
selectedId,
onSelect,
filters,
onFiltersChange,
sortBy,
sortOrder,
onSortChange,
viewMode = 'list',
showFilters = false,
itemsPerPage = 20
}) => {
// État local
const [currentPage, setCurrentPage] = useState(1);
const [searchText, setSearchText] = useState(filters?.searchText || '');
const [sortMenuAnchor, setSortMenuAnchor] = useState<null | HTMLElement>(null);
// Evidence paginées
const paginatedEvidences = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
return evidences.slice(startIndex, endIndex);
}, [evidences, currentPage, itemsPerPage]);
// Nombre total de pages
const totalPages = Math.ceil(evidences.length / itemsPerPage);
// Gestion de la recherche
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setSearchText(value);
setCurrentPage(1); // Retour à la première page
if (onFiltersChange) {
onFiltersChange({ searchText: value });
}
};
// Gestion du tri
const handleSortMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setSortMenuAnchor(event.currentTarget);
};
const handleSortMenuClose = () => {
setSortMenuAnchor(null);
};
const handleSortChange = (newSortBy: string) => {
const newSortOrder = sortBy === newSortBy && sortOrder === 'desc' ? 'asc' : 'desc';
onSortChange(newSortBy, newSortOrder);
handleSortMenuClose();
};
// Gestion du changement de page
const handlePageChange = (event: React.ChangeEvent<unknown>, page: number) => {
setCurrentPage(page);
};
// Rendu d'un item Evidence
const renderEvidenceItem = (evidence: VWBEvidence) => {
const isSelected = selectedId === evidence.id;
const isSuccess = evidence.success;
return (
<ListItem
key={evidence.id}
disablePadding
className={`evidence-list-item ${isSelected ? 'selected' : ''} ${isSuccess ? 'success' : 'error'}`}
sx={{
mb: 1,
borderRadius: 2,
border: isSelected ? '2px solid #1976d2' : '1px solid #334155',
backgroundColor: isSelected ? '#1e40af' : '#334155',
}}
>
<ListItemButton
selected={isSelected}
onClick={() => onSelect(evidence)}
sx={{
borderRadius: 2,
'&:hover': {
backgroundColor: isSelected ? '#1e40af' : '#475569',
transform: 'translateY(-1px)',
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.2)'
}
}}
>
<ListItemAvatar>
<Avatar
sx={{
bgcolor: isSuccess ? '#22c55e' : '#ef4444',
width: 32,
height: 32
}}
>
{isSuccess ? <SuccessIcon fontSize="small" /> : <ErrorIcon fontSize="small" />}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Typography variant="subtitle2" sx={{ color: '#e2e8f0', fontWeight: 600 }}>
{evidence.action_name || evidence.action_id}
</Typography>
}
secondary={
<Box>
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block' }}>
{EvidenceUtils.formatDate(evidence.captured_at)}
</Typography>
<Box display="flex" alignItems="center" gap={1} mt={0.5}>
<Chip
icon={<TimeIcon />}
label={EvidenceUtils.formatExecutionTime(evidence.execution_time_ms)}
size="small"
variant="outlined"
sx={{
fontSize: '10px',
height: 20,
color: '#94a3b8',
borderColor: '#475569'
}}
/>
{evidence.confidence_score && (
<Chip
label={EvidenceUtils.formatConfidence(evidence.confidence_score)}
size="small"
variant="outlined"
sx={{
fontSize: '10px',
height: 20,
color: '#94a3b8',
borderColor: '#475569'
}}
/>
)}
{!isSuccess && (
<Chip
label="ERREUR"
size="small"
sx={{
fontSize: '10px',
height: 20,
backgroundColor: '#ef4444',
color: 'white'
}}
/>
)}
</Box>
</Box>
}
/>
{/* Miniature du screenshot */}
{evidence.screenshot_base64 && (
<Box ml={1}>
<img
src={`data:image/png;base64,${evidence.screenshot_base64}`}
alt={`Screenshot de ${evidence.action_name || evidence.action_id}`}
className="evidence-thumbnail"
style={{
width: 60,
height: 40,
objectFit: 'cover',
borderRadius: 4,
border: '1px solid #475569'
}}
/>
</Box>
)}
</ListItemButton>
</ListItem>
);
};
// Rendu en mode grille
const renderGridView = () => (
<Box className="evidence-grid">
{paginatedEvidences.map(evidence => {
const isSelected = selectedId === evidence.id;
const isSuccess = evidence.success;
return (
<Box
key={evidence.id}
className={`evidence-grid-item ${isSelected ? 'selected' : ''}`}
onClick={() => onSelect(evidence)}
sx={{
border: isSelected ? '2px solid #1976d2' : '1px solid #334155',
backgroundColor: isSelected ? '#1e40af' : '#334155',
cursor: 'pointer',
'&:hover': {
backgroundColor: isSelected ? '#1e40af' : '#475569',
transform: 'translateY(-2px)',
boxShadow: '0 6px 12px rgba(0, 0, 0, 0.3)'
}
}}
>
{/* En-tête */}
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
<Typography variant="subtitle2" sx={{ color: '#e2e8f0', fontWeight: 600 }}>
{evidence.action_name || evidence.action_id}
</Typography>
<Chip
icon={isSuccess ? <SuccessIcon /> : <ErrorIcon />}
label={isSuccess ? 'OK' : 'ERR'}
size="small"
sx={{
backgroundColor: isSuccess ? '#22c55e' : '#ef4444',
color: 'white',
fontSize: '10px',
height: 20
}}
/>
</Box>
{/* Screenshot */}
{evidence.screenshot_base64 && (
<img
src={`data:image/png;base64,${evidence.screenshot_base64}`}
alt={`Screenshot de ${evidence.action_name || evidence.action_id}`}
style={{
width: '100%',
height: 100,
objectFit: 'cover',
borderRadius: 4,
border: '1px solid #475569',
marginBottom: 8
}}
/>
)}
{/* Métadonnées */}
<Box flex={1}>
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block' }}>
{EvidenceUtils.formatDate(evidence.captured_at)}
</Typography>
<Box display="flex" justifyContent="space-between" mt={1}>
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
{EvidenceUtils.formatExecutionTime(evidence.execution_time_ms)}
</Typography>
{evidence.confidence_score && (
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
{EvidenceUtils.formatConfidence(evidence.confidence_score)}
</Typography>
)}
</Box>
</Box>
</Box>
);
})}
</Box>
);
return (
<Box height="100%" display="flex" flexDirection="column">
{/* Barre d'outils */}
<Box p={2} borderBottom="1px solid #334155">
<Box display="flex" gap={2} alignItems="center" mb={showFilters ? 2 : 0}>
{/* Recherche */}
<TextField
size="small"
placeholder="Rechercher dans les Evidence..."
value={searchText}
onChange={handleSearchChange}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon sx={{ color: '#94a3b8' }} />
</InputAdornment>
),
}}
sx={{
flex: 1,
'& .MuiOutlinedInput-root': {
backgroundColor: '#334155',
color: '#e2e8f0',
'& fieldset': { borderColor: '#475569' },
'&:hover fieldset': { borderColor: '#64748b' },
'&.Mui-focused fieldset': { borderColor: '#1976d2' }
}
}}
/>
{/* Bouton de tri */}
<IconButton
onClick={handleSortMenuOpen}
sx={{ color: '#94a3b8' }}
>
<SortIcon />
</IconButton>
{/* Menu de tri */}
<Menu
anchorEl={sortMenuAnchor}
open={Boolean(sortMenuAnchor)}
onClose={handleSortMenuClose}
>
<MenuItem onClick={() => handleSortChange('date')}>
Date {sortBy === 'date' && (sortOrder === 'desc' ? '↓' : '↑')}
</MenuItem>
<MenuItem onClick={() => handleSortChange('action')}>
Action {sortBy === 'action' && (sortOrder === 'desc' ? '↓' : '↑')}
</MenuItem>
<MenuItem onClick={() => handleSortChange('status')}>
Statut {sortBy === 'status' && (sortOrder === 'desc' ? '↓' : '↑')}
</MenuItem>
<MenuItem onClick={() => handleSortChange('execution_time')}>
Temps {sortBy === 'execution_time' && (sortOrder === 'desc' ? '↓' : '↑')}
</MenuItem>
<MenuItem onClick={() => handleSortChange('confidence')}>
Confiance {sortBy === 'confidence' && (sortOrder === 'desc' ? '↓' : '↑')}
</MenuItem>
</Menu>
</Box>
{/* Informations */}
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
{evidences.length} Evidence{evidences.length > 1 ? 's' : ''}
{evidences.length !== paginatedEvidences.length &&
` (${paginatedEvidences.length} affichée${paginatedEvidences.length > 1 ? 's' : ''})`
}
</Typography>
</Box>
{/* Liste ou grille */}
<Box flex={1} overflow="auto">
{viewMode === 'list' ? (
<List sx={{ p: 1 }}>
{paginatedEvidences.map(renderEvidenceItem)}
</List>
) : (
renderGridView()
)}
</Box>
{/* Pagination */}
{totalPages > 1 && (
<Box p={2} borderTop="1px solid #334155" display="flex" justifyContent="center">
<Pagination
count={totalPages}
page={currentPage}
onChange={handlePageChange}
color="primary"
size="small"
sx={{
'& .MuiPaginationItem-root': {
color: '#94a3b8',
'&.Mui-selected': {
backgroundColor: '#1976d2',
color: 'white'
}
}
}}
/>
</Box>
)}
</Box>
);
};
export default EvidenceList;

View File

@@ -0,0 +1,224 @@
/**
* Composant EvidenceStats - Affichage des statistiques des Evidence
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*/
import React from 'react';
import { Box, Typography, Chip, LinearProgress } from '@mui/material';
import {
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Schedule as TimeIcon,
Visibility as ConfidenceIcon,
Assessment as StatsIcon
} from '@mui/icons-material';
import { EvidenceStats, EvidenceUtils } from '../../types/evidence';
interface EvidenceStatsProps {
stats: EvidenceStats;
totalEvidences: number;
filteredCount: number;
hasFilters: boolean;
}
const EvidenceStatsComponent: React.FC<EvidenceStatsProps> = ({
stats,
totalEvidences,
filteredCount,
hasFilters
}) => {
// Calcul du taux de succès
const successRate = stats.total > 0 ? (stats.successful / stats.total) * 100 : 0;
// Couleur du taux de succès
const getSuccessRateColor = (rate: number) => {
if (rate >= 90) return '#22c55e'; // Vert
if (rate >= 70) return '#f59e0b'; // Orange
return '#ef4444'; // Rouge
};
return (
<Box className="evidence-stats">
<Box display="flex" alignItems="center" gap={1} mb={2}>
<StatsIcon sx={{ color: '#1976d2' }} />
<Typography variant="h6" sx={{ color: '#e2e8f0', fontWeight: 600 }}>
Statistiques des Evidence
</Typography>
{hasFilters && (
<Chip
label={`${filteredCount}/${totalEvidences} filtrées`}
size="small"
variant="outlined"
sx={{ color: '#94a3b8', borderColor: '#475569' }}
/>
)}
</Box>
<Box className="evidence-stats-grid">
{/* Total */}
<Box className="evidence-stat-item">
<Typography variant="h4" className="evidence-stat-value">
{stats.total}
</Typography>
<Typography variant="caption" className="evidence-stat-label">
Total
</Typography>
</Box>
{/* Réussies */}
<Box className="evidence-stat-item">
<Box display="flex" alignItems="center" justifyContent="center" gap={0.5} mb={0.5}>
<SuccessIcon sx={{ color: '#22c55e', fontSize: 16 }} />
<Typography variant="h4" sx={{ color: '#22c55e', fontWeight: 700 }}>
{stats.successful}
</Typography>
</Box>
<Typography variant="caption" className="evidence-stat-label">
Réussies
</Typography>
</Box>
{/* Échouées */}
<Box className="evidence-stat-item">
<Box display="flex" alignItems="center" justifyContent="center" gap={0.5} mb={0.5}>
<ErrorIcon sx={{ color: '#ef4444', fontSize: 16 }} />
<Typography variant="h4" sx={{ color: '#ef4444', fontWeight: 700 }}>
{stats.failed}
</Typography>
</Box>
<Typography variant="caption" className="evidence-stat-label">
Échouées
</Typography>
</Box>
{/* Taux de succès */}
<Box className="evidence-stat-item">
<Typography
variant="h4"
sx={{
color: getSuccessRateColor(successRate),
fontWeight: 700
}}
>
{Math.round(successRate)}%
</Typography>
<Typography variant="caption" className="evidence-stat-label">
Taux de succès
</Typography>
<LinearProgress
variant="determinate"
value={successRate}
sx={{
mt: 0.5,
height: 4,
borderRadius: 2,
backgroundColor: '#334155',
'& .MuiLinearProgress-bar': {
backgroundColor: getSuccessRateColor(successRate)
}
}}
/>
</Box>
{/* Temps moyen */}
<Box className="evidence-stat-item">
<Box display="flex" alignItems="center" justifyContent="center" gap={0.5} mb={0.5}>
<TimeIcon sx={{ color: '#1976d2', fontSize: 16 }} />
<Typography variant="h4" className="evidence-stat-value">
{EvidenceUtils.formatExecutionTime(stats.averageExecutionTime)}
</Typography>
</Box>
<Typography variant="caption" className="evidence-stat-label">
Temps moyen
</Typography>
</Box>
{/* Confiance moyenne */}
{stats.averageConfidence > 0 && (
<Box className="evidence-stat-item">
<Box display="flex" alignItems="center" justifyContent="center" gap={0.5} mb={0.5}>
<ConfidenceIcon sx={{ color: '#1976d2', fontSize: 16 }} />
<Typography variant="h4" className="evidence-stat-value">
{EvidenceUtils.formatConfidence(stats.averageConfidence)}
</Typography>
</Box>
<Typography variant="caption" className="evidence-stat-label">
Confiance moyenne
</Typography>
</Box>
)}
</Box>
{/* Distribution des types d'actions */}
{Object.keys(stats.actionTypeDistribution).length > 0 && (
<Box mt={2}>
<Typography variant="subtitle2" sx={{ color: '#e2e8f0', mb: 1 }}>
Répartition par type d'action
</Typography>
<Box display="flex" flexWrap="wrap" gap={1}>
{Object.entries(stats.actionTypeDistribution)
.sort(([,a], [,b]) => b - a) // Tri par nombre décroissant
.slice(0, 6) // Limite à 6 types max
.map(([actionType, count]) => (
<Chip
key={actionType}
label={`${actionType} (${count})`}
size="small"
variant="outlined"
sx={{
color: '#94a3b8',
borderColor: '#475569',
backgroundColor: '#334155'
}}
/>
))}
</Box>
</Box>
)}
{/* Timeline récente */}
{stats.timelineData.length > 0 && (
<Box mt={2}>
<Typography variant="subtitle2" sx={{ color: '#e2e8f0', mb: 1 }}>
Activité récente (7 derniers jours)
</Typography>
<Box display="flex" gap={1} alignItems="end" height={40}>
{stats.timelineData
.slice(-7) // 7 derniers jours
.map((day, index) => {
const height = Math.max(4, (day.count / Math.max(...stats.timelineData.map(d => d.count))) * 32);
const color = day.successRate >= 0.9 ? '#22c55e' : day.successRate >= 0.7 ? '#f59e0b' : '#ef4444';
return (
<Box
key={day.date}
sx={{
width: 20,
height: height,
backgroundColor: color,
borderRadius: '2px 2px 0 0',
opacity: 0.8,
cursor: 'pointer',
'&:hover': { opacity: 1 }
}}
title={`${day.date}: ${day.count} Evidence (${Math.round(day.successRate * 100)}% succès)`}
/>
);
})}
</Box>
<Box display="flex" justifyContent="space-between" mt={0.5}>
<Typography variant="caption" sx={{ color: '#64748b' }}>
{stats.timelineData.slice(-7)[0]?.date}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>
{stats.timelineData.slice(-1)[0]?.date}
</Typography>
</Box>
</Box>
)}
</Box>
);
};
export default EvidenceStatsComponent;

View File

@@ -0,0 +1,414 @@
/**
* Styles CSS pour le composant Evidence Viewer VWB
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*/
.evidence-viewer {
position: relative;
background: #1e293b;
border-radius: 12px;
border: 1px solid #334155;
color: #e2e8f0;
}
.evidence-viewer-fabs {
position: relative;
}
/* Liste des Evidence */
.evidence-list {
height: 100%;
overflow-y: auto;
padding: 8px;
}
.evidence-list-item {
margin-bottom: 8px;
padding: 12px;
background: #334155;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
}
.evidence-list-item:hover {
background: #475569;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.evidence-list-item.selected {
border-color: #1976d2;
background: #1e40af;
}
.evidence-list-item.error {
border-left: 4px solid #ef4444;
}
.evidence-list-item.success {
border-left: 4px solid #22c55e;
}
/* Vue grille */
.evidence-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
padding: 12px;
height: 100%;
overflow-y: auto;
}
.evidence-grid-item {
background: #334155;
border-radius: 8px;
padding: 12px;
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
min-height: 200px;
display: flex;
flex-direction: column;
}
.evidence-grid-item:hover {
background: #475569;
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
}
.evidence-grid-item.selected {
border-color: #1976d2;
background: #1e40af;
}
/* En-tête d'Evidence */
.evidence-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.evidence-title {
font-weight: 600;
color: #e2e8f0;
font-size: 14px;
margin: 0;
}
.evidence-status {
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.evidence-status.success {
background: #22c55e;
color: white;
}
.evidence-status.error {
background: #ef4444;
color: white;
}
/* Métadonnées d'Evidence */
.evidence-metadata {
font-size: 12px;
color: #94a3b8;
line-height: 1.4;
}
.evidence-metadata-row {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
}
.evidence-metadata-label {
font-weight: 500;
}
.evidence-metadata-value {
color: #e2e8f0;
}
/* Screenshot miniature */
.evidence-thumbnail {
width: 100%;
height: 80px;
object-fit: cover;
border-radius: 4px;
margin: 8px 0;
border: 1px solid #475569;
}
/* Détail d'Evidence */
.evidence-detail {
height: 100%;
overflow-y: auto;
padding: 16px;
background: #1e293b;
}
.evidence-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #334155;
}
.evidence-detail-title {
font-size: 18px;
font-weight: 600;
color: #e2e8f0;
margin: 0;
}
.evidence-detail-close {
color: #94a3b8;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
}
.evidence-detail-close:hover {
background: #334155;
color: #e2e8f0;
}
/* Screenshot principal */
.evidence-screenshot {
width: 100%;
max-height: 400px;
object-fit: contain;
border-radius: 8px;
border: 1px solid #334155;
margin: 12px 0;
background: #0f172a;
}
/* Annotations sur screenshot */
.evidence-screenshot-container {
position: relative;
display: inline-block;
margin: 12px 0;
}
.evidence-annotation {
position: absolute;
border: 2px solid #1976d2;
background: rgba(25, 118, 210, 0.2);
pointer-events: none;
}
.evidence-annotation.click-point {
width: 12px;
height: 12px;
border-radius: 50%;
background: #ef4444;
border: 2px solid white;
transform: translate(-50%, -50%);
}
.evidence-annotation.bbox {
background: rgba(25, 118, 210, 0.1);
border: 2px dashed #1976d2;
}
/* Panneau de métadonnées */
.evidence-metadata-panel {
background: #0f172a;
border-radius: 8px;
padding: 12px;
margin: 12px 0;
}
.evidence-metadata-panel h4 {
color: #e2e8f0;
margin: 0 0 8px 0;
font-size: 14px;
font-weight: 600;
}
.evidence-metadata-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
font-size: 12px;
}
.evidence-metadata-item {
display: flex;
flex-direction: column;
}
.evidence-metadata-item-label {
color: #94a3b8;
font-weight: 500;
margin-bottom: 2px;
}
.evidence-metadata-item-value {
color: #e2e8f0;
font-family: monospace;
}
/* Message d'erreur */
.evidence-error {
background: #7f1d1d;
border: 1px solid #ef4444;
border-radius: 8px;
padding: 12px;
margin: 12px 0;
}
.evidence-error-title {
color: #fecaca;
font-weight: 600;
margin-bottom: 4px;
}
.evidence-error-message {
color: #fee2e2;
font-size: 14px;
line-height: 1.4;
}
/* Statistiques */
.evidence-stats {
padding: 12px 16px;
background: #0f172a;
border-bottom: 1px solid #334155;
}
.evidence-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 16px;
}
.evidence-stat-item {
text-align: center;
}
.evidence-stat-value {
font-size: 20px;
font-weight: 700;
color: #1976d2;
margin-bottom: 4px;
}
.evidence-stat-label {
font-size: 12px;
color: #94a3b8;
text-transform: uppercase;
font-weight: 500;
}
/* Filtres */
.evidence-filters {
padding: 12px 16px;
background: #0f172a;
border-bottom: 1px solid #334155;
}
.evidence-filters-row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 8px;
}
.evidence-filters-row:last-child {
margin-bottom: 0;
}
/* Responsive */
@media (max-width: 768px) {
.evidence-grid {
grid-template-columns: 1fr;
gap: 8px;
padding: 8px;
}
.evidence-metadata-grid {
grid-template-columns: 1fr;
}
.evidence-stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.evidence-filters-row {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.evidence-viewer-fabs .MuiFab-root {
position: fixed !important;
bottom: 16px !important;
}
.evidence-viewer-fabs .MuiFab-root:nth-child(1) {
right: 16px !important;
}
.evidence-viewer-fabs .MuiFab-root:nth-child(2) {
right: 80px !important;
}
.evidence-viewer-fabs .MuiFab-root:nth-child(3) {
right: 144px !important;
}
}
/* Animations */
@keyframes evidenceAppear {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.evidence-list-item,
.evidence-grid-item {
animation: evidenceAppear 0.3s ease-out;
}
/* Scrollbar personnalisée */
.evidence-list::-webkit-scrollbar,
.evidence-detail::-webkit-scrollbar {
width: 6px;
}
.evidence-list::-webkit-scrollbar-track,
.evidence-detail::-webkit-scrollbar-track {
background: #1e293b;
}
.evidence-list::-webkit-scrollbar-thumb,
.evidence-detail::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 3px;
}
.evidence-list::-webkit-scrollbar-thumb:hover,
.evidence-detail::-webkit-scrollbar-thumb:hover {
background: #64748b;
}

View File

@@ -0,0 +1,461 @@
/**
* Panneau Evidence d'Exécution - Affichage temps réel des Evidence VWB
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Ce composant affiche les Evidence générées pendant l'exécution des workflows VWB
* avec mise à jour en temps réel et navigation dans l'historique.
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
Box,
Typography,
Paper,
Tabs,
Tab,
Badge,
IconButton,
Tooltip,
Chip,
Divider,
List,
ListItem,
ListItemText,
ListItemIcon,
Collapse,
Alert,
} from '@mui/material';
import {
Visibility as EvidenceIcon,
Screenshot as ScreenshotIcon,
Timeline as TimelineIcon,
FilterList as FilterIcon,
Refresh as RefreshIcon,
ExpandMore as ExpandIcon,
ExpandLess as CollapseIcon,
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Info as InfoIcon,
} from '@mui/icons-material';
// Import des composants Evidence existants
import EvidenceList from './EvidenceList';
import EvidenceDetail from './EvidenceDetail';
import ScreenshotViewer from './ScreenshotViewer';
import EvidenceFilters from './EvidenceFilters';
// Import des types
import { Evidence, Step, StepExecutionState } from '../../types';
import { VWBExecutionResult } from '../../services/vwbExecutionService';
// Import du hook d'exécution Evidence
import { useExecutionEvidence } from '../../hooks/useExecutionEvidence';
interface ExecutionEvidencePanelProps {
/** Étape actuellement en cours d'exécution */
currentStep?: Step;
/** Résultats d'exécution VWB */
executionResults: VWBExecutionResult[];
/** Callback lors de la sélection d'une Evidence */
onEvidenceSelect?: (evidence: Evidence) => void;
/** Callback lors du changement d'étape */
onStepChange?: (stepId: string) => void;
/** Mode d'affichage compact */
compact?: boolean;
/** Hauteur maximale du panneau */
maxHeight?: number;
}
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
/**
* Composant TabPanel pour les onglets
*/
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => (
<div hidden={value !== index} style={{ height: '100%' }}>
{value === index && children}
</div>
);
/**
* Panneau principal pour l'affichage des Evidence d'exécution
*/
const ExecutionEvidencePanel: React.FC<ExecutionEvidencePanelProps> = ({
currentStep,
executionResults,
onEvidenceSelect,
onStepChange,
compact = false,
maxHeight = 600,
}) => {
// État local
const [activeTab, setActiveTab] = useState(0);
const [selectedEvidence, setSelectedEvidence] = useState<Evidence | null>(null);
const [expandedSteps, setExpandedSteps] = useState<Set<string>>(new Set());
const [showFilters, setShowFilters] = useState(false);
// Hook pour la gestion des Evidence d'exécution
const {
allEvidence,
evidenceByStep,
currentStepEvidence,
addEvidence,
clearEvidence,
getEvidenceStats,
} = useExecutionEvidence();
// Synchroniser les Evidence avec les résultats d'exécution
useEffect(() => {
executionResults.forEach(result => {
if (result.evidence && result.evidence.length > 0) {
result.evidence.forEach(evidence => {
addEvidence(result.stepId, evidence);
});
}
});
}, [executionResults, addEvidence]);
// Statistiques des Evidence
const evidenceStats = useMemo(() => getEvidenceStats(), [getEvidenceStats]);
// Gérer la sélection d'Evidence
const handleEvidenceSelect = useCallback((evidence: Evidence) => {
setSelectedEvidence(evidence);
onEvidenceSelect?.(evidence);
}, [onEvidenceSelect]);
// Gérer l'expansion des étapes
const toggleStepExpansion = useCallback((stepId: string) => {
setExpandedSteps(prev => {
const newSet = new Set(prev);
if (newSet.has(stepId)) {
newSet.delete(stepId);
} else {
newSet.add(stepId);
}
return newSet;
});
}, []);
// Gérer le changement d'onglet
const handleTabChange = useCallback((_: React.SyntheticEvent, newValue: number) => {
setActiveTab(newValue);
}, []);
// Rendu de la liste des étapes avec Evidence
const renderStepsList = useCallback(() => {
const stepsWithEvidence = executionResults.filter(result =>
result.evidence && result.evidence.length > 0
);
if (stepsWithEvidence.length === 0) {
return (
<Alert severity="info" sx={{ m: 2 }}>
Aucune Evidence générée pour le moment.
Les Evidence apparaîtront automatiquement lors de l'exécution.
</Alert>
);
}
return (
<List sx={{ width: '100%' }}>
{stepsWithEvidence.map((result) => {
const isExpanded = expandedSteps.has(result.stepId);
const stepEvidence = result.evidence || [];
return (
<React.Fragment key={result.stepId}>
<ListItem
component="div"
onClick={() => toggleStepExpansion(result.stepId)}
sx={{
cursor: "pointer",
borderLeft: `4px solid ${result.success ? '#4caf50' : '#f44336'}`,
mb: 1,
borderRadius: 1,
backgroundColor: 'background.paper',
}}
>
<ListItemIcon>
{result.success ? (
<SuccessIcon color="success" />
) : (
<ErrorIcon color="error" />
)}
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2">
Étape {result.stepId}
</Typography>
<Chip
label={result.actionId}
size="small"
variant="outlined"
sx={{ fontSize: '0.7rem' }}
/>
<Badge
badgeContent={stepEvidence.length}
color="primary"
sx={{ ml: 'auto' }}
>
<EvidenceIcon fontSize="small" />
</Badge>
</Box>
}
secondary={`Durée: ${result.duration}ms`}
/>
<IconButton size="small">
{isExpanded ? <CollapseIcon /> : <ExpandIcon />}
</IconButton>
</ListItem>
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<Box sx={{ pl: 4, pr: 2, pb: 2 }}>
<EvidenceList
evidences={stepEvidence}
selectedId={selectedEvidence?.id}
onSelect={handleEvidenceSelect}
filters={{
actionTypes: [],
status: 'all',
dateRange: {},
searchText: '',
confidenceRange: { min: 0, max: 1 },
executionTimeRange: { min: 0, max: 10000 }
}}
onFiltersChange={() => {}}
sortBy="timestamp"
sortOrder="desc"
onSortChange={() => {}}
viewMode="list"
/>
</Box>
</Collapse>
</React.Fragment>
);
})}
</List>
);
}, [executionResults, expandedSteps, selectedEvidence, handleEvidenceSelect, toggleStepExpansion]);
// Rendu de la timeline des Evidence
const renderTimeline = useCallback(() => {
const sortedEvidence = [...allEvidence].sort((a, b) =>
new Date(b.captured_at).getTime() - new Date(a.captured_at).getTime()
);
return (
<Box sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Timeline des Evidence ({sortedEvidence.length})
</Typography>
<EvidenceList
evidences={sortedEvidence}
selectedId={selectedEvidence?.id}
onSelect={handleEvidenceSelect}
filters={{
actionTypes: [],
status: 'all',
dateRange: {},
searchText: '',
confidenceRange: { min: 0, max: 1 },
executionTimeRange: { min: 0, max: 10000 }
}}
onFiltersChange={() => {}}
sortBy="timestamp"
sortOrder="desc"
onSortChange={() => {}}
viewMode="list"
/>
</Box>
);
}, [allEvidence, selectedEvidence, handleEvidenceSelect, compact]);
// Rendu de l'étape actuelle
const renderCurrentStep = useCallback(() => {
if (!currentStep) {
return (
<Alert severity="info" sx={{ m: 2 }}>
Aucune étape en cours d'exécution
</Alert>
);
}
const stepEvidence = currentStepEvidence;
return (
<Box sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Étape Actuelle: {currentStep.name}
</Typography>
<Chip
label={currentStep.type}
color="primary"
size="small"
sx={{ mb: 2 }}
/>
{stepEvidence.length > 0 ? (
<EvidenceList
evidences={stepEvidence}
selectedId={selectedEvidence?.id}
onSelect={handleEvidenceSelect}
filters={{
actionTypes: [],
status: 'all',
dateRange: {},
searchText: '',
confidenceRange: { min: 0, max: 1 },
executionTimeRange: { min: 0, max: 10000 }
}}
onFiltersChange={() => {}}
sortBy="timestamp"
sortOrder="desc"
onSortChange={() => {}}
viewMode="list"
/>
) : (
<Typography variant="body2" color="text.secondary">
Aucune Evidence générée pour cette étape
</Typography>
)}
</Box>
);
}, [currentStep, currentStepEvidence, selectedEvidence, handleEvidenceSelect, compact]);
return (
<Paper
elevation={2}
sx={{
height: compact ? 'auto' : maxHeight,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* En-tête avec statistiques */}
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="h6">
Evidence d'Exécution
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip
label={`${evidenceStats.total} Evidence`}
size="small"
color="primary"
/>
<Chip
label={`${evidenceStats.screenshots} Screenshots`}
size="small"
variant="outlined"
/>
<Tooltip title="Filtres">
<IconButton
size="small"
onClick={() => setShowFilters(!showFilters)}
color={showFilters ? 'primary' : 'default'}
>
<FilterIcon />
</IconButton>
</Tooltip>
<Tooltip title="Actualiser">
<IconButton size="small" onClick={() => window.location.reload()}>
<RefreshIcon />
</IconButton>
</Tooltip>
</Box>
</Box>
{/* Filtres */}
<Collapse in={showFilters}>
<Box sx={{ mt: 2 }}>
<EvidenceFilters
evidences={allEvidence}
filters={{
actionTypes: [],
status: 'all',
dateRange: {},
searchText: '',
confidenceRange: { min: 0, max: 1 },
executionTimeRange: { min: 0, max: 10000 }
}}
onFiltersChange={(filtered) => {
// Logique de filtrage à implémenter
console.log('Evidence filtrées:', filtered);
}}
onClearFilters={() => {
console.log('Filtres effacés');
}}
/>
</Box>
</Collapse>
</Box>
{/* Onglets */}
<Tabs
value={activeTab}
onChange={handleTabChange}
variant="fullWidth"
sx={{ borderBottom: 1, borderColor: 'divider' }}
>
<Tab
label={
<Badge badgeContent={evidenceStats.byCurrentStep} color="primary">
Étape Actuelle
</Badge>
}
icon={<InfoIcon />}
/>
<Tab
label={
<Badge badgeContent={evidenceStats.bySteps} color="primary">
Par Étapes
</Badge>
}
icon={<EvidenceIcon />}
/>
<Tab
label={
<Badge badgeContent={evidenceStats.total} color="primary">
Timeline
</Badge>
}
icon={<TimelineIcon />}
/>
</Tabs>
{/* Contenu des onglets */}
<Box sx={{ flex: 1, overflow: 'auto' }}>
<TabPanel value={activeTab} index={0}>
{renderCurrentStep()}
</TabPanel>
<TabPanel value={activeTab} index={1}>
{renderStepsList()}
</TabPanel>
<TabPanel value={activeTab} index={2}>
{renderTimeline()}
</TabPanel>
</Box>
{/* Panneau de détail Evidence sélectionnée */}
{selectedEvidence && (
<Box sx={{ borderTop: 1, borderColor: 'divider' }}>
<EvidenceDetail
evidence={selectedEvidence}
onClose={() => setSelectedEvidence(null)}
/>
</Box>
)}
</Paper>
);
};
export default ExecutionEvidencePanel;

View File

@@ -0,0 +1,282 @@
/**
* Composant ScreenshotViewer - Visualiseur de screenshots avec annotations
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*/
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Box, Paper } from '@mui/material';
import { ScreenshotViewerProps, AnnotationData } from '../../types/evidence';
const ScreenshotViewer: React.FC<ScreenshotViewerProps> = ({
screenshot,
bbox,
clickPoint,
annotations = [],
zoom = 1,
onZoomChange,
maxWidth = 800,
maxHeight = 600
}) => {
// Références
const containerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
// État local
const [imageLoaded, setImageLoaded] = useState(false);
const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 });
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
// Chargement de l'image
const handleImageLoad = useCallback(() => {
if (imageRef.current) {
setImageDimensions({
width: imageRef.current.naturalWidth,
height: imageRef.current.naturalHeight
});
setImageLoaded(true);
}
}, []);
// Gestion du zoom avec la molette
const handleWheel = useCallback((event: React.WheelEvent) => {
if (!onZoomChange) return;
event.preventDefault();
const delta = event.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.max(0.1, Math.min(5, zoom * delta));
onZoomChange(newZoom);
}, [zoom, onZoomChange]);
// Début du pan
const handleMouseDown = useCallback((event: React.MouseEvent) => {
if (zoom <= 1) return; // Pas de pan si pas de zoom
setIsPanning(true);
setPanStart({
x: event.clientX - panOffset.x,
y: event.clientY - panOffset.y
});
}, [zoom, panOffset]);
// Pan en cours
const handleMouseMove = useCallback((event: React.MouseEvent) => {
if (!isPanning) return;
setPanOffset({
x: event.clientX - panStart.x,
y: event.clientY - panStart.y
});
}, [isPanning, panStart]);
// Fin du pan
const handleMouseUp = useCallback(() => {
setIsPanning(false);
}, []);
// Reset du pan quand le zoom change
useEffect(() => {
if (zoom <= 1) {
setPanOffset({ x: 0, y: 0 });
}
}, [zoom]);
// Calcul des dimensions d'affichage
const getDisplayDimensions = () => {
if (!imageLoaded) return { width: maxWidth, height: maxHeight };
const aspectRatio = imageDimensions.width / imageDimensions.height;
let displayWidth = imageDimensions.width;
let displayHeight = imageDimensions.height;
// Ajustement aux dimensions max
if (displayWidth > maxWidth) {
displayWidth = maxWidth;
displayHeight = displayWidth / aspectRatio;
}
if (displayHeight > maxHeight) {
displayHeight = maxHeight;
displayWidth = displayHeight * aspectRatio;
}
return {
width: displayWidth * zoom,
height: displayHeight * zoom
};
};
// Conversion des coordonnées d'annotation
const convertCoordinates = (coord: { x: number; y: number; width?: number; height?: number }) => {
if (!imageLoaded) return coord;
const displayDims = getDisplayDimensions();
const scaleX = displayDims.width / imageDimensions.width;
const scaleY = displayDims.height / imageDimensions.height;
return {
x: coord.x * scaleX,
y: coord.y * scaleY,
width: coord.width ? coord.width * scaleX : undefined,
height: coord.height ? coord.height * scaleY : undefined
};
};
// Rendu des annotations
const renderAnnotations = () => {
if (!imageLoaded) return null;
const allAnnotations: AnnotationData[] = [];
// Ajout de la bbox si présente
if (bbox) {
allAnnotations.push({
id: 'bbox',
type: 'bbox',
position: { x: bbox.x, y: bbox.y },
coordinates: bbox,
label: 'Zone détectée',
color: '#1976d2',
opacity: 0.3,
data: { type: 'bbox', bbox }
});
}
// Ajout du point de clic si présent
if (clickPoint) {
allAnnotations.push({
id: 'click',
type: 'click',
position: { x: clickPoint.x, y: clickPoint.y },
coordinates: clickPoint,
label: 'Point de clic',
color: '#ef4444',
opacity: 1,
data: { type: 'click', clickPoint }
});
}
// Ajout des annotations personnalisées
allAnnotations.push(...annotations);
return allAnnotations.map(annotation => {
const coords = annotation.coordinates ? convertCoordinates(annotation.coordinates) : { x: annotation.position.x, y: annotation.position.y };
if (annotation.type === 'click') {
return (
<div
key={annotation.id}
className="evidence-annotation click-point"
style={{
position: 'absolute',
left: coords.x - 6,
top: coords.y - 6,
width: 12,
height: 12,
borderRadius: '50%',
backgroundColor: annotation.color || '#ef4444',
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
zIndex: 10
}}
title={annotation.label}
/>
);
}
if (annotation.type === 'bbox' && coords.width && coords.height) {
return (
<div
key={annotation.id}
className="evidence-annotation bbox"
style={{
position: 'absolute',
left: coords.x,
top: coords.y,
width: coords.width,
height: coords.height,
border: `2px dashed ${annotation.color || '#1976d2'}`,
backgroundColor: `${annotation.color || '#1976d2'}${Math.round((annotation.opacity || 0.3) * 255).toString(16).padStart(2, '0')}`,
pointerEvents: 'none',
zIndex: 5
}}
title={annotation.label}
/>
);
}
return null;
});
};
const displayDims = getDisplayDimensions();
return (
<Paper
ref={containerRef}
sx={{
position: 'relative',
display: 'inline-block',
maxWidth: '100%',
maxHeight: maxHeight,
overflow: zoom > 1 ? 'auto' : 'hidden',
cursor: zoom > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',
backgroundColor: '#0f172a',
border: '1px solid #334155'
}}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div
className="evidence-screenshot-container"
style={{
position: 'relative',
display: 'inline-block',
transform: `translate(${panOffset.x}px, ${panOffset.y}px)`,
transition: isPanning ? 'none' : 'transform 0.1s ease'
}}
>
<img
ref={imageRef}
src={`data:image/png;base64,${screenshot}`}
alt="Screenshot Evidence"
className="evidence-screenshot"
style={{
width: displayDims.width,
height: displayDims.height,
display: 'block',
userSelect: 'none',
pointerEvents: 'none'
}}
onLoad={handleImageLoad}
onError={() => setImageLoaded(false)}
/>
{/* Annotations */}
{renderAnnotations()}
</div>
{/* Indicateur de chargement */}
{!imageLoaded && (
<Box
position="absolute"
top="50%"
left="50%"
sx={{
transform: 'translate(-50%, -50%)',
color: '#94a3b8',
fontSize: '14px'
}}
>
Chargement de l'image...
</Box>
)}
</Paper>
);
};
export default ScreenshotViewer;

View File

@@ -0,0 +1,322 @@
/**
* Composant Evidence Viewer VWB - Visualisation des preuves d'exécution
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*/
import React, { useState, useCallback } from 'react';
import {
Box,
Paper,
Typography,
Divider,
Alert,
CircularProgress,
Fab,
Tooltip,
useTheme,
useMediaQuery
} from '@mui/material';
import {
Refresh as RefreshIcon,
GetApp as ExportIcon,
FilterList as FilterIcon,
ViewList as ListIcon,
ViewModule as GridIcon
} from '@mui/icons-material';
import { EvidenceViewerProps, VWBEvidence } from '../../types/evidence';
import { useEvidenceViewer } from '../../hooks/useEvidenceViewer';
import EvidenceList from './EvidenceList';
import EvidenceDetail from './EvidenceDetail';
import EvidenceFilters from './EvidenceFilters';
import EvidenceStats from './EvidenceStats';
import './EvidenceViewer.css';
const EvidenceViewer: React.FC<EvidenceViewerProps> = ({
evidences: externalEvidences,
selectedEvidenceId: externalSelectedId,
onEvidenceSelect,
onExport,
showFilters = true,
maxHeight = 600,
className = ''
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
// État local
const [viewMode, setViewMode] = useState<'list' | 'grid'>('list');
const [showFiltersPanel, setShowFiltersPanel] = useState(false);
const [showStatsPanel, setShowStatsPanel] = useState(true);
// Hook Evidence Viewer (utilisé seulement si pas d'Evidence externes)
const {
evidences: internalEvidences,
filteredEvidences,
selectedEvidence,
stats,
loading,
error,
filters,
sortBy,
sortOrder,
setSelectedEvidenceId: setInternalSelectedId,
setFilters,
setSorting,
refreshEvidences,
clearFilters,
exportEvidences,
hasFilters,
isServiceAvailable
} = useEvidenceViewer({
autoRefresh: !externalEvidences, // Auto-refresh seulement si pas d'Evidence externes
refreshInterval: 30000
});
// Utilisation des Evidence externes ou internes
const evidences = externalEvidences || internalEvidences;
const displayedEvidences = externalEvidences || filteredEvidences;
const currentSelectedId = externalSelectedId || selectedEvidence?.id;
// Gestion de la sélection d'Evidence
const handleEvidenceSelect = useCallback((evidence: VWBEvidence) => {
const evidenceId = evidence.id;
if (onEvidenceSelect) {
onEvidenceSelect(evidenceId);
} else {
setInternalSelectedId(evidenceId);
}
}, [onEvidenceSelect, setInternalSelectedId]);
// Gestion de l'export
const handleExport = useCallback(async (format: 'json' | 'html' | 'pdf') => {
if (onExport) {
onExport(displayedEvidences);
} else {
await exportEvidences(format);
}
}, [onExport, displayedEvidences, exportEvidences]);
// Gestion du refresh
const handleRefresh = useCallback(async () => {
if (!externalEvidences) {
await refreshEvidences();
}
}, [externalEvidences, refreshEvidences]);
// Evidence sélectionnée
const selectedEvidenceData = currentSelectedId
? evidences.find(e => e.id === currentSelectedId)
: null;
// Rendu du contenu principal
const renderContent = () => {
if (loading && !externalEvidences) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight={200}>
<CircularProgress size={40} />
<Typography variant="body2" sx={{ ml: 2 }}>
Chargement des Evidence...
</Typography>
</Box>
);
}
if (error && !externalEvidences) {
return (
<Alert severity="error" sx={{ m: 2 }}>
{error}
{!isServiceAvailable && (
<Typography variant="caption" display="block" sx={{ mt: 1 }}>
Le service Evidence n'est pas disponible. Vérifiez que le backend VWB est démarré.
</Typography>
)}
</Alert>
);
}
if (displayedEvidences.length === 0) {
return (
<Box textAlign="center" py={4}>
<Typography variant="h6" color="text.secondary" gutterBottom>
Aucune Evidence disponible
</Typography>
<Typography variant="body2" color="text.secondary">
{hasFilters
? 'Aucune Evidence ne correspond aux filtres appliqués.'
: 'Aucune Evidence d\'exécution n\'a été trouvée.'}
</Typography>
</Box>
);
}
return (
<Box display="flex" height="100%">
{/* Liste des Evidence */}
<Box
flex={selectedEvidenceData ? (isMobile ? 0 : 1) : 1}
display={selectedEvidenceData && isMobile ? 'none' : 'block'}
>
<EvidenceList
evidences={displayedEvidences}
selectedId={currentSelectedId}
onSelect={handleEvidenceSelect}
filters={externalEvidences ? undefined : filters}
onFiltersChange={externalEvidences ? undefined : setFilters}
sortBy={sortBy}
sortOrder={sortOrder}
onSortChange={setSorting}
viewMode={viewMode}
showFilters={showFilters && showFiltersPanel}
/>
</Box>
{/* Détail de l'Evidence sélectionnée */}
{selectedEvidenceData && (
<>
<Divider orientation="vertical" flexItem />
<Box
flex={isMobile ? 1 : 1}
display={selectedEvidenceData ? 'block' : 'none'}
>
<EvidenceDetail
evidence={selectedEvidenceData}
onClose={isMobile ? () => setInternalSelectedId('') : undefined}
showMetadata={true}
/>
</Box>
</>
)}
</Box>
);
};
return (
<Paper
className={`evidence-viewer ${className}`}
sx={{
height: maxHeight,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}
role="region"
aria-label="Visualiseur d'Evidence VWB"
>
{/* En-tête avec statistiques */}
{showStatsPanel && !externalEvidences && (
<>
<EvidenceStats
stats={stats}
totalEvidences={evidences.length}
filteredCount={displayedEvidences.length}
hasFilters={hasFilters}
/>
<Divider />
</>
)}
{/* Panneau de filtres */}
{showFilters && showFiltersPanel && !externalEvidences && (
<>
<EvidenceFilters
filters={filters}
onFiltersChange={setFilters}
onClearFilters={clearFilters}
evidences={evidences}
/>
<Divider />
</>
)}
{/* Contenu principal */}
<Box flex={1} overflow="hidden">
{renderContent()}
</Box>
{/* Boutons d'action flottants */}
<Box className="evidence-viewer-fabs">
{/* Bouton Refresh */}
{!externalEvidences && (
<Tooltip title="Actualiser les Evidence">
<Fab
size="small"
color="primary"
onClick={handleRefresh}
disabled={loading}
aria-label="Actualiser les Evidence"
sx={{
position: 'absolute',
bottom: 16,
right: 16,
zIndex: 1000
}}
>
<RefreshIcon />
</Fab>
</Tooltip>
)}
{/* Bouton Export */}
<Tooltip title="Exporter les Evidence">
<Fab
size="small"
color="secondary"
onClick={() => handleExport('html')}
aria-label="Exporter les Evidence"
sx={{
position: 'absolute',
bottom: 16,
right: 80,
zIndex: 1000
}}
>
<ExportIcon />
</Fab>
</Tooltip>
{/* Bouton Filtres */}
{showFilters && !externalEvidences && (
<Tooltip title={showFiltersPanel ? "Masquer les filtres" : "Afficher les filtres"}>
<Fab
size="small"
color={showFiltersPanel ? "primary" : "default"}
onClick={() => setShowFiltersPanel(!showFiltersPanel)}
aria-label={showFiltersPanel ? "Masquer les filtres" : "Afficher les filtres"}
aria-pressed={showFiltersPanel}
sx={{
position: 'absolute',
bottom: 16,
right: 144,
zIndex: 1000
}}
>
<FilterIcon />
</Fab>
</Tooltip>
)}
{/* Bouton Mode d'affichage */}
<Tooltip title={viewMode === 'list' ? "Vue grille" : "Vue liste"}>
<Fab
size="small"
color="default"
onClick={() => setViewMode(viewMode === 'list' ? 'grid' : 'list')}
aria-label={viewMode === 'list' ? "Basculer en vue grille" : "Basculer en vue liste"}
sx={{
position: 'absolute',
bottom: 16,
right: 208,
zIndex: 1000
}}
>
{viewMode === 'list' ? <GridIcon /> : <ListIcon />}
</Fab>
</Tooltip>
</Box>
</Paper>
);
};
export default EvidenceViewer;

View File

@@ -0,0 +1,243 @@
/**
* Styles CSS pour les Contrôles d'Exécution VWB
* Auteur : Dom, Alice, Kiro - 11 janvier 2026
*/
.execution-controls {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
background: #fafafa;
border-radius: 8px;
}
.execution-controls-main {
display: flex;
flex-direction: column;
gap: 12px;
}
.execution-controls-buttons {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.execution-controls-advanced {
background: #f5f5f5;
padding: 16px;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.execution-controls-settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
margin-top: 12px;
}
.execution-controls-slider-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.execution-controls-switches {
display: flex;
gap: 16px;
flex-wrap: wrap;
margin-top: 12px;
}
.execution-controls-status {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: #e3f2fd;
border-radius: 8px;
border-left: 4px solid #2196f3;
}
.execution-controls-status.paused {
background: #fff3e0;
border-left-color: #ff9800;
}
.execution-controls-status.error {
background: #ffebee;
border-left-color: #f44336;
}
.execution-controls-stats {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.execution-controls-breakpoint-indicator {
position: relative;
}
.execution-controls-breakpoint-indicator::before {
content: '';
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background: #ff5722;
border-radius: 50%;
border: 2px solid white;
}
.execution-controls-save-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
min-width: 320px;
z-index: 1300;
}
.execution-controls-save-dialog-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1299;
}
/* Animations pour les états d'exécution */
@keyframes execution-pulse {
0% { opacity: 1; }
50% { opacity: 0.7; }
100% { opacity: 1; }
}
.execution-controls-running {
animation: execution-pulse 2s infinite;
}
.execution-controls-paused {
animation: execution-pulse 1s infinite;
}
/* Responsive design */
@media (max-width: 768px) {
.execution-controls-buttons {
flex-direction: column;
align-items: stretch;
}
.execution-controls-settings-grid {
grid-template-columns: 1fr;
}
.execution-controls-switches {
flex-direction: column;
gap: 8px;
}
.execution-controls-status {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
/* Mode sombre */
@media (prefers-color-scheme: dark) {
.execution-controls {
background: #1e1e1e;
color: #e0e0e0;
}
.execution-controls-advanced {
background: #2d2d2d;
border-color: #404040;
}
.execution-controls-save-dialog {
background: #2d2d2d;
color: #e0e0e0;
}
}
/* Indicateurs de performance */
.execution-controls-performance-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
}
.execution-controls-performance-indicator.fast {
background: #e8f5e8;
color: #2e7d32;
}
.execution-controls-performance-indicator.normal {
background: #fff3e0;
color: #f57c00;
}
.execution-controls-performance-indicator.slow {
background: #ffebee;
color: #d32f2f;
}
/* Transitions fluides */
.execution-controls * {
transition: all 0.2s ease-in-out;
}
.execution-controls button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12);
}
.execution-controls button:active {
transform: translateY(0);
}
/* Indicateurs d'état spéciaux */
.execution-controls-step-indicator {
position: relative;
display: inline-block;
}
.execution-controls-step-indicator.current::after {
content: '';
position: absolute;
top: 50%;
right: -8px;
transform: translateY(-50%);
width: 0;
height: 0;
border-left: 6px solid #2196f3;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
}
.execution-controls-step-indicator.breakpoint::before {
content: '●';
position: absolute;
top: -4px;
right: -4px;
color: #ff5722;
font-size: 12px;
line-height: 1;
}

View File

@@ -0,0 +1,746 @@
/**
* Contrôles d'Exécution VWB - Interface de contrôle avancée pour l'exécution des workflows
* Auteur : Dom, Alice, Kiro - 11 janvier 2026
*
* Ce composant fournit une interface complète de contrôle d'exécution avec :
* - Contrôles play/pause/stop
* - Mode pas-à-pas pour le débogage
* - Sauvegarde/restauration d'état d'exécution
* - Gestion avancée des breakpoints
*/
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import {
Box,
Button,
ButtonGroup,
Card,
CardContent,
Typography,
Divider,
Switch,
FormControlLabel,
Slider,
Tooltip,
IconButton,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Chip,
Alert,
Collapse,
TextField,
InputAdornment,
} from '@mui/material';
import {
PlayArrow as PlayIcon,
Pause as PauseIcon,
Stop as StopIcon,
SkipNext as StepIcon,
Replay as ResetIcon,
Save as SaveIcon,
Restore as RestoreIcon,
Settings as SettingsIcon,
Speed as SpeedIcon,
BugReport as DebugIcon,
Timeline as TimelineIcon,
Bookmark as BookmarkIcon,
BookmarkBorder as BookmarkBorderIcon,
MoreVert as MoreIcon,
Schedule as ScheduleIcon,
Memory as MemoryIcon,
} from '@mui/icons-material';
// Import des hooks et types
import { useVWBExecution, VWBExecutionState } from '../../hooks/useVWBExecution';
import { Step, Workflow, Variable } from '../../types';
export interface ExecutionControlsProps {
workflow: Workflow;
variables: Variable[];
executionState: VWBExecutionState;
onStepStateChange?: (stepId: string, state: any) => void;
onExecutionComplete?: (success: boolean, summary: any) => void;
onEvidenceGenerated?: (stepId: string, evidence: any[]) => void;
debugMode?: boolean;
onDebugModeChange?: (enabled: boolean) => void;
className?: string;
}
export interface ExecutionSettings {
autoValidate: boolean;
generateEvidence: boolean;
retryAttempts: number;
timeout: number;
pauseOnError: boolean;
skipNonVWBSteps: boolean;
stepDelay: number;
enableBreakpoints: boolean;
}
export interface ExecutionSaveState {
id: string;
name: string;
timestamp: Date;
workflowId: string;
currentStepIndex: number;
variables: Variable[];
settings: ExecutionSettings;
results: any[];
evidence: any[];
}
/**
* Composant principal des contrôles d'exécution
*/
const ExecutionControls: React.FC<ExecutionControlsProps> = ({
workflow,
variables,
executionState,
onStepStateChange,
onExecutionComplete,
onEvidenceGenerated,
debugMode = false,
onDebugModeChange,
className,
}) => {
// États locaux
const [settings, setSettings] = useState<ExecutionSettings>({
autoValidate: true,
generateEvidence: true,
retryAttempts: 3,
timeout: 30000,
pauseOnError: false,
skipNonVWBSteps: false,
stepDelay: 0,
enableBreakpoints: false,
});
const [stepByStepMode, setStepByStepMode] = useState(false);
const [breakpoints, setBreakpoints] = useState<Set<string>>(new Set());
const [savedStates, setSavedStates] = useState<ExecutionSaveState[]>([]);
const [settingsMenuAnchor, setSettingsMenuAnchor] = useState<null | HTMLElement>(null);
const [saveStateDialogOpen, setSaveStateDialogOpen] = useState(false);
const [saveStateName, setSaveStateName] = useState('');
const [showAdvancedControls, setShowAdvancedControls] = useState(false);
// Hook d'exécution VWB avec paramètres dynamiques
const {
isRunning,
isPaused,
canStart,
canPause,
canResume,
canStop,
startExecution,
pauseExecution,
resumeExecution,
stopExecution,
resetExecution,
getExecutionSummary,
isVWBStep,
} = useVWBExecution(
workflow,
variables,
{
onStepStart: (step, index) => {
// Vérifier les breakpoints
if (settings.enableBreakpoints && breakpoints.has(step.id)) {
pauseExecution();
}
onStepStateChange?.(step.id, 'running');
},
onStepComplete: (step, result) => {
onStepStateChange?.(step.id, result.success ? 'success' : 'error');
if (result.evidence) {
onEvidenceGenerated?.(step.id, result.evidence);
}
// Mode pas-à-pas : pause après chaque étape
if (stepByStepMode && isRunning) {
setTimeout(() => pauseExecution(), 100);
}
},
onStepError: (step, error) => {
onStepStateChange?.(step.id, 'error');
},
onExecutionComplete: (success, summary) => {
onExecutionComplete?.(success, summary);
},
onEvidenceGenerated: onEvidenceGenerated,
},
{
autoValidate: settings.autoValidate,
generateEvidence: settings.generateEvidence,
retryAttempts: settings.retryAttempts,
timeout: settings.timeout,
pauseOnError: settings.pauseOnError,
skipNonVWBSteps: settings.skipNonVWBSteps,
}
);
// Charger les états sauvegardés depuis localStorage
useEffect(() => {
const saved = localStorage.getItem('vwb_execution_saved_states');
if (saved) {
try {
const states = JSON.parse(saved);
setSavedStates(states.map((state: any) => ({
...state,
timestamp: new Date(state.timestamp),
})));
} catch (error) {
console.error('Erreur lors du chargement des états sauvegardés:', error);
}
}
}, []);
// Sauvegarder les états dans localStorage
const saveSavedStates = useCallback((states: ExecutionSaveState[]) => {
try {
localStorage.setItem('vwb_execution_saved_states', JSON.stringify(states));
setSavedStates(states);
} catch (error) {
console.error('Erreur lors de la sauvegarde des états:', error);
}
}, []);
// Gestionnaire d'exécution avec délai
const handleStartExecution = useCallback(async () => {
if (settings.stepDelay > 0) {
// Implémentation du délai entre étapes (simulation)
console.log(`Démarrage avec délai de ${settings.stepDelay}ms entre les étapes`);
}
await startExecution();
}, [startExecution, settings.stepDelay]);
// Gestionnaire d'exécution pas-à-pas
const handleStepByStepExecution = useCallback(() => {
setStepByStepMode(true);
handleStartExecution();
}, [handleStartExecution]);
// Gestionnaire de reprise pas-à-pas
const handleStepByStepResume = useCallback(() => {
if (isPaused && stepByStepMode) {
resumeExecution();
}
}, [isPaused, stepByStepMode, resumeExecution]);
// Gestionnaire d'arrêt
const handleStopExecution = useCallback(() => {
setStepByStepMode(false);
stopExecution();
}, [stopExecution]);
// Gestionnaire de réinitialisation
const handleResetExecution = useCallback(() => {
setStepByStepMode(false);
setBreakpoints(new Set());
resetExecution();
}, [resetExecution]);
// Basculer un breakpoint
const toggleBreakpoint = useCallback((stepId: string) => {
setBreakpoints(prev => {
const newBreakpoints = new Set(prev);
if (newBreakpoints.has(stepId)) {
newBreakpoints.delete(stepId);
} else {
newBreakpoints.add(stepId);
}
return newBreakpoints;
});
}, []);
// Sauvegarder l'état d'exécution
const saveExecutionState = useCallback(() => {
if (!saveStateName.trim()) return;
const saveState: ExecutionSaveState = {
id: `state_${Date.now()}`,
name: saveStateName.trim(),
timestamp: new Date(),
workflowId: workflow.id,
currentStepIndex: executionState.currentStepIndex,
variables,
settings,
results: executionState.results,
evidence: executionState.evidence,
};
const newStates = [...savedStates, saveState];
saveSavedStates(newStates);
setSaveStateName('');
setSaveStateDialogOpen(false);
}, [saveStateName, workflow.id, executionState, variables, settings, savedStates, saveSavedStates]);
// Restaurer un état d'exécution
const restoreExecutionState = useCallback((saveState: ExecutionSaveState) => {
// Arrêter l'exécution en cours
if (isRunning) {
stopExecution();
}
// Restaurer les paramètres
setSettings(saveState.settings);
// Note: La restauration complète nécessiterait une intégration plus profonde
// avec le système de gestion d'état du workflow
console.log('Restauration de l\'état:', saveState);
setSettingsMenuAnchor(null);
}, [isRunning, stopExecution]);
// Supprimer un état sauvegardé
const deleteSavedState = useCallback((stateId: string) => {
const newStates = savedStates.filter(state => state.id !== stateId);
saveSavedStates(newStates);
}, [savedStates, saveSavedStates]);
// Statistiques d'exécution
const executionStats = useMemo(() => {
const vwbSteps = workflow.steps.filter(step => isVWBStep(step));
return {
totalSteps: workflow.steps.length,
vwbSteps: vwbSteps.length,
breakpointsSet: breakpoints.size,
estimatedDuration: workflow.steps.length * (settings.stepDelay + 1000), // Estimation
};
}, [workflow.steps, isVWBStep, breakpoints.size, settings.stepDelay]);
return (
<Box className={className} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Contrôles principaux */}
<Card variant="outlined">
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" component="h3">
Contrôles d'Exécution
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Chip
label={`${executionStats.totalSteps} étapes`}
size="small"
icon={<TimelineIcon />}
/>
<Chip
label={`${executionStats.vwbSteps} VWB`}
size="small"
color="primary"
/>
{breakpoints.size > 0 && (
<Chip
label={`${breakpoints.size} breakpoints`}
size="small"
color="warning"
icon={<BookmarkIcon />}
/>
)}
</Box>
</Box>
{/* Boutons de contrôle principaux */}
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
<ButtonGroup variant="contained" size="large">
{/* Bouton Exécuter - toujours visible */}
<Tooltip title={canStart ? "Démarrer l'exécution complète" : "Exécution en cours ou pas d'étapes"}>
<span>
<Button
startIcon={<PlayIcon />}
onClick={() => {
console.log('🔘 [Controls] Clic sur Exécuter', { canStart, stepsLength: workflow.steps.length });
handleStartExecution();
}}
color="success"
disabled={!canStart || workflow.steps.length === 0}
>
Exécuter ({workflow.steps.length})
</Button>
</span>
</Tooltip>
{canPause && (
<Tooltip title="Mettre en pause">
<Button
startIcon={<PauseIcon />}
onClick={pauseExecution}
color="warning"
>
Pause
</Button>
</Tooltip>
)}
{canResume && (
<Tooltip title="Reprendre l'exécution">
<Button
startIcon={<PlayIcon />}
onClick={resumeExecution}
color="success"
>
Reprendre
</Button>
</Tooltip>
)}
{canStop && (
<Tooltip title="Arrêter l'exécution">
<Button
startIcon={<StopIcon />}
onClick={handleStopExecution}
color="error"
>
Arrêter
</Button>
</Tooltip>
)}
</ButtonGroup>
{/* Contrôles pas-à-pas */}
<ButtonGroup variant="outlined">
{canStart && (
<Tooltip title="Exécution pas-à-pas">
<Button
startIcon={<StepIcon />}
onClick={handleStepByStepExecution}
disabled={workflow.steps.length === 0}
>
Pas-à-pas
</Button>
</Tooltip>
)}
{stepByStepMode && isPaused && (
<Tooltip title="Étape suivante">
<Button
startIcon={<StepIcon />}
onClick={handleStepByStepResume}
color="primary"
>
Suivant
</Button>
</Tooltip>
)}
</ButtonGroup>
{/* Contrôles de gestion d'état */}
<ButtonGroup variant="outlined">
<Tooltip title="Réinitialiser">
<Button
startIcon={<ResetIcon />}
onClick={handleResetExecution}
disabled={isRunning}
>
Reset
</Button>
</Tooltip>
<Tooltip title="Sauvegarder l'état">
<Button
startIcon={<SaveIcon />}
onClick={() => setSaveStateDialogOpen(true)}
disabled={executionState.status === 'idle'}
>
Sauver
</Button>
</Tooltip>
<Tooltip title="Paramètres et états sauvegardés">
<IconButton
onClick={(e) => setSettingsMenuAnchor(e.currentTarget)}
>
<SettingsIcon />
</IconButton>
</Tooltip>
</ButtonGroup>
</Box>
{/* Mode debug et contrôles avancés */}
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
<FormControlLabel
control={
<Switch
checked={debugMode}
onChange={(e) => onDebugModeChange?.(e.target.checked)}
/>
}
label="Mode Debug"
/>
<FormControlLabel
control={
<Switch
checked={stepByStepMode}
onChange={(e) => setStepByStepMode(e.target.checked)}
disabled={isRunning}
/>
}
label="Pas-à-pas"
/>
<FormControlLabel
control={
<Switch
checked={settings.enableBreakpoints}
onChange={(e) => setSettings(prev => ({ ...prev, enableBreakpoints: e.target.checked }))}
/>
}
label="Breakpoints"
/>
<Button
variant="text"
size="small"
onClick={() => setShowAdvancedControls(!showAdvancedControls)}
>
{showAdvancedControls ? 'Masquer' : 'Afficher'} les contrôles avancés
</Button>
</Box>
{/* Contrôles avancés */}
<Collapse in={showAdvancedControls}>
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 1 }}>
<Typography variant="subtitle2" gutterBottom>
Paramètres d'Exécution
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: 2 }}>
{/* Délai entre étapes */}
<Box>
<Typography variant="body2" gutterBottom>
Délai entre étapes: {settings.stepDelay}ms
</Typography>
<Slider
value={settings.stepDelay}
onChange={(_, value) => setSettings(prev => ({ ...prev, stepDelay: value as number }))}
min={0}
max={5000}
step={100}
disabled={isRunning}
valueLabelDisplay="auto"
/>
</Box>
{/* Timeout */}
<Box>
<Typography variant="body2" gutterBottom>
Timeout: {settings.timeout / 1000}s
</Typography>
<Slider
value={settings.timeout}
onChange={(_, value) => setSettings(prev => ({ ...prev, timeout: value as number }))}
min={5000}
max={120000}
step={5000}
disabled={isRunning}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${value / 1000}s`}
/>
</Box>
{/* Tentatives de retry */}
<Box>
<Typography variant="body2" gutterBottom>
Tentatives de retry: {settings.retryAttempts}
</Typography>
<Slider
value={settings.retryAttempts}
onChange={(_, value) => setSettings(prev => ({ ...prev, retryAttempts: value as number }))}
min={0}
max={10}
step={1}
disabled={isRunning}
valueLabelDisplay="auto"
/>
</Box>
</Box>
<Box sx={{ mt: 2, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<FormControlLabel
control={
<Switch
checked={settings.autoValidate}
onChange={(e) => setSettings(prev => ({ ...prev, autoValidate: e.target.checked }))}
disabled={isRunning}
/>
}
label="Validation automatique"
/>
<FormControlLabel
control={
<Switch
checked={settings.generateEvidence}
onChange={(e) => setSettings(prev => ({ ...prev, generateEvidence: e.target.checked }))}
disabled={isRunning}
/>
}
label="Générer Evidence"
/>
<FormControlLabel
control={
<Switch
checked={settings.pauseOnError}
onChange={(e) => setSettings(prev => ({ ...prev, pauseOnError: e.target.checked }))}
disabled={isRunning}
/>
}
label="Pause sur erreur"
/>
<FormControlLabel
control={
<Switch
checked={settings.skipNonVWBSteps}
onChange={(e) => setSettings(prev => ({ ...prev, skipNonVWBSteps: e.target.checked }))}
disabled={isRunning}
/>
}
label="Ignorer étapes non-VWB"
/>
</Box>
</Box>
</Collapse>
</CardContent>
</Card>
{/* Informations d'état */}
{(isRunning || isPaused) && (
<Alert severity={isPaused ? 'warning' : 'info'}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<Typography variant="body2">
{isPaused ? 'Exécution en pause' : 'Exécution en cours'}
{stepByStepMode && ' (Mode pas-à-pas)'}
</Typography>
{executionState.currentStep && (
<Typography variant="caption" color="text.secondary">
Étape actuelle: {executionState.currentStep.name}
</Typography>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Chip
label={`${executionState.completedSteps}/${executionState.totalSteps}`}
size="small"
icon={<ScheduleIcon />}
/>
{executionState.evidence.length > 0 && (
<Chip
label={`${executionState.evidence.length} Evidence`}
size="small"
color="info"
/>
)}
</Box>
</Box>
</Alert>
)}
{/* Menu des paramètres */}
<Menu
anchorEl={settingsMenuAnchor}
open={Boolean(settingsMenuAnchor)}
onClose={() => setSettingsMenuAnchor(null)}
>
<MenuItem onClick={() => setShowAdvancedControls(!showAdvancedControls)}>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary="Paramètres avancés" />
</MenuItem>
<Divider />
{savedStates.length > 0 && (
<>
<MenuItem disabled>
<ListItemText primary="États sauvegardés" />
</MenuItem>
{savedStates.slice(-5).map((state) => (
<MenuItem
key={state.id}
onClick={() => restoreExecutionState(state)}
>
<ListItemIcon>
<RestoreIcon />
</ListItemIcon>
<ListItemText
primary={state.name}
secondary={state.timestamp.toLocaleString()}
/>
</MenuItem>
))}
<Divider />
</>
)}
<MenuItem onClick={() => setSaveStateDialogOpen(true)}>
<ListItemIcon>
<SaveIcon />
</ListItemIcon>
<ListItemText primary="Sauvegarder l'état actuel" />
</MenuItem>
</Menu>
{/* Dialog de sauvegarde d'état */}
{saveStateDialogOpen && (
<Box
sx={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
bgcolor: 'background.paper',
boxShadow: 24,
p: 3,
borderRadius: 2,
minWidth: 300,
zIndex: 1300,
}}
>
<Typography variant="h6" gutterBottom>
Sauvegarder l'État d'Exécution
</Typography>
<TextField
fullWidth
label="Nom de la sauvegarde"
value={saveStateName}
onChange={(e) => setSaveStateName(e.target.value)}
margin="normal"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SaveIcon />
</InputAdornment>
),
}}
/>
<Box sx={{ display: 'flex', gap: 2, mt: 2 }}>
<Button
variant="contained"
onClick={saveExecutionState}
disabled={!saveStateName.trim()}
>
Sauvegarder
</Button>
<Button
variant="outlined"
onClick={() => {
setSaveStateDialogOpen(false);
setSaveStateName('');
}}
>
Annuler
</Button>
</Box>
</Box>
)}
</Box>
);
};
export default ExecutionControls;

View File

@@ -0,0 +1,11 @@
/**
* Index des Contrôles d'Exécution VWB
* Auteur : Dom, Alice, Kiro - 11 janvier 2026
*/
export { default as ExecutionControls } from './ExecutionControls';
export type {
ExecutionControlsProps,
ExecutionSettings,
ExecutionSaveState
} from './ExecutionControls';

View File

@@ -232,8 +232,8 @@ const VWBExecutorExtension: React.FC<VWBExecutorExtensionProps> = ({
// Déterminer l'URL de l'API
const hostname = window.location.hostname;
const apiBase = (hostname === 'localhost' || hostname === '127.0.0.1')
? 'http://localhost:5003/api'
: `http://${hostname}:5003/api`;
? 'http://localhost:5001/api'
: `http://${hostname}:5000/api`;
const response = await fetch(`${apiBase}/workflows/${workflow.id}/feedback`, {
method: 'POST',
@@ -385,17 +385,24 @@ const VWBExecutorExtension: React.FC<VWBExecutorExtensionProps> = ({
</Box>
{/* Contrôles d'exécution */}
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
{canStart && (
<Button
variant="contained"
startIcon={<PlayIcon />}
onClick={startExecution}
disabled={!canExecute || workflow.steps.length === 0}
color="success"
>
Exécuter VWB
</Button>
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
{/* Bouton Exécuter - toujours visible */}
<Button
variant="contained"
startIcon={<PlayIcon />}
onClick={() => {
console.log('🔘 [VWB UI] Clic sur Exécuter VWB', { canStart, canExecute, stepsLength: workflow.steps.length });
startExecution();
}}
disabled={!canStart || !canExecute || workflow.steps.length === 0}
color="success"
>
Exécuter VWB ({workflow.steps.length} étapes)
</Button>
{/* Message d'état */}
{!canStart && executionState.status === 'running' && (
<Chip label="Exécution en cours..." color="primary" size="small" />
)}
{canPause && (

View File

@@ -484,30 +484,13 @@ const StandardExecutor: React.FC<StandardExecutorProps> = ({
}
};
// Exécuter une étape individuelle avec le nouveau client API ou en simulation locale
// Exécuter une étape individuelle avec le nouveau client API
// NOTE: On n'utilise plus isOffline comme bloqueur - on essaie toujours l'API
const executeStep = useCallback(async (step: Step): Promise<StepExecutionResult> => {
const startTime = Date.now();
const stepId = step.id;
const currentRetries = retryAttempts.get(stepId) || 0;
// Mode simulation locale si API hors ligne
if (isOffline) {
// Simuler un délai d'exécution réaliste
await new Promise(resolve => setTimeout(resolve, 300 + Math.random() * 500));
const duration = Date.now() - startTime;
// Simulation : succès pour la plupart des étapes
const simulatedSuccess = Math.random() > 0.1; // 90% de succès en simulation
return {
stepId: step.id,
success: simulatedSuccess,
duration,
output: simulatedSuccess ? { simulated: true, stepType: step.type } : undefined,
error: simulatedSuccess ? undefined : 'Erreur simulée pour démonstration',
};
}
try {
// Utiliser le nouveau client API pour l'exécution
const result = await executionApi.executeStep({
@@ -570,7 +553,7 @@ const StandardExecutor: React.FC<StandardExecutorProps> = ({
error: apiError.message || 'Erreur inconnue',
};
}
}, [executionApi, workflow.id, retryAttempts, isOffline]);
}, [executionApi, workflow.id, retryAttempts]);
// Déterminer si une étape doit être retentée
const shouldRetryStep = useCallback((error: ApiError): boolean => {

View File

@@ -0,0 +1,519 @@
/**
* Composant Glossaire - Dictionnaire des termes techniques français
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce composant fournit un glossaire accessible des termes techniques
* utilisés dans le Visual Workflow Builder, avec recherche et navigation.
*/
import React, { useState, useMemo } from 'react';
import {
Box,
Typography,
TextField,
List,
ListItem,
ListItemText,
Accordion,
AccordionSummary,
AccordionDetails,
InputAdornment,
Chip,
Divider,
Paper,
} from '@mui/material';
import {
Search as SearchIcon,
ExpandMore as ExpandMoreIcon,
Book as BookIcon,
} from '@mui/icons-material';
interface GlossaryTerm {
id: string;
term: string;
definition: string;
category: string;
synonyms?: string[];
relatedTerms?: string[];
examples?: string[];
}
interface GlossaryProps {
searchTerm?: string;
onTermSelect?: (termId: string) => void;
}
// Base de données des termes du glossaire
const glossaryTerms: GlossaryTerm[] = [
// Termes généraux
{
id: 'workflow',
term: 'Workflow',
definition: 'Séquence d\'étapes automatisées qui reproduit un processus métier ou une tâche répétitive. Dans RPA Vision, un workflow est composé d\'étapes connectées qui s\'exécutent dans un ordre logique.',
category: 'Général',
synonyms: ['Flux de travail', 'Processus automatisé'],
relatedTerms: ['etape', 'connexion', 'execution'],
examples: ['Workflow de saisie de données', 'Workflow de validation de formulaires']
},
{
id: 'etape',
term: 'Étape',
definition: 'Unité d\'action élémentaire dans un workflow. Chaque étape effectue une action spécifique comme cliquer, saisir du texte, ou extraire des données.',
category: 'Général',
synonyms: ['Action', 'Nœud', 'Tâche'],
relatedTerms: ['workflow', 'parametres', 'connexion'],
examples: ['Étape "Cliquer"', 'Étape "Saisir du texte"', 'Étape "Attendre"']
},
{
id: 'connexion',
term: 'Connexion',
definition: 'Lien entre deux étapes qui définit l\'ordre d\'exécution. Les connexions créent le flux logique du workflow.',
category: 'Général',
synonyms: ['Lien', 'Transition', 'Flèche'],
relatedTerms: ['etape', 'workflow', 'flux'],
examples: ['Connexion de l\'étape A vers l\'étape B']
},
{
id: 'parametres',
term: 'Paramètres',
definition: 'Valeurs de configuration d\'une étape qui définissent comment elle doit s\'exécuter. Chaque type d\'étape a ses propres paramètres.',
category: 'Configuration',
synonyms: ['Propriétés', 'Configuration', 'Options'],
relatedTerms: ['etape', 'validation', 'variables'],
examples: ['Paramètre "texte" pour une étape de saisie', 'Paramètre "durée" pour une attente']
},
// Termes techniques
{
id: 'selection-visuelle',
term: 'Sélection visuelle',
definition: 'Méthode pour choisir un élément sur une page web en cliquant directement sur une capture d\'écran, plutôt qu\'en écrivant un sélecteur CSS.',
category: 'Technique',
synonyms: ['Sélecteur visuel', 'Pointage visuel'],
relatedTerms: ['element-cible', 'capture-ecran', 'selecteur'],
examples: ['Sélectionner un bouton en cliquant dessus', 'Choisir un champ de saisie visuellement']
},
{
id: 'element-cible',
term: 'Élément cible',
definition: 'Élément d\'une page web sur lequel une étape va agir. Peut être un bouton, un champ de saisie, un lien, etc.',
category: 'Technique',
synonyms: ['Cible', 'Élément sélectionné'],
relatedTerms: ['selection-visuelle', 'selecteur', 'dom'],
examples: ['Bouton "Valider"', 'Champ "Email"', 'Lien "En savoir plus"']
},
{
id: 'variables',
term: 'Variables',
definition: 'Valeurs dynamiques qui peuvent être utilisées dans les paramètres des étapes. Permettent de rendre les workflows réutilisables et flexibles.',
category: 'Technique',
synonyms: ['Variables dynamiques', 'Placeholders'],
relatedTerms: ['parametres', 'substitution', 'dynamique'],
examples: ['${nom_utilisateur}', '${email}', '${compteur}']
},
{
id: 'validation',
term: 'Validation',
definition: 'Vérification automatique de la cohérence et de la complétude d\'un workflow avant son exécution. Détecte les erreurs et avertissements.',
category: 'Technique',
synonyms: ['Vérification', 'Contrôle qualité'],
relatedTerms: ['erreurs', 'avertissements', 'execution'],
examples: ['Validation des paramètres manquants', 'Détection de cycles']
},
// Types d'étapes
{
id: 'clic',
term: 'Clic',
definition: 'Action de cliquer sur un élément de la page web. Peut être un clic gauche, droit, ou double-clic selon les besoins.',
category: 'Actions Web',
synonyms: ['Click', 'Cliquer'],
relatedTerms: ['element-cible', 'interaction'],
examples: ['Clic sur un bouton', 'Clic droit pour menu contextuel']
},
{
id: 'saisie',
term: 'Saisie de texte',
definition: 'Action de saisir du texte dans un champ de saisie. Supporte les variables pour un contenu dynamique.',
category: 'Actions Web',
synonyms: ['Frappe', 'Écriture', 'Input'],
relatedTerms: ['variables', 'champ-saisie', 'texte'],
examples: ['Saisir un nom d\'utilisateur', 'Remplir un formulaire']
},
{
id: 'extraction',
term: 'Extraction de données',
definition: 'Action de récupérer des informations depuis un élément de la page web pour les stocker dans une variable.',
category: 'Données',
synonyms: ['Récupération', 'Capture de données'],
relatedTerms: ['variables', 'element-cible', 'donnees'],
examples: ['Extraire le prix d\'un produit', 'Récupérer un numéro de commande']
},
{
id: 'condition',
term: 'Condition',
definition: 'Structure logique qui permet d\'exécuter différentes actions selon qu\'une condition est vraie ou fausse.',
category: 'Logique',
synonyms: ['Test conditionnel', 'Branchement'],
relatedTerms: ['logique', 'variables', 'flux'],
examples: ['Si âge > 18 alors continuer', 'Si champ vide alors alerter']
},
{
id: 'attente',
term: 'Attente',
definition: 'Pause dans l\'exécution du workflow pendant une durée déterminée. Utile pour attendre le chargement d\'éléments.',
category: 'Contrôle',
synonyms: ['Pause', 'Délai', 'Wait'],
relatedTerms: ['synchronisation', 'timing'],
examples: ['Attendre 2 secondes', 'Pause après un clic']
},
// Interface utilisateur
{
id: 'canvas',
term: 'Canvas',
definition: 'Zone de travail principale où les étapes sont placées et connectées pour former le workflow visuel.',
category: 'Interface',
synonyms: ['Zone de travail', 'Espace de conception'],
relatedTerms: ['etape', 'connexion', 'workflow'],
examples: ['Glisser une étape sur le canvas', 'Zoomer sur le canvas']
},
{
id: 'palette',
term: 'Palette',
definition: 'Boîte à outils contenant tous les types d\'étapes disponibles, organisés par catégories pour faciliter la recherche.',
category: 'Interface',
synonyms: ['Boîte à outils', 'Toolbox'],
relatedTerms: ['etape', 'categories', 'drag-drop'],
examples: ['Palette d\'étapes', 'Catégorie Actions Web']
},
{
id: 'proprietes',
term: 'Panneau de propriétés',
definition: 'Interface de configuration des paramètres de l\'étape sélectionnée. S\'adapte selon le type d\'étape.',
category: 'Interface',
synonyms: ['Configuration', 'Paramètres'],
relatedTerms: ['parametres', 'etape', 'configuration'],
examples: ['Configurer le texte à saisir', 'Définir la durée d\'attente']
},
{
id: 'minimap',
term: 'Mini-carte',
definition: 'Vue d\'ensemble miniaturisée du workflow complet, permettant de naviguer rapidement dans les gros workflows.',
category: 'Interface',
synonyms: ['Vue d\'ensemble', 'Navigation'],
relatedTerms: ['canvas', 'navigation', 'workflow'],
examples: ['Cliquer sur la mini-carte pour se déplacer']
},
// Concepts avancés
{
id: 'cycle',
term: 'Cycle',
definition: 'Boucle infinie dans un workflow où les étapes se référencent mutuellement, empêchant l\'exécution normale.',
category: 'Erreurs',
synonyms: ['Boucle infinie', 'Référence circulaire'],
relatedTerms: ['validation', 'erreurs', 'connexion'],
examples: ['Étape A → Étape B → Étape A']
},
{
id: 'etape-deconnectee',
term: 'Étape déconnectée',
definition: 'Étape qui n\'est pas reliée au flux principal du workflow et qui ne sera donc pas exécutée.',
category: 'Avertissements',
synonyms: ['Étape isolée', 'Nœud orphelin'],
relatedTerms: ['connexion', 'validation', 'flux'],
examples: ['Étape sans connexion d\'entrée ni de sortie']
},
{
id: 'execution',
term: 'Exécution',
definition: 'Processus de lancement et de déroulement du workflow, étape par étape, selon l\'ordre défini par les connexions.',
category: 'Général',
synonyms: ['Lancement', 'Déroulement', 'Run'],
relatedTerms: ['workflow', 'etape', 'validation'],
examples: ['Exécution réussie', 'Exécution interrompue par erreur']
}
];
// Catégories pour l'organisation
const categories = [
'Général',
'Technique',
'Actions Web',
'Données',
'Logique',
'Contrôle',
'Interface',
'Erreurs',
'Avertissements'
];
/**
* Composant Glossaire
*/
const Glossary: React.FC<GlossaryProps> = ({
searchTerm: externalSearchTerm = '',
onTermSelect,
}) => {
const [internalSearchTerm, setInternalSearchTerm] = useState('');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(['Général']));
// Utiliser le terme de recherche externe ou interne
const searchTerm = externalSearchTerm || internalSearchTerm;
// Filtrer les termes selon la recherche
const filteredTerms = useMemo(() => {
if (!searchTerm.trim()) {
return glossaryTerms;
}
const lowerSearchTerm = searchTerm.toLowerCase();
return glossaryTerms.filter(term =>
term.term.toLowerCase().includes(lowerSearchTerm) ||
term.definition.toLowerCase().includes(lowerSearchTerm) ||
term.synonyms?.some(synonym => synonym.toLowerCase().includes(lowerSearchTerm)) ||
term.examples?.some(example => example.toLowerCase().includes(lowerSearchTerm))
);
}, [searchTerm]);
// Organiser les termes par catégorie
const termsByCategory = useMemo(() => {
const organized: Record<string, GlossaryTerm[]> = {};
categories.forEach(category => {
organized[category] = filteredTerms.filter(term => term.category === category);
});
return organized;
}, [filteredTerms]);
// Gestionnaire d'expansion des catégories
const handleCategoryToggle = (category: string) => {
setExpandedCategories(prev => {
const newSet = new Set(prev);
if (newSet.has(category)) {
newSet.delete(category);
} else {
newSet.add(category);
}
return newSet;
});
};
// Gestionnaire de sélection de terme
const handleTermSelect = (termId: string) => {
if (onTermSelect) {
onTermSelect(termId);
}
};
// Rendu d'un terme
const renderTerm = (term: GlossaryTerm) => (
<ListItem
key={term.id}
onClick={() => handleTermSelect(term.id)}
sx={{
cursor: 'pointer',
'&:hover': {
backgroundColor: '#f5f5f5',
},
flexDirection: 'column',
alignItems: 'stretch',
py: 2,
}}
>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="h6" component="dt">
{term.term}
</Typography>
{term.synonyms && term.synonyms.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5 }}>
{term.synonyms.map((synonym, index) => (
<Chip
key={index}
label={synonym}
size="small"
variant="outlined"
color="primary"
/>
))}
</Box>
)}
</Box>
}
secondary={
<Box component="dd" sx={{ m: 0 }}>
<Typography variant="body2" paragraph>
{term.definition}
</Typography>
{term.examples && term.examples.length > 0 && (
<Box sx={{ mb: 1 }}>
<Typography variant="caption" fontWeight="bold" color="text.secondary">
Exemples :
</Typography>
<List dense sx={{ pl: 2 }}>
{term.examples.map((example, index) => (
<ListItem key={index} sx={{ py: 0, pl: 0 }}>
<Typography variant="caption" color="text.secondary">
{example}
</Typography>
</ListItem>
))}
</List>
</Box>
)}
{term.relatedTerms && term.relatedTerms.length > 0 && (
<Box>
<Typography variant="caption" fontWeight="bold" color="text.secondary">
Termes liés :
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.5 }}>
{term.relatedTerms.map((relatedId, index) => {
const relatedTerm = glossaryTerms.find(t => t.id === relatedId);
return relatedTerm ? (
<Chip
key={index}
label={relatedTerm.term}
size="small"
variant="outlined"
color="secondary"
onClick={(e) => {
e.stopPropagation();
handleTermSelect(relatedId);
}}
/>
) : null;
})}
</Box>
</Box>
)}
</Box>
}
/>
</ListItem>
);
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* En-tête */}
<Box sx={{ p: 2, borderBottom: '1px solid #e0e0e0' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<BookIcon color="primary" />
<Typography variant="h5" component="h1">
Glossaire Technique
</Typography>
</Box>
{/* Champ de recherche */}
{!externalSearchTerm && (
<TextField
fullWidth
size="small"
placeholder="Rechercher un terme..."
value={internalSearchTerm}
onChange={(e) => setInternalSearchTerm(e.target.value)}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
},
}}
/>
)}
{/* Statistiques */}
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
{filteredTerms.length} terme(s) trouvé(s) sur {glossaryTerms.length} au total
</Typography>
</Box>
{/* Contenu du glossaire */}
<Box sx={{ flex: 1, overflow: 'auto' }}>
{searchTerm ? (
// Vue de recherche : tous les termes filtrés
<Paper sx={{ m: 2, p: 2 }}>
<Typography variant="h6" gutterBottom>
Résultats de recherche pour "{searchTerm}"
</Typography>
<List component="dl">
{filteredTerms.map(term => (
<React.Fragment key={term.id}>
{renderTerm(term)}
<Divider />
</React.Fragment>
))}
</List>
{filteredTerms.length === 0 && (
<Typography variant="body2" color="text.secondary" textAlign="center">
Aucun terme trouvé pour "{searchTerm}"
</Typography>
)}
</Paper>
) : (
// Vue par catégories
<Box>
{categories.map(category => {
const categoryTerms = termsByCategory[category];
if (categoryTerms.length === 0) return null;
return (
<Accordion
key={category}
expanded={expandedCategories.has(category)}
onChange={() => handleCategoryToggle(category)}
disableGutters
elevation={0}
sx={{
'&:before': { display: 'none' },
borderBottom: '1px solid #f0f0f0',
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
minHeight: 56,
'& .MuiAccordionSummary-content': {
alignItems: 'center',
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6">{category}</Typography>
<Chip
label={categoryTerms.length}
size="small"
color="primary"
variant="outlined"
/>
</Box>
</AccordionSummary>
<AccordionDetails sx={{ p: 0 }}>
<List component="dl">
{categoryTerms.map(term => (
<React.Fragment key={term.id}>
{renderTerm(term)}
<Divider />
</React.Fragment>
))}
</List>
</AccordionDetails>
</Accordion>
);
})}
</Box>
)}
</Box>
</Box>
);
};
export default Glossary;

View File

@@ -0,0 +1,413 @@
/**
* Interactive Preview Area Styles - RPA Vision V3
*
* Styles pour la zone d'aperçu interactif avec zoom et navigation.
* Optimisé pour une expérience de visualisation fluide et intuitive.
*/
.interactive-preview-area {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 9998;
background: rgba(0, 0, 0, 0.9);
display: flex;
flex-direction: column;
}
.interactive-preview-area__header {
background: #1976d2;
color: white;
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
flex-shrink: 0;
}
.interactive-preview-area__title {
display: flex;
align-items: center;
gap: 12px;
font-size: 1.25rem;
font-weight: 600;
}
.interactive-preview-area__controls {
display: flex;
align-items: center;
gap: 16px;
}
.interactive-preview-area__zoom-controls {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.1);
padding: 8px 12px;
border-radius: 20px;
}
.interactive-preview-area__zoom-slider {
width: 100px;
margin: 0 8px;
}
.interactive-preview-area__zoom-label {
min-width: 40px;
text-align: center;
font-size: 0.875rem;
font-weight: 500;
}
.interactive-preview-area__action-controls {
display: flex;
gap: 8px;
}
.interactive-preview-area__content {
flex: 1;
display: flex;
overflow: hidden;
}
.interactive-preview-area__canvas-container {
flex: 1;
position: relative;
overflow: hidden;
background: #2c2c2c;
display: flex;
align-items: center;
justify-content: center;
}
.interactive-preview-area__canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: grab;
transition: cursor 0.2s ease;
}
.interactive-preview-area__canvas--dragging {
cursor: grabbing;
}
.interactive-preview-area__sidebar {
width: 320px;
background: white;
border-left: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.interactive-preview-area__sidebar-header {
padding: 16px;
border-bottom: 1px solid #e0e0e0;
background: #f8f9fa;
}
.interactive-preview-area__sidebar-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.interactive-preview-area__info-section {
margin-bottom: 24px;
}
.interactive-preview-area__info-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
margin-bottom: 12px;
color: #333;
}
.interactive-preview-area__info-content {
font-size: 0.875rem;
line-height: 1.5;
color: #666;
}
.interactive-preview-area__metadata-grid {
display: grid;
gap: 8px;
}
.interactive-preview-area__metadata-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.interactive-preview-area__metadata-label {
font-weight: 500;
color: #333;
min-width: 80px;
}
.interactive-preview-area__metadata-value {
color: #666;
text-align: right;
flex: 1;
word-break: break-word;
}
.interactive-preview-area__text-content {
background: #e3f2fd;
padding: 12px;
border-radius: 8px;
border: 1px solid #bbdefb;
font-style: italic;
color: #1565c0;
margin: 8px 0;
}
.interactive-preview-area__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 8px 0;
}
.interactive-preview-area__display-controls {
display: flex;
flex-direction: column;
gap: 8px;
}
.interactive-preview-area__control-button {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 6px;
background: white;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
}
.interactive-preview-area__control-button:hover {
background: #f5f5f5;
border-color: #1976d2;
}
.interactive-preview-area__control-button--active {
background: #1976d2;
color: white;
border-color: #1976d2;
}
/* Annotations overlay */
.interactive-preview-area__annotations {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
}
.interactive-preview-area__annotation {
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
color: white;
cursor: pointer;
pointer-events: auto;
transition: transform 0.2s ease;
}
.interactive-preview-area__annotation:hover {
transform: scale(1.2);
}
.interactive-preview-area__annotation--info {
background: #2196f3;
}
.interactive-preview-area__annotation--warning {
background: #ff9800;
}
.interactive-preview-area__annotation--success {
background: #4caf50;
}
.interactive-preview-area__annotation-tooltip {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 6px 10px;
border-radius: 4px;
font-size: 10px;
white-space: nowrap;
pointer-events: none;
z-index: 20;
}
/* Target highlight animations */
.interactive-preview-area__target-highlight {
position: absolute;
border: 3px solid #4caf50;
background: rgba(76, 175, 80, 0.1);
pointer-events: none;
z-index: 5;
}
.interactive-preview-area__target-highlight--animated {
animation: interactive-preview-pulse 2s infinite;
}
@keyframes interactive-preview-pulse {
0% {
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.7);
border-width: 3px;
}
50% {
box-shadow: 0 0 0 10px rgba(76, 175, 80, 0.2);
border-width: 4px;
}
100% {
box-shadow: 0 0 0 0 rgba(76, 175, 80, 0);
border-width: 3px;
}
}
.interactive-preview-area__target-corners {
position: absolute;
pointer-events: none;
z-index: 6;
}
.interactive-preview-area__corner {
position: absolute;
width: 15px;
height: 15px;
border: 2px solid #4caf50;
}
.interactive-preview-area__corner--top-left {
top: -2px;
left: -2px;
border-right: none;
border-bottom: none;
}
.interactive-preview-area__corner--top-right {
top: -2px;
right: -2px;
border-left: none;
border-bottom: none;
}
.interactive-preview-area__corner--bottom-left {
bottom: -2px;
left: -2px;
border-right: none;
border-top: none;
}
.interactive-preview-area__corner--bottom-right {
bottom: -2px;
right: -2px;
border-left: none;
border-top: none;
}
/* Loading state */
.interactive-preview-area__loading {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
flex-direction: column;
gap: 16px;
color: white;
}
.interactive-preview-area__loading-spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top: 4px solid white;
border-radius: 50%;
animation: interactive-preview-spin 1s linear infinite;
}
@keyframes interactive-preview-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Responsive design */
@media (max-width: 768px) {
.interactive-preview-area__sidebar {
width: 280px;
}
.interactive-preview-area__header {
padding: 12px 16px;
}
.interactive-preview-area__zoom-controls {
padding: 6px 10px;
}
.interactive-preview-area__zoom-slider {
width: 80px;
}
.interactive-preview-area__sidebar-content {
padding: 12px;
}
}
@media (max-width: 480px) {
.interactive-preview-area__content {
flex-direction: column;
}
.interactive-preview-area__sidebar {
width: 100%;
height: 250px;
border-left: none;
border-top: 1px solid #e0e0e0;
}
.interactive-preview-area__canvas-container {
height: calc(100vh - 250px - 80px);
}
.interactive-preview-area__controls {
flex-wrap: wrap;
gap: 8px;
}
.interactive-preview-area__zoom-controls {
order: 2;
width: 100%;
justify-content: center;
}
}

View File

@@ -0,0 +1,612 @@
/**
* Composant Zone d'Aperçu Interactif - Configuration des paramètres d'étapes
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Zone d'aperçu interactif pour les captures d'écran avec zoom, navigation et annotations.
* Permet l'examen détaillé des éléments sélectionnés avec contours animés.
*
* Fonctionnalités:
* - Zoom fluide avec molette de souris
* - Navigation par glisser-déposer
* - Annotations contextuelles
* - Surbrillance de l'élément cible
* - Contrôles de navigation intuitifs
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
Box,
Typography,
IconButton,
Tooltip,
Chip,
Paper,
Slider,
Divider,
} from '@mui/material';
import {
Close as CloseIcon,
ZoomIn as ZoomInIcon,
ZoomOut as ZoomOutIcon,
CenterFocusStrong as CenterIcon,
Fullscreen as FullscreenIcon,
Info as InfoIcon,
MyLocation as TargetIcon,
} from '@mui/icons-material';
import { BoundingBox } from '../../types';
import './InteractivePreviewArea.css';
interface VisualMetadata {
element_type?: string;
relative_position?: string;
text_content?: string;
}
interface InteractivePreviewAreaProps {
open: boolean;
onClose: () => void;
screenshot: string;
boundingBox: BoundingBox;
metadata?: VisualMetadata;
}
interface ViewportState {
zoom: number;
panX: number;
panY: number;
isDragging: boolean;
dragStart: { x: number; y: number };
}
interface AnnotationPoint {
x: number;
y: number;
label: string;
type: 'info' | 'warning' | 'target';
}
const InteractivePreviewArea: React.FC<InteractivePreviewAreaProps> = ({
open,
onClose,
screenshot,
boundingBox,
metadata,
}) => {
const [viewport, setViewport] = useState<ViewportState>({
zoom: 1,
panX: 0,
panY: 0,
isDragging: false,
dragStart: { x: 0, y: 0 },
});
const [showAnnotations, setShowAnnotations] = useState(true);
const [showTargetHighlight, setShowTargetHighlight] = useState(true);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 });
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const animationRef = useRef<number | null>(null);
// Réinitialiser l'état quand le dialog s'ouvre
useEffect(() => {
if (open) {
setViewport({
zoom: 1,
panX: 0,
panY: 0,
isDragging: false,
dragStart: { x: 0, y: 0 },
});
setImageLoaded(false);
}
}, [open]);
// Charger l'image et obtenir ses dimensions
useEffect(() => {
if (open && screenshot) {
const img = new Image();
img.onload = () => {
setImageDimensions({ width: img.width, height: img.height });
setImageLoaded(true);
// Centrer sur l'élément cible
centerOnTarget();
};
img.src = `data:image/png;base64,${screenshot}`;
imageRef.current = img;
}
}, [open, screenshot]);
// Animation du contour cible
useEffect(() => {
if (showTargetHighlight && imageLoaded) {
const animate = () => {
drawCanvas();
animationRef.current = requestAnimationFrame(animate);
};
animate();
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}
}, [showTargetHighlight, imageLoaded, viewport]);
/**
* Centre la vue sur l'élément cible
*/
const centerOnTarget = useCallback(() => {
if (!imageLoaded || !containerRef.current) return;
const container = containerRef.current;
const containerRect = container.getBoundingClientRect();
// Calculer la position pour centrer l'élément cible
const targetCenterX = boundingBox.x + boundingBox.width / 2;
const targetCenterY = boundingBox.y + boundingBox.height / 2;
const panX = (containerRect.width / 2) - (targetCenterX * viewport.zoom);
const panY = (containerRect.height / 2) - (targetCenterY * viewport.zoom);
setViewport(prev => ({
...prev,
panX,
panY,
}));
}, [boundingBox, viewport.zoom, imageLoaded]);
/**
* Gère le zoom avec la molette
*/
const handleWheel = useCallback((event: React.WheelEvent) => {
event.preventDefault();
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const zoomFactor = event.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.max(0.1, Math.min(5, viewport.zoom * zoomFactor));
// Ajuster le pan pour zoomer vers la position de la souris
const zoomRatio = newZoom / viewport.zoom;
const newPanX = mouseX - (mouseX - viewport.panX) * zoomRatio;
const newPanY = mouseY - (mouseY - viewport.panY) * zoomRatio;
setViewport(prev => ({
...prev,
zoom: newZoom,
panX: newPanX,
panY: newPanY,
}));
}, [viewport]);
/**
* Démarre le glisser-déposer
*/
const handleMouseDown = useCallback((event: React.MouseEvent) => {
event.preventDefault();
setViewport(prev => ({
...prev,
isDragging: true,
dragStart: { x: event.clientX - prev.panX, y: event.clientY - prev.panY },
}));
}, []);
/**
* Gère le glisser-déposer
*/
const handleMouseMove = useCallback((event: React.MouseEvent) => {
if (!viewport.isDragging) return;
const newPanX = event.clientX - viewport.dragStart.x;
const newPanY = event.clientY - viewport.dragStart.y;
setViewport(prev => ({
...prev,
panX: newPanX,
panY: newPanY,
}));
}, [viewport.isDragging, viewport.dragStart]);
/**
* Termine le glisser-déposer
*/
const handleMouseUp = useCallback(() => {
setViewport(prev => ({
...prev,
isDragging: false,
}));
}, []);
/**
* Dessine le canvas avec l'image et les overlays
*/
const drawCanvas = useCallback(() => {
const canvas = canvasRef.current;
const image = imageRef.current;
if (!canvas || !image || !imageLoaded) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Ajuster la taille du canvas
const container = containerRef.current;
if (container) {
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
}
// Effacer le canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Dessiner l'image avec transformation
ctx.save();
ctx.translate(viewport.panX, viewport.panY);
ctx.scale(viewport.zoom, viewport.zoom);
ctx.drawImage(image, 0, 0);
ctx.restore();
// Dessiner le contour animé de l'élément cible
if (showTargetHighlight) {
drawTargetHighlight(ctx);
}
// Dessiner les annotations
if (showAnnotations) {
drawAnnotations(ctx);
}
}, [viewport, showTargetHighlight, showAnnotations, imageLoaded]);
/**
* Dessine le contour animé de l'élément cible
*/
const drawTargetHighlight = useCallback((ctx: CanvasRenderingContext2D) => {
const time = Date.now() * 0.003; // Animation lente
ctx.save();
ctx.translate(viewport.panX, viewport.panY);
ctx.scale(viewport.zoom, viewport.zoom);
// Contour principal
ctx.strokeStyle = '#4CAF50';
ctx.lineWidth = 3 / viewport.zoom;
ctx.setLineDash([10, 5]);
ctx.lineDashOffset = time * 20;
ctx.strokeRect(
boundingBox.x - 2,
boundingBox.y - 2,
boundingBox.width + 4,
boundingBox.height + 4
);
// Contour externe animé
ctx.strokeStyle = `rgba(76, 175, 80, ${0.5 + 0.3 * Math.sin(time * 2)})`;
ctx.lineWidth = 1 / viewport.zoom;
ctx.setLineDash([]);
ctx.strokeRect(
boundingBox.x - 5,
boundingBox.y - 5,
boundingBox.width + 10,
boundingBox.height + 10
);
// Points de coin
const cornerSize = 8 / viewport.zoom;
ctx.fillStyle = '#4CAF50';
const corners = [
{ x: boundingBox.x, y: boundingBox.y },
{ x: boundingBox.x + boundingBox.width, y: boundingBox.y },
{ x: boundingBox.x, y: boundingBox.y + boundingBox.height },
{ x: boundingBox.x + boundingBox.width, y: boundingBox.y + boundingBox.height },
];
corners.forEach(corner => {
ctx.fillRect(
corner.x - cornerSize / 2,
corner.y - cornerSize / 2,
cornerSize,
cornerSize
);
});
ctx.restore();
}, [viewport, boundingBox]);
/**
* Dessine les annotations
*/
const drawAnnotations = useCallback((ctx: CanvasRenderingContext2D) => {
const annotations: AnnotationPoint[] = [
{
x: boundingBox.x + boundingBox.width / 2,
y: boundingBox.y - 20,
label: metadata?.element_type || 'Élément',
type: 'target'
}
];
ctx.save();
ctx.translate(viewport.panX, viewport.panY);
ctx.scale(viewport.zoom, viewport.zoom);
annotations.forEach(annotation => {
// Bulle d'annotation
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.strokeStyle = '#4CAF50';
ctx.lineWidth = 1 / viewport.zoom;
const padding = 8 / viewport.zoom;
const fontSize = 12 / viewport.zoom;
ctx.font = `${fontSize}px Arial`;
const textWidth = ctx.measureText(annotation.label).width;
const bubbleWidth = textWidth + padding * 2;
const bubbleHeight = fontSize + padding * 2;
// Dessiner la bulle
ctx.fillRect(
annotation.x - bubbleWidth / 2,
annotation.y - bubbleHeight,
bubbleWidth,
bubbleHeight
);
ctx.strokeRect(
annotation.x - bubbleWidth / 2,
annotation.y - bubbleHeight,
bubbleWidth,
bubbleHeight
);
// Dessiner le texte
ctx.fillStyle = 'white';
ctx.textAlign = 'center';
ctx.fillText(
annotation.label,
annotation.x,
annotation.y - padding
);
});
ctx.restore();
}, [viewport, boundingBox, metadata]);
/**
* Contrôles de zoom
*/
const zoomIn = useCallback(() => {
setViewport(prev => ({
...prev,
zoom: Math.min(5, prev.zoom * 1.2),
}));
}, []);
const zoomOut = useCallback(() => {
setViewport(prev => ({
...prev,
zoom: Math.max(0.1, prev.zoom / 1.2),
}));
}, []);
const resetZoom = useCallback(() => {
setViewport(prev => ({
...prev,
zoom: 1,
panX: 0,
panY: 0,
}));
}, []);
return (
<Dialog
open={open}
onClose={onClose}
maxWidth={false}
fullWidth
slotProps={{
paper: {
sx: {
width: '90vw',
height: '90vh',
maxWidth: 'none',
maxHeight: 'none'
}
}
}}
>
<DialogTitle>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TargetIcon />
<Typography variant="h6">Aperçu Interactif</Typography>
{metadata && (
<Chip
label={metadata.element_type}
size="small"
color="primary"
/>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Tooltip title="Annotations">
<IconButton
onClick={() => setShowAnnotations(!showAnnotations)}
color={showAnnotations ? 'primary' : 'default'}
>
<InfoIcon />
</IconButton>
</Tooltip>
<Tooltip title="Surbrillance cible">
<IconButton
onClick={() => setShowTargetHighlight(!showTargetHighlight)}
color={showTargetHighlight ? 'primary' : 'default'}
>
<TargetIcon />
</IconButton>
</Tooltip>
<IconButton onClick={onClose}>
<CloseIcon />
</IconButton>
</Box>
</Box>
</DialogTitle>
<DialogContent sx={{ p: 0, display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Barre d'outils */}
<Paper sx={{ p: 1, display: 'flex', alignItems: 'center', gap: 2, borderRadius: 0 }}>
<Box sx={{ display: 'flex', gap: 1 }}>
<Tooltip title="Zoom avant">
<IconButton onClick={zoomIn} size="small">
<ZoomInIcon />
</IconButton>
</Tooltip>
<Tooltip title="Zoom arrière">
<IconButton onClick={zoomOut} size="small">
<ZoomOutIcon />
</IconButton>
</Tooltip>
<Tooltip title="Centrer sur l'élément">
<IconButton onClick={centerOnTarget} size="small">
<CenterIcon />
</IconButton>
</Tooltip>
<Tooltip title="Réinitialiser">
<IconButton onClick={resetZoom} size="small">
<FullscreenIcon />
</IconButton>
</Tooltip>
</Box>
<Divider orientation="vertical" flexItem />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, minWidth: 200 }}>
<Typography variant="body2">Zoom:</Typography>
<Slider
value={viewport.zoom}
min={0.1}
max={5}
step={0.1}
onChange={(_, value) => setViewport(prev => ({ ...prev, zoom: value as number }))}
sx={{ flex: 1 }}
/>
<Typography variant="body2" sx={{ minWidth: 50 }}>
{Math.round(viewport.zoom * 100)}%
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
<Typography variant="body2" color="textSecondary">
Position: {Math.round(viewport.panX)}, {Math.round(viewport.panY)}
</Typography>
</Paper>
{/* Zone de visualisation */}
<Box
ref={containerRef}
className="preview-container"
sx={{
flex: 1,
position: 'relative',
overflow: 'hidden',
cursor: viewport.isDragging ? 'grabbing' : 'grab',
backgroundColor: '#f5f5f5'
}}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
}}
/>
{!imageLoaded && (
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center'
}}
>
<Typography variant="body2" color="textSecondary">
Chargement de l'image...
</Typography>
</Box>
)}
</Box>
{/* Informations sur l'élément */}
{metadata && (
<Paper sx={{ p: 2, borderRadius: 0 }}>
<Typography variant="subtitle2" gutterBottom>
Informations sur l'Élément
</Typography>
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
<Box>
<Typography variant="caption" color="textSecondary">Type:</Typography>
<Typography variant="body2">{metadata.element_type}</Typography>
</Box>
<Box>
<Typography variant="caption" color="textSecondary">Position:</Typography>
<Typography variant="body2">{metadata.relative_position}</Typography>
</Box>
<Box>
<Typography variant="caption" color="textSecondary">Taille:</Typography>
<Typography variant="body2">
{boundingBox.width} × {boundingBox.height}px
</Typography>
</Box>
{metadata.text_content && (
<Box>
<Typography variant="caption" color="textSecondary">Texte:</Typography>
<Typography variant="body2">"{metadata.text_content}"</Typography>
</Box>
)}
</Box>
</Paper>
)}
</DialogContent>
</Dialog>
);
};
export default InteractivePreviewArea;

View File

@@ -0,0 +1,212 @@
/**
* Composant Raccourcis Clavier - Aide à l'accessibilité
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce composant affiche la liste des raccourcis clavier disponibles
* pour améliorer l'accessibilité et l'efficacité d'utilisation.
*/
import React from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
Box,
Divider,
} from '@mui/material';
import {
Keyboard as KeyboardIcon,
Close as CloseIcon,
} from '@mui/icons-material';
interface KeyboardShortcutsProps {
open: boolean;
onClose: () => void;
shortcuts?: Array<{
keys: string;
description: string;
}>;
}
// Raccourcis par défaut si aucun n'est fourni
const defaultShortcuts = [
// Navigation
{ category: 'Navigation', keys: 'Tab', description: 'Naviguer vers l\'étape suivante' },
{ category: 'Navigation', keys: 'Shift + Tab', description: 'Naviguer vers l\'étape précédente' },
{ category: 'Navigation', keys: '↑ ↓ ← →', description: 'Déplacer l\'étape sélectionnée' },
// Édition
{ category: 'Édition', keys: 'Suppr', description: 'Supprimer l\'étape sélectionnée' },
{ category: 'Édition', keys: 'Ctrl + C', description: 'Copier l\'étape sélectionnée' },
{ category: 'Édition', keys: 'Ctrl + V', description: 'Coller l\'étape copiée' },
{ category: 'Édition', keys: 'Ctrl + Z', description: 'Annuler la dernière action' },
{ category: 'Édition', keys: 'Ctrl + Y', description: 'Rétablir l\'action annulée' },
// Workflow
{ category: 'Workflow', keys: 'Ctrl + S', description: 'Sauvegarder le workflow' },
{ category: 'Workflow', keys: 'F5', description: 'Exécuter le workflow' },
{ category: 'Workflow', keys: 'Ctrl + Entrée', description: 'Exécuter le workflow (alternative)' },
{ category: 'Workflow', keys: 'Ctrl + A', description: 'Sélectionner toutes les étapes' },
// Affichage
{ category: 'Affichage', keys: 'Ctrl + +', description: 'Zoomer' },
{ category: 'Affichage', keys: 'Ctrl + -', description: 'Dézoomer' },
{ category: 'Affichage', keys: 'Ctrl + 0', description: 'Ajuster le zoom' },
// Aide
{ category: 'Aide', keys: 'Échap', description: 'Annuler l\'action en cours' },
{ category: 'Aide', keys: 'F1', description: 'Afficher l\'aide' },
{ category: 'Aide', keys: 'Shift + ?', description: 'Afficher les raccourcis clavier' },
];
/**
* Composant Raccourcis Clavier
*/
const KeyboardShortcuts: React.FC<KeyboardShortcutsProps> = ({
open,
onClose,
shortcuts = defaultShortcuts,
}) => {
// Organiser les raccourcis par catégorie
const shortcutsByCategory = shortcuts.reduce((acc, shortcut) => {
const category = 'category' in shortcut ? (shortcut as any).category : 'Général';
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(shortcut);
return acc;
}, {} as Record<string, typeof shortcuts>);
// Fonction pour formater les touches
const formatKeys = (keys: string) => {
return keys.split(' + ').map((key, index, array) => (
<React.Fragment key={key}>
<Chip
label={key}
size="small"
variant="outlined"
sx={{
fontFamily: 'monospace',
fontSize: '0.75rem',
height: 24,
backgroundColor: '#f5f5f5',
border: '1px solid #ccc',
}}
/>
{index < array.length - 1 && (
<Typography
component="span"
sx={{ mx: 0.5, color: 'text.secondary' }}
>
+
</Typography>
)}
</React.Fragment>
));
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
aria-labelledby="keyboard-shortcuts-title"
>
<DialogTitle id="keyboard-shortcuts-title">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<KeyboardIcon />
<Typography variant="h6">
Raccourcis Clavier
</Typography>
</Box>
</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" paragraph>
Utilisez ces raccourcis clavier pour naviguer et travailler plus efficacement
dans le Visual Workflow Builder.
</Typography>
{Object.entries(shortcutsByCategory).map(([category, categoryShortcuts], categoryIndex) => (
<Box key={category} sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom color="primary">
{category}
</Typography>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 'bold', width: '30%' }}>
Raccourci
</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>
Description
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{categoryShortcuts.map((shortcut, index) => (
<TableRow key={index} hover>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{formatKeys(shortcut.keys)}
</Box>
</TableCell>
<TableCell>
<Typography variant="body2">
{shortcut.description}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{categoryIndex < Object.keys(shortcutsByCategory).length - 1 && (
<Divider sx={{ mt: 2 }} />
)}
</Box>
))}
<Box sx={{ mt: 3, p: 2, backgroundColor: '#f8f9fa', borderRadius: 1 }}>
<Typography variant="subtitle2" gutterBottom>
💡 Conseils d'accessibilité
</Typography>
<Typography variant="body2" color="text.secondary">
• Les raccourcis fonctionnent uniquement quand aucun champ de saisie n'est actif<br />
Utilisez Tab pour naviguer entre les éléments de l'interface<br />
Appuyez sur Échap pour annuler une action en cours<br />
F1 ou Shift+? affiche cette aide à tout moment
</Typography>
</Box>
</DialogContent>
<DialogActions>
<Button
onClick={onClose}
startIcon={<CloseIcon />}
variant="contained"
autoFocus
>
Fermer
</Button>
</DialogActions>
</Dialog>
);
};
export default KeyboardShortcuts;

View File

@@ -0,0 +1,277 @@
/**
* Composant CatalogActionItem - Affichage spécialisé pour les actions du catalogue VisionOnly
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
*
* Ce composant gère l'affichage d'une action du catalogue dans la Palette VWB,
* avec indicateurs visuels spécialisés, tooltips enrichis, et drag & drop optimisé.
*/
import React, { memo } from 'react';
import {
ListItem,
ListItemIcon,
ListItemText,
Tooltip,
Box,
Typography,
Chip,
Badge,
} from '@mui/material';
import {
Visibility as VisionIcon,
Speed as PerformanceIcon,
CheckCircle as ValidatedIcon,
} from '@mui/icons-material';
// Import des types
import { StepTemplate } from '../../types';
import { VWBCatalogAction } from '../../types/catalog';
interface CatalogActionItemProps {
/** Template de l'étape (converti depuis l'action du catalogue) */
stepTemplate: StepTemplate;
/** Action originale du catalogue (pour métadonnées enrichies) */
catalogAction?: VWBCatalogAction;
/** Gestionnaire de début de drag */
onDragStart: (event: React.DragEvent, stepTemplate: StepTemplate) => void;
/** Indique si l'action est mise en évidence par la recherche */
isHighlighted?: boolean;
}
/**
* Composant pour afficher une action du catalogue dans la Palette
*/
const CatalogActionItem: React.FC<CatalogActionItemProps> = ({
stepTemplate,
catalogAction,
onDragStart,
isHighlighted = false,
}) => {
// Déterminer les indicateurs de qualité
const hasExamples = catalogAction?.examples && catalogAction.examples.length > 0;
const hasDocumentation = catalogAction?.documentation && catalogAction.documentation.length > 0;
const complexityLevel = catalogAction?.metadata?.complexity || 'simple';
// Couleurs selon la complexité
const complexityColors = {
simple: '#4caf50', // Vert
intermediate: '#ff9800', // Orange
advanced: '#f44336' // Rouge
};
// Icônes selon la catégorie
const getCategoryIcon = (category: string) => {
switch (category) {
case 'vision_ui': return '🖱️';
case 'control': return '⏳';
case 'data': return '📊';
case 'navigation': return '🧭';
case 'validation': return '✅';
default: return '📋';
}
};
// Construire le contenu du tooltip enrichi
const tooltipContent = (
<Box sx={{ maxWidth: 300 }}>
{/* En-tête avec nom et badges */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="subtitle2" sx={{ flex: 1 }}>
{stepTemplate.name}
</Typography>
<Chip
label="VisionOnly"
size="small"
color="primary"
sx={{ fontSize: '0.7em' }}
/>
</Box>
{/* Description */}
<Typography variant="body2" sx={{ mb: 1.5 }}>
{stepTemplate.description}
</Typography>
{/* Paramètres requis */}
{stepTemplate.requiredParameters.length > 0 && (
<Box sx={{ mb: 1 }}>
<Typography variant="caption" color="inherit" sx={{ fontWeight: 'bold' }}>
Paramètres requis :
</Typography>
<Typography variant="caption" color="inherit" sx={{ display: 'block' }}>
{stepTemplate.requiredParameters.join(', ')}
</Typography>
</Box>
)}
{/* Exemple d'utilisation */}
{hasExamples && catalogAction?.examples[0] && (
<Box sx={{ mb: 1 }}>
<Typography variant="caption" color="inherit" sx={{ fontWeight: 'bold' }}>
Exemple :
</Typography>
<Typography variant="caption" color="inherit" sx={{ display: 'block' }}>
{catalogAction.examples[0].description}
</Typography>
</Box>
)}
{/* Complexité */}
{catalogAction?.metadata?.complexity && (
<Box sx={{ mb: 1 }}>
<Typography variant="caption" color="inherit" sx={{ fontWeight: 'bold' }}>
Complexité :
</Typography>
<Chip
label={complexityLevel}
size="small"
sx={{
ml: 0.5,
fontSize: '0.65em',
height: 16,
backgroundColor: complexityColors[complexityLevel],
color: 'white'
}}
/>
</Box>
)}
{/* Durée estimée */}
{catalogAction?.metadata?.estimatedDuration && (
<Box sx={{ mb: 1 }}>
<Typography variant="caption" color="inherit">
Durée estimée : {Math.round(catalogAction.metadata.estimatedDuration / 1000)}s
</Typography>
</Box>
)}
{/* Indicateur de reconnaissance visuelle */}
<Typography variant="caption" color="inherit" sx={{
display: 'block',
mt: 1,
fontStyle: 'italic',
backgroundColor: 'rgba(33, 150, 243, 0.1)',
padding: '4px 8px',
borderRadius: '4px'
}}>
🎯 Action avec reconnaissance visuelle automatique
</Typography>
</Box>
);
return (
<Tooltip
title={tooltipContent}
placement="right"
arrow
enterDelay={300}
leaveDelay={200}
>
<ListItem
draggable
onDragStart={(e) => onDragStart(e, stepTemplate)}
sx={{
cursor: 'grab',
backgroundColor: isHighlighted ? '#e8f4fd' : '#f0f4ff',
borderLeft: '3px solid #2196f3',
borderRadius: '0 4px 4px 0',
mb: 0.5,
'&:hover': {
backgroundColor: isHighlighted ? '#d1e7dd' : '#e3f2fd',
transform: 'translateX(2px)',
transition: 'all 0.2s ease-in-out',
},
'&:active': {
cursor: 'grabbing',
transform: 'translateX(1px) scale(0.98)',
},
}}
>
{/* Icône principale avec badge de catégorie */}
<ListItemIcon sx={{ minWidth: 40 }}>
<Badge
badgeContent={getCategoryIcon(catalogAction?.category || 'vision_ui')}
sx={{
'& .MuiBadge-badge': {
fontSize: '0.6em',
minWidth: 16,
height: 16,
backgroundColor: 'transparent',
}
}}
>
<Typography sx={{ fontSize: '1.2em' }}>
{stepTemplate.icon}
</Typography>
</Badge>
</ListItemIcon>
{/* Contenu principal */}
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="body2" noWrap sx={{ flex: 1, fontWeight: 500 }}>
{stepTemplate.name}
</Typography>
{/* Indicateurs de qualité */}
<Box sx={{ display: 'flex', gap: 0.25 }}>
{hasExamples && (
<Tooltip title="Exemples disponibles">
<ValidatedIcon sx={{ fontSize: 12, color: '#4caf50' }} />
</Tooltip>
)}
{hasDocumentation && (
<Tooltip title="Documentation complète">
<VisionIcon sx={{ fontSize: 12, color: '#2196f3' }} />
</Tooltip>
)}
{catalogAction?.metadata?.estimatedDuration && catalogAction.metadata.estimatedDuration < 2000 && (
<Tooltip title="Exécution rapide">
<PerformanceIcon sx={{ fontSize: 12, color: '#ff9800' }} />
</Tooltip>
)}
</Box>
{/* Label VISION */}
<Typography
variant="caption"
sx={{
color: '#2196f3',
fontWeight: 'bold',
fontSize: '0.65em',
backgroundColor: 'rgba(33, 150, 243, 0.1)',
padding: '1px 4px',
borderRadius: '2px'
}}
>
VISION
</Typography>
</Box>
}
secondary={
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{ fontSize: '0.7em' }}
>
{stepTemplate.description}
</Typography>
}
/>
</ListItem>
</Tooltip>
);
};
// Mémorisation pour optimiser les performances
export default memo(CatalogActionItem, (prevProps, nextProps) => {
return (
prevProps.stepTemplate.id === nextProps.stepTemplate.id &&
prevProps.isHighlighted === nextProps.isHighlighted &&
JSON.stringify(prevProps.catalogAction) === JSON.stringify(nextProps.catalogAction)
);
});

View File

@@ -0,0 +1,740 @@
/**
* Composant Palette d'Étapes Étendue - Boîte à outils avec catégories françaises et actions VisionOnly
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
*
* Ce composant affiche les types d'étapes disponibles organisés en catégories françaises,
* avec recherche débouncée, tooltips explicatifs, support du drag-and-drop optimisé,
* et intégration complète du catalogue d'actions VisionOnly.
*
* NOUVELLES FONCTIONNALITÉS:
* - Intégration du catalogue d'actions VisionOnly
* - Catégories dynamiques depuis l'API catalogue
* - Actions Vision UI, Contrôle, Données du catalogue
* - Recherche unifiée (actions par défaut + catalogue)
* - Indicateurs de statut du service catalogue
* - Tooltips enrichis avec exemples d'actions
*/
import React, { useState, useMemo, useCallback, memo, useEffect } from 'react';
import {
Box,
Typography,
TextField,
Accordion,
AccordionSummary,
AccordionDetails,
List,
ListItem,
ListItemIcon,
ListItemText,
Tooltip,
InputAdornment,
Chip,
Alert,
CircularProgress,
Badge,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
Search as SearchIcon,
CloudOff as OfflineIcon,
CheckCircle as OnlineIcon,
} from '@mui/icons-material';
// Import des types partagés
import { PaletteProps, StepCategory, StepTemplate } from '../../types';
// Import des types du catalogue
import {
VWBCatalogAction,
VWBActionCategory,
VWBActionCategoryInfo
} from '../../types/catalog';
// Import du service catalogue
import { catalogService } from '../../services/catalogService';
// Import des hooks d'optimisation
import { useDebounce } from '../../hooks/useDebounce';
// Import des tooltips
import { getTooltip } from '../../utils/tooltips';
// Import du hook de gestion du catalogue
import { useCatalogActions } from '../../hooks/useCatalogActions';
// Catégories par défaut désactivées - Mode 100% Visuel uniquement
// Les actions legacy basées sur sélecteurs CSS ont été supprimées
// Seules les actions VisionOnly du catalogue sont disponibles
const getDefaultCategories = (): StepCategory[] => [];
// Mapping des catégories du catalogue vers les métadonnées d'affichage (100% visuel)
const getCatalogCategoryMetadata = (categoryId: VWBActionCategory): {
name: string;
description: string;
icon: string;
color: string;
} => {
const metadata: Record<VWBActionCategory, { name: string; description: string; icon: string; color: string }> = {
vision_ui: {
name: 'Interactions Visuelles',
description: 'Cliquer, saisir, glisser-déposer sur des éléments visuels',
icon: '🖱️',
color: '#2196f3',
},
control: {
name: 'Contrôle de Flux',
description: 'Conditions, boucles et synchronisation basées sur la vision',
icon: '🔀',
color: '#ff9800',
},
data: {
name: 'Extraction de Données',
description: 'Extraire texte, tableaux, télécharger des fichiers',
icon: '📊',
color: '#4caf50',
},
intelligence: {
name: 'Intelligence IA',
description: 'Analyse et traitement intelligent par IA',
icon: '🤖',
color: '#9c27b0',
},
database: {
name: 'Base de Données',
description: 'Lire et enregistrer des données en base',
icon: '💾',
color: '#00bcd4',
},
validation: {
name: 'Validation',
description: 'Vérifier la présence et le contenu des éléments',
icon: '✅',
color: '#f44336',
},
};
return metadata[categoryId] || {
name: categoryId,
description: `Actions de type ${categoryId}`,
icon: '📋',
color: '#757575',
};
};
// Convertir une action du catalogue en StepTemplate pour compatibilité
const convertCatalogActionToStepTemplate = (action: VWBCatalogAction): StepTemplate => {
// Extraire les paramètres requis
const requiredParameters = Object.entries(action.parameters)
.filter(([_, param]) => param.required)
.map(([name, _]) => name);
// Extraire les paramètres par défaut
const defaultParameters = Object.entries(action.parameters)
.filter(([_, param]) => param.default !== undefined)
.reduce((acc, [name, param]) => {
acc[name] = param.default;
return acc;
}, {} as Record<string, any>);
return {
id: action.id,
type: action.id as any, // Utiliser l'ID comme type pour les actions du catalogue
name: action.name,
description: action.description,
icon: action.icon,
defaultParameters,
requiredParameters,
};
};
// Interface pour l'état du catalogue
interface CatalogState {
actions: VWBCatalogAction[];
categories: Array<{
id: VWBActionCategory;
name: string;
description: string;
icon: string;
actionCount: number;
}>;
isLoading: boolean;
isOnline: boolean;
error: string | null;
lastUpdate: Date | null;
}
/**
* Composant Palette pour la sélection d'étapes avec intégration du catalogue VisionOnly
*/
const Palette: React.FC<PaletteProps> = ({
categories,
searchTerm,
onSearch,
onStepDrag,
}) => {
// Utiliser les catégories par défaut si aucune n'est fournie
const effectiveCategories = categories.length > 0 ? categories : getDefaultCategories();
const [expandedCategories, setExpandedCategories] = useState<string[]>([
'actions-web',
'vision_ui', // Étendre automatiquement les actions Vision UI
]);
// Utiliser le hook de gestion du catalogue
const {
state: catalogHookState,
filteredActions: catalogActions,
actions: catalogActionMethods,
} = useCatalogActions({
autoLoad: true,
refreshInterval: 5 * 60 * 1000, // 5 minutes
});
// Utiliser l'état du hook comme état principal du catalogue
const catalogState = useMemo(() => ({
actions: catalogHookState.actions,
categories: catalogHookState.categories.map(cat => ({
id: cat.id,
name: cat.name,
description: cat.description,
icon: cat.icon,
actionCount: cat.actionCount,
})),
isLoading: catalogHookState.isLoading,
isOnline: catalogHookState.isOnline,
error: catalogHookState.error,
lastUpdate: catalogHookState.lastUpdate,
}), [catalogHookState]);
// Debouncing de la recherche pour optimiser les performances
const debouncedSearchTerm = useDebounce(searchTerm, 300);
// Gestionnaire d'événements clavier pour la palette
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
switch (event.key) {
case 'Enter':
case ' ':
// Activer l'élément focalisé
event.preventDefault();
break;
case 'ArrowUp':
case 'ArrowDown':
// Navigation verticale dans la liste
event.preventDefault();
break;
case 'Escape':
// Fermer les catégories étendues
event.preventDefault();
setExpandedCategories([]);
break;
}
}, []);
// Convertir les actions du catalogue en catégories compatibles
const catalogCategories = useMemo((): StepCategory[] => {
if (catalogState.actions.length === 0) return [];
// Grouper les actions par catégorie
const actionsByCategory = catalogState.actions.reduce((acc, action) => {
if (!acc[action.category]) {
acc[action.category] = [];
}
acc[action.category].push(action);
return acc;
}, {} as Record<VWBActionCategory, VWBCatalogAction[]>);
// Créer les catégories avec leurs actions
return Object.entries(actionsByCategory).map(([categoryId, actions]) => {
const metadata = getCatalogCategoryMetadata(categoryId as VWBActionCategory);
return {
id: `catalog_${categoryId}`,
name: metadata.name,
description: metadata.description,
icon: metadata.icon,
steps: actions.map(convertCatalogActionToStepTemplate),
};
});
}, [catalogState.actions]);
// Combiner les catégories par défaut et du catalogue
const allCategories = useMemo(() => {
return [...effectiveCategories, ...catalogCategories];
}, [effectiveCategories, catalogCategories]);
// Filtrage des étapes selon le terme de recherche débouncé
const filteredCategories = useMemo(() => {
if (!debouncedSearchTerm.trim()) {
return allCategories;
}
const searchLower = debouncedSearchTerm.toLowerCase();
return allCategories
.map((category) => ({
...category,
steps: category.steps.filter((step) =>
step.name.toLowerCase().includes(searchLower) ||
step.description.toLowerCase().includes(searchLower) ||
step.type.toLowerCase().includes(searchLower)
),
}))
.filter((category) => category.steps.length > 0);
}, [allCategories, debouncedSearchTerm]);
// Gestionnaire d'expansion des catégories
const handleCategoryToggle = (categoryId: string) => {
setExpandedCategories((prev) =>
prev.includes(categoryId)
? prev.filter((id) => id !== categoryId)
: [...prev, categoryId]
);
};
// Gestionnaire de début de drag
const handleDragStart = (event: React.DragEvent, stepTemplate: StepTemplate) => {
// Pour les actions du catalogue, utiliser un format spécial
const dragData = stepTemplate.id.startsWith('catalog_')
? `catalog:${stepTemplate.type}`
: stepTemplate.type;
event.dataTransfer.setData('application/reactflow', dragData);
event.dataTransfer.effectAllowed = 'move';
onStepDrag(stepTemplate);
};
// Gestionnaire de rechargement du catalogue
const handleReloadCatalog = useCallback(async () => {
await catalogActionMethods.reload();
}, [catalogActionMethods]);
return (
<Box
sx={{
width: 280,
height: '100%',
backgroundColor: '#ffffff',
borderRight: '1px solid #e0e0e0',
display: 'flex',
flexDirection: 'column',
}}
role="complementary"
aria-label="Palette d'étapes disponibles avec actions VisionOnly"
onKeyDown={handleKeyDown}
tabIndex={0}
>
{/* En-tête avec titre et indicateur de statut */}
<Box sx={{ p: 2, borderBottom: '1px solid #e0e0e0' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6" component="h2">
Palette d'Étapes
</Typography>
{/* Indicateur de statut du catalogue étendu */}
<Tooltip
title={
<Box>
<Typography variant="subtitle2" gutterBottom>
{catalogHookState.mode === 'dynamic' ? '🌐 Mode Dynamique' :
catalogHookState.mode === 'static' ? '📦 Mode Statique' :
'🔴 Mode Hors Ligne'}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{catalogHookState.mode === 'dynamic'
? `Connecté au service catalogue - ${catalogState.actions.length} actions disponibles`
: catalogHookState.mode === 'static'
? `Catalogue de secours actif - ${catalogState.actions.length} actions de base`
: 'Service catalogue indisponible'}
</Typography>
{catalogHookState.serviceUrl && (
<Typography variant="caption" color="inherit" sx={{ display: 'block', mb: 0.5 }}>
URL: {catalogHookState.serviceUrl}
</Typography>
)}
{catalogHookState.error && (
<Typography variant="caption" color="error" sx={{ display: 'block', mb: 0.5 }}>
Erreur: {catalogHookState.error}
</Typography>
)}
<Typography variant="caption" color="inherit" sx={{ fontStyle: 'italic' }}>
{catalogHookState.mode === 'dynamic'
? 'Cliquez pour forcer une re-détection'
: 'Cliquez pour réessayer la connexion'}
</Typography>
</Box>
}
placement="left"
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
cursor: 'pointer',
padding: '4px 8px',
borderRadius: '4px',
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.04)',
},
}}
onClick={async () => {
if (catalogHookState.mode === 'dynamic') {
await catalogActionMethods.forceUrlDetection();
} else {
await catalogActionMethods.reload();
}
}}
>
{catalogState.isLoading ? (
<CircularProgress size={16} />
) : (
<>
{/* Icône selon le mode */}
{catalogHookState.mode === 'dynamic' ? (
<Badge badgeContent={catalogState.actions.length} color="primary" max={99}>
<OnlineIcon color="success" fontSize="small" />
</Badge>
) : catalogHookState.mode === 'static' ? (
<Badge badgeContent={catalogState.actions.length} color="warning" max={99}>
<Box sx={{ fontSize: '16px' }}>📦</Box>
</Badge>
) : (
<OfflineIcon color="disabled" fontSize="small" />
)}
{/* Texte du mode */}
<Typography
variant="caption"
sx={{
fontSize: '0.7em',
fontWeight: 'bold',
color: catalogHookState.mode === 'dynamic' ? 'success.main' :
catalogHookState.mode === 'static' ? 'warning.main' :
'text.disabled'
}}
>
{catalogHookState.mode === 'dynamic' ? 'LIVE' :
catalogHookState.mode === 'static' ? 'LOCAL' :
'OFF'}
</Typography>
</>
)}
</Box>
</Tooltip>
</Box>
{/* Champ de recherche */}
<TextField
fullWidth
size="small"
placeholder="Rechercher une étape..."
value={searchTerm}
onChange={(e) => onSearch(e.target.value)}
aria-label="Rechercher dans les étapes disponibles"
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
},
}}
/>
{/* Statistiques du catalogue avec mode */}
<Box sx={{ mt: 1, display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip
label={`${effectiveCategories.length} catégories par défaut`}
size="small"
variant="outlined"
color="default"
/>
{catalogState.actions.length > 0 && (
<Chip
label={`${catalogState.actions.length} actions ${catalogHookState.mode === 'static' ? 'locales' : 'VisionOnly'}`}
size="small"
variant="outlined"
color={catalogHookState.mode === 'dynamic' ? 'primary' : 'warning'}
/>
)}
{/* Bouton de réinitialisation pour les cas problématiques */}
{(catalogHookState.error || catalogHookState.mode === 'offline') && (
<Tooltip title="Réinitialiser complètement le service catalogue">
<Chip
label="🔄 Reset"
size="small"
variant="outlined"
color="error"
clickable
onClick={async () => {
await catalogActionMethods.resetService();
}}
sx={{ fontSize: '0.7em' }}
/>
</Tooltip>
)}
</Box>
{/* Message d'erreur du catalogue avec actions */}
{catalogState.error && !catalogState.isLoading && (
<Alert
severity={catalogHookState.mode === 'static' ? 'info' : 'warning'}
sx={{ mt: 1 }}
action={
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Typography
variant="caption"
sx={{ cursor: 'pointer', textDecoration: 'underline' }}
onClick={handleReloadCatalog}
>
Recharger
</Typography>
{catalogHookState.mode !== 'static' && (
<Typography
variant="caption"
sx={{ cursor: 'pointer', textDecoration: 'underline' }}
onClick={async () => {
await catalogActionMethods.forceUrlDetection();
}}
>
Re-détecter
</Typography>
)}
</Box>
}
>
{catalogHookState.mode === 'static'
? 'Mode local actif - Actions de base disponibles'
: 'Service catalogue indisponible - Mode local activé'
}
</Alert>
)}
</Box>
{/* Liste des catégories et étapes */}
<Box sx={{ flex: 1, overflow: 'auto' }}>
{filteredCategories.map((category) => {
const isCatalogCategory = category.id.startsWith('catalog_');
return (
<Accordion
key={category.id}
expanded={expandedCategories.includes(category.id)}
onChange={() => handleCategoryToggle(category.id)}
disableGutters
elevation={0}
sx={{
'&:before': { display: 'none' },
borderBottom: '1px solid #f0f0f0',
backgroundColor: isCatalogCategory ? '#f8f9ff' : 'inherit',
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
minHeight: 48,
'& .MuiAccordionSummary-content': {
alignItems: 'center',
},
}}
>
<Tooltip
title={
<Box>
<Typography variant="subtitle2" gutterBottom>
{category.name}
{isCatalogCategory && (
<Chip
label="VisionOnly"
size="small"
color="primary"
sx={{ ml: 1, fontSize: '0.7em' }}
/>
)}
</Typography>
<Typography variant="body2">
{category.description}
</Typography>
{getTooltip('category', category.id) && (
<Typography variant="caption" color="inherit" sx={{ mt: 1, display: 'block' }}>
Exemple : {getTooltip('category', category.id)?.example}
</Typography>
)}
</Box>
}
placement="right"
arrow
enterDelay={700}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%' }}>
<Typography sx={{ fontSize: '1.2em' }}>{category.icon}</Typography>
<Typography variant="subtitle2" sx={{ flex: 1 }}>
{category.name}
</Typography>
<Typography variant="caption" color="text.secondary">
({category.steps.length})
</Typography>
{isCatalogCategory && (
<Chip
label="Vision"
size="small"
color="primary"
variant="outlined"
sx={{ fontSize: '0.65em', height: 18 }}
/>
)}
</Box>
</Tooltip>
</AccordionSummary>
<AccordionDetails sx={{ p: 0 }}>
<List dense>
{category.steps.map((step) => {
const isFromCatalog = category.id.startsWith('catalog_');
return (
<Tooltip
key={step.id}
title={
<Box>
<Typography variant="subtitle2" gutterBottom>
{step.name}
{isFromCatalog && (
<Chip
label="VisionOnly"
size="small"
color="primary"
sx={{ ml: 1, fontSize: '0.7em' }}
/>
)}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{step.description}
</Typography>
{step.requiredParameters.length > 0 && (
<Typography variant="caption" color="inherit" sx={{ display: 'block', mb: 0.5 }}>
Paramètres requis : {step.requiredParameters.join(', ')}
</Typography>
)}
{getTooltip('step', step.type) && (
<>
<Typography variant="caption" color="inherit">
{getTooltip('step', step.type)?.example}
</Typography>
<br />
<Typography variant="caption" color="inherit" fontStyle="italic">
{getTooltip('step', step.type)?.shortcut}
</Typography>
</>
)}
{isFromCatalog && (
<Typography variant="caption" color="inherit" sx={{ display: 'block', mt: 1, fontStyle: 'italic' }}>
🎯 Action avec reconnaissance visuelle automatique
</Typography>
)}
</Box>
}
placement="right"
arrow
enterDelay={500}
leaveDelay={200}
>
<ListItem
draggable
onDragStart={(e) => handleDragStart(e, step)}
sx={{
cursor: 'grab',
backgroundColor: isFromCatalog ? '#f0f4ff' : 'inherit',
'&:hover': {
backgroundColor: isFromCatalog ? '#e3f2fd' : '#f5f5f5',
},
'&:active': {
cursor: 'grabbing',
},
borderLeft: isFromCatalog ? '3px solid #2196f3' : 'none',
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<Typography sx={{ fontSize: '1.1em' }}>{step.icon}</Typography>
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="body2" noWrap sx={{ flex: 1 }}>
{step.name}
</Typography>
{isFromCatalog && (
<Typography
variant="caption"
sx={{
color: '#2196f3',
fontWeight: 'bold',
fontSize: '0.65em'
}}
>
VISION
</Typography>
)}
</Box>
}
/>
</ListItem>
</Tooltip>
);
})}
</List>
</AccordionDetails>
</Accordion>
);
})}
</Box>
{/* Message si aucun résultat */}
{filteredCategories.length === 0 && !catalogState.isLoading && (
<Box sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Aucune étape trouvée pour "{searchTerm}"
</Typography>
{catalogState.error && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Le catalogue VisionOnly est hors ligne
</Typography>
)}
</Box>
)}
{/* Indicateur de chargement */}
{catalogState.isLoading && (
<Box sx={{ p: 2, textAlign: 'center' }}>
<CircularProgress size={24} />
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Chargement du catalogue VisionOnly...
</Typography>
</Box>
)}
{/* Pied de page avec informations */}
{catalogState.lastUpdate && (
<Box sx={{ p: 1, borderTop: '1px solid #f0f0f0', backgroundColor: '#fafafa' }}>
<Typography variant="caption" color="text.secondary" align="center" display="block">
Dernière mise à jour : {catalogState.lastUpdate.toLocaleTimeString('fr-FR')}
</Typography>
</Box>
)}
</Box>
);
};
// Mémorisation du composant Palette pour éviter les re-rendus inutiles
export default memo(Palette, (prevProps, nextProps) => {
// Comparaison personnalisée pour optimiser les re-rendus
return (
JSON.stringify(prevProps.categories) === JSON.stringify(nextProps.categories) &&
prevProps.searchTerm === nextProps.searchTerm &&
prevProps.onSearch === nextProps.onSearch &&
prevProps.onStepDrag === nextProps.onStepDrag
);
});

View File

@@ -0,0 +1,509 @@
/**
* Composant de Test Debug - Propriétés d'Étapes
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*
* Ce composant teste et affiche les informations de débogage pour
* diagnostiquer le problème des propriétés d'étapes vides.
*/
import React, { useState, useCallback, useMemo } from 'react';
import {
Box,
Typography,
Button,
Card,
CardContent,
Alert,
Accordion,
AccordionSummary,
AccordionDetails,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
BugReport as BugIcon,
CheckCircle as CheckIcon,
Error as ErrorIcon,
} from '@mui/icons-material';
// Import des types
import { Step, StepType, StepExecutionState } from '../types';
// Import des hooks VWB
import { useVWBStepIntegration, useIsVWBStep, useVWBActionId } from '../hooks/useVWBStepIntegration';
// Configuration des paramètres (copie de PropertiesPanel)
interface ParameterConfig {
name: string;
label: string;
type: 'text' | 'number' | 'boolean' | 'select' | 'visual';
required?: boolean;
description?: string;
supportVariables?: boolean;
options?: { value: string; label: string }[];
defaultValue?: any;
min?: number;
max?: number;
}
const stepParametersConfig: Record<StepType, ParameterConfig[]> = {
click: [
{
name: 'target',
label: 'Élément cible',
type: 'visual',
required: true,
description: 'Sélectionner l\'élément à cliquer',
},
{
name: 'clickType',
label: 'Type de clic',
type: 'select',
options: [
{ value: 'left', label: 'Clic gauche' },
{ value: 'right', label: 'Clic droit' },
{ value: 'double', label: 'Double-clic' },
],
defaultValue: 'left',
},
],
type: [
{
name: 'target',
label: 'Champ de saisie',
type: 'visual',
required: true,
description: 'Sélectionner le champ où saisir le texte',
},
{
name: 'text',
label: 'Texte à saisir',
type: 'text',
required: true,
supportVariables: true,
},
{
name: 'clearFirst',
label: 'Vider le champ d\'abord',
type: 'boolean',
defaultValue: true,
},
],
wait: [
{
name: 'duration',
label: 'Durée (secondes)',
type: 'number',
required: true,
min: 0.1,
max: 60,
defaultValue: 1,
},
],
condition: [
{
name: 'condition',
label: 'Condition',
type: 'text',
required: true,
supportVariables: true,
description: 'Expression conditionnelle à évaluer',
},
],
extract: [
{
name: 'target',
label: 'Élément source',
type: 'visual',
required: true,
description: 'Sélectionner l\'élément dont extraire les données',
},
{
name: 'attribute',
label: 'Attribut à extraire',
type: 'select',
options: [
{ value: 'text', label: 'Texte' },
{ value: 'value', label: 'Valeur' },
{ value: 'href', label: 'Lien (href)' },
{ value: 'src', label: 'Source (src)' },
],
defaultValue: 'text',
},
],
scroll: [
{
name: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'up', label: 'Vers le haut' },
{ value: 'down', label: 'Vers le bas' },
{ value: 'left', label: 'Vers la gauche' },
{ value: 'right', label: 'Vers la droite' },
],
defaultValue: 'down',
},
{
name: 'amount',
label: 'Quantité (pixels)',
type: 'number',
defaultValue: 300,
min: 1,
},
],
navigate: [
{
name: 'url',
label: 'URL de destination',
type: 'text',
required: true,
supportVariables: true,
},
],
screenshot: [
{
name: 'filename',
label: 'Nom du fichier',
type: 'text',
supportVariables: true,
description: 'Nom du fichier de capture (optionnel)',
},
],
};
/**
* Composant de test pour déboguer les propriétés d'étapes
*/
const PropertiesDebugTest: React.FC = () => {
const [selectedStepType, setSelectedStepType] = useState<StepType>('click');
const [testResults, setTestResults] = useState<any[]>([]);
// Hooks VWB
const { methods: vwbMethods } = useVWBStepIntegration();
// Créer une étape de test
const createTestStep = useCallback((stepType: StepType): Step => {
return {
id: `test_step_${Date.now()}`,
type: stepType,
name: `Test ${stepType}`,
position: { x: 100, y: 100 },
data: {
label: `Test ${stepType}`,
stepType: stepType,
parameters: {},
},
executionState: StepExecutionState.IDLE,
validationErrors: [],
};
}, []);
// Tester la résolution des paramètres
const testParameterResolution = useCallback((stepType: StepType) => {
console.log(`🧪 Test de résolution pour le type: ${stepType}`);
const testStep = createTestStep(stepType);
// Test 1: Configuration directe
const directConfig = stepParametersConfig[stepType];
// Test 2: Fonction getParameterConfig simulée
const getParameterConfig = (step: Step): ParameterConfig[] => {
if (!step) return [];
console.log(`🔍 Recherche config pour type: "${step.type}"`);
console.log(`🔍 Clés disponibles:`, Object.keys(stepParametersConfig));
console.log(`🔍 Type exact match:`, stepParametersConfig[step.type] !== undefined);
return stepParametersConfig[step.type] || [];
};
const resolvedConfig = getParameterConfig(testStep);
// Test 3: Hooks VWB
const isVWBStep = useIsVWBStep(testStep);
const vwbActionId = useVWBActionId(testStep);
const result = {
stepType,
testStep,
directConfig: directConfig || null,
directConfigLength: directConfig ? directConfig.length : 0,
resolvedConfig,
resolvedConfigLength: resolvedConfig.length,
isVWBStep,
vwbActionId,
configExists: stepParametersConfig[stepType] !== undefined,
timestamp: new Date().toISOString(),
};
console.log(`✅ Résultat test ${stepType}:`, result);
setTestResults(prev => [...prev, result]);
return result;
}, [createTestStep]);
// Tester tous les types d'étapes
const testAllStepTypes = useCallback(() => {
console.log('🚀 Test de tous les types d\'étapes');
setTestResults([]);
const allTypes: StepType[] = ['click', 'type', 'wait', 'condition', 'extract', 'scroll', 'navigate', 'screenshot'];
allTypes.forEach(stepType => {
setTimeout(() => testParameterResolution(stepType), 100);
});
}, [testParameterResolution]);
// Analyser les résultats
const analysisResults = useMemo(() => {
if (testResults.length === 0) return null;
const totalTests = testResults.length;
const successfulResolutions = testResults.filter(r => r.resolvedConfigLength > 0).length;
const failedResolutions = testResults.filter(r => r.resolvedConfigLength === 0).length;
const vwbSteps = testResults.filter(r => r.isVWBStep).length;
return {
totalTests,
successfulResolutions,
failedResolutions,
vwbSteps,
successRate: (successfulResolutions / totalTests) * 100,
};
}, [testResults]);
return (
<Box sx={{ p: 3, maxWidth: 1200, margin: '0 auto' }}>
<Typography variant="h4" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<BugIcon color="primary" />
Test Debug - Propriétés d'Étapes
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Ce composant teste la résolution des propriétés d'étapes pour diagnostiquer
pourquoi les paramètres apparaissent vides dans l'interface.
</Typography>
{/* Contrôles de test */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Contrôles de Test
</Typography>
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<Button
variant="contained"
onClick={testAllStepTypes}
startIcon={<BugIcon />}
>
Tester Tous les Types
</Button>
<Button
variant="outlined"
onClick={() => setTestResults([])}
>
Vider les Résultats
</Button>
</Box>
<Typography variant="body2" color="text.secondary">
Cliquez sur "Tester Tous les Types" pour exécuter les tests de résolution
des paramètres pour chaque type d'étape.
</Typography>
</CardContent>
</Card>
{/* Analyse des résultats */}
{analysisResults && (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{analysisResults.successRate === 100 ? (
<CheckIcon color="success" />
) : (
<ErrorIcon color="error" />
)}
Analyse des Résultats
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mb: 2 }}>
<Chip
label={`Total: ${analysisResults.totalTests}`}
color="primary"
/>
<Chip
label={`Succès: ${analysisResults.successfulResolutions}`}
color="success"
/>
<Chip
label={`Échecs: ${analysisResults.failedResolutions}`}
color="error"
/>
<Chip
label={`VWB: ${analysisResults.vwbSteps}`}
color="info"
/>
<Chip
label={`Taux: ${analysisResults.successRate.toFixed(1)}%`}
color={analysisResults.successRate === 100 ? 'success' : 'warning'}
/>
</Box>
{analysisResults.failedResolutions > 0 && (
<Alert severity="error" sx={{ mt: 2 }}>
<Typography variant="body2">
{analysisResults.failedResolutions} type(s) d'étapes ne résolvent pas leurs paramètres correctement.
Cela explique pourquoi les propriétés apparaissent vides dans l'interface.
</Typography>
</Alert>
)}
</CardContent>
</Card>
)}
{/* Configuration disponible */}
<Accordion sx={{ mb: 3 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">
Configuration stepParametersConfig Disponible
</Typography>
</AccordionSummary>
<AccordionDetails>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Type d'Étape</TableCell>
<TableCell>Nombre de Paramètres</TableCell>
<TableCell>Paramètres</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(stepParametersConfig).map(([stepType, config]) => (
<TableRow key={stepType}>
<TableCell>
<Chip label={stepType} size="small" />
</TableCell>
<TableCell>{config.length}</TableCell>
<TableCell>
{config.map(param => param.name).join(', ')}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</AccordionDetails>
</Accordion>
{/* Résultats des tests */}
{testResults.length > 0 && (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">
Résultats Détaillés des Tests ({testResults.length})
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{testResults.map((result, index) => (
<Card key={index} variant="outlined">
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">
Test: {result.stepType}
</Typography>
<Chip
label={result.resolvedConfigLength > 0 ? 'SUCCÈS' : 'ÉCHEC'}
color={result.resolvedConfigLength > 0 ? 'success' : 'error'}
/>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 2 }}>
<Box>
<Typography variant="body2" color="text.secondary">
Configuration Directe
</Typography>
<Typography variant="body1">
{result.directConfigLength} paramètres
</Typography>
</Box>
<Box>
<Typography variant="body2" color="text.secondary">
Configuration Résolue
</Typography>
<Typography variant="body1">
{result.resolvedConfigLength} paramètres
</Typography>
</Box>
<Box>
<Typography variant="body2" color="text.secondary">
Est Action VWB
</Typography>
<Typography variant="body1">
{result.isVWBStep ? 'Oui' : 'Non'}
</Typography>
</Box>
<Box>
<Typography variant="body2" color="text.secondary">
Config Existe
</Typography>
<Typography variant="body1">
{result.configExists ? 'Oui' : 'Non'}
</Typography>
</Box>
</Box>
{result.resolvedConfigLength === 0 && result.configExists && (
<Alert severity="error" sx={{ mt: 2 }}>
<Typography variant="body2">
🚨 PROBLÈME IDENTIFIÉ: La configuration existe ({result.directConfigLength} paramètres)
mais la résolution retourne 0 paramètres. Cela indique un problème dans la logique
de résolution ou dans le mapping des types.
</Typography>
</Alert>
)}
<Accordion sx={{ mt: 2 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="body2">
Détails Techniques
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box component="pre" sx={{
fontSize: '0.75rem',
bgcolor: '#f5f5f5',
p: 1,
borderRadius: 1,
overflow: 'auto',
}}>
{JSON.stringify(result, null, 2)}
</Box>
</AccordionDetails>
</Accordion>
</CardContent>
</Card>
))}
</Box>
</AccordionDetails>
</Accordion>
)}
</Box>
);
};
export default PropertiesDebugTest;

View File

@@ -0,0 +1,468 @@
/**
* Composant EmptyStateMessage - Messages informatifs pour états vides
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*
* Ce composant affiche des messages informatifs et utiles lorsqu'aucune propriété
* n'est disponible, avec distinction entre différents types d'états vides.
*/
import React, { memo, useMemo } from 'react';
import {
Box,
Typography,
Alert,
Button,
List,
ListItem,
ListItemIcon,
ListItemText,
Chip,
Paper,
} from '@mui/material';
import {
Info as InfoIcon,
Warning as WarningIcon,
Error as ErrorIcon,
HelpOutline as HelpIcon,
Refresh as RefreshIcon,
Settings as SettingsIcon,
Lightbulb as LightbulbIcon,
Search as SearchIcon,
} from '@mui/icons-material';
/**
* Types de raisons pour l'état vide
*/
export type EmptyStateReason =
| 'no-parameters'
| 'loading-error'
| 'vwb-not-found'
| 'unknown-type'
| 'resolution-failed'
| 'catalog-unavailable'
| 'network-error';
/**
* Props du composant EmptyStateMessage
*/
export interface EmptyStateMessageProps {
stepType: string;
reason: EmptyStateReason;
error?: Error;
suggestions?: string[];
onRetry?: () => void;
onRefresh?: () => void;
onHelp?: () => void;
className?: string;
}
/**
* Configuration des messages par type de raison
*/
interface EmptyStateConfig {
severity: 'info' | 'warning' | 'error';
icon: React.ReactNode;
title: string;
description: string;
actionLabel?: string;
showSuggestions: boolean;
defaultSuggestions: string[];
}
/**
* Configurations des états vides
*/
const EMPTY_STATE_CONFIGS: Record<EmptyStateReason, EmptyStateConfig> = {
'no-parameters': {
severity: 'info',
icon: <InfoIcon />,
title: 'Aucun paramètre configurable',
description: 'Cette étape fonctionne sans paramètres supplémentaires.',
showSuggestions: false,
defaultSuggestions: []
},
'loading-error': {
severity: 'error',
icon: <ErrorIcon />,
title: 'Erreur de chargement',
description: 'Impossible de charger les propriétés de cette étape.',
actionLabel: 'Réessayer',
showSuggestions: true,
defaultSuggestions: [
'Vérifiez votre connexion réseau',
'Actualisez la page',
'Contactez le support si le problème persiste'
]
},
'vwb-not-found': {
severity: 'warning',
icon: <WarningIcon />,
title: 'Action VWB non trouvée',
description: 'Cette action du catalogue Vision-Only RPA n\'est pas disponible.',
actionLabel: 'Actualiser le catalogue',
showSuggestions: true,
defaultSuggestions: [
'Utilisez une action standard équivalente',
'Vérifiez que le catalogue VWB est à jour',
'Contactez l\'administrateur pour ajouter cette action'
]
},
'unknown-type': {
severity: 'warning',
icon: <HelpIcon />,
title: 'Type d\'étape non reconnu',
description: 'Ce type d\'étape n\'est pas pris en charge par l\'éditeur.',
showSuggestions: true,
defaultSuggestions: [
'Vérifiez l\'orthographe du type d\'étape',
'Utilisez un type d\'étape standard',
'Consultez la documentation des types supportés'
]
},
'resolution-failed': {
severity: 'error',
icon: <ErrorIcon />,
title: 'Échec de résolution',
description: 'Impossible de déterminer les propriétés de cette étape.',
actionLabel: 'Réessayer',
showSuggestions: true,
defaultSuggestions: [
'Vérifiez la configuration de l\'étape',
'Redémarrez l\'éditeur de workflow',
'Vérifiez les logs pour plus de détails'
]
},
'catalog-unavailable': {
severity: 'warning',
icon: <WarningIcon />,
title: 'Catalogue indisponible',
description: 'Le catalogue d\'actions n\'est pas accessible actuellement.',
actionLabel: 'Actualiser',
showSuggestions: true,
defaultSuggestions: [
'Vérifiez la connexion au serveur',
'Utilisez des actions standard en attendant',
'Contactez l\'administrateur système'
]
},
'network-error': {
severity: 'error',
icon: <ErrorIcon />,
title: 'Erreur réseau',
description: 'Impossible de se connecter au serveur pour charger les propriétés.',
actionLabel: 'Réessayer',
showSuggestions: true,
defaultSuggestions: [
'Vérifiez votre connexion internet',
'Vérifiez que le serveur est accessible',
'Réessayez dans quelques instants'
]
}
};
/**
* Composant EmptyStateMessage
*/
const EmptyStateMessage: React.FC<EmptyStateMessageProps> = ({
stepType,
reason,
error,
suggestions = [],
onRetry,
onRefresh,
onHelp,
className
}) => {
// Configuration pour la raison donnée
const config = useMemo(() => {
return EMPTY_STATE_CONFIGS[reason] || EMPTY_STATE_CONFIGS['unknown-type'];
}, [reason]);
// Suggestions finales (personnalisées + par défaut)
const finalSuggestions = useMemo(() => {
const customSuggestions = suggestions.length > 0 ? suggestions : [];
const defaultSuggestions = config.showSuggestions ? config.defaultSuggestions : [];
return [...customSuggestions, ...defaultSuggestions];
}, [suggestions, config]);
// Gestionnaire d'action principale
const handlePrimaryAction = () => {
if (reason === 'loading-error' || reason === 'resolution-failed' || reason === 'network-error') {
onRetry?.();
} else if (reason === 'vwb-not-found' || reason === 'catalog-unavailable') {
onRefresh?.();
}
};
// Rendu des détails d'erreur
const renderErrorDetails = () => {
if (!error) return null;
return (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary" gutterBottom>
Détails de l'erreur :
</Typography>
<Paper
variant="outlined"
sx={{
p: 1,
backgroundColor: 'grey.50',
fontFamily: 'monospace',
fontSize: '0.75rem',
maxHeight: 100,
overflow: 'auto'
}}
>
{error.message}
</Paper>
</Box>
);
};
// Rendu des suggestions
const renderSuggestions = () => {
if (finalSuggestions.length === 0) return null;
return (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom sx={{ display: 'flex', alignItems: 'center' }}>
<LightbulbIcon sx={{ fontSize: 16, mr: 0.5 }} />
Suggestions
</Typography>
<List dense>
{finalSuggestions.map((suggestion, index) => (
<ListItem key={index} sx={{ py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 24 }}>
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
backgroundColor: 'primary.main'
}}
/>
</ListItemIcon>
<ListItemText
primary={suggestion}
primaryTypographyProps={{ variant: 'body2' }}
/>
</ListItem>
))}
</List>
</Box>
);
};
// Rendu des actions
const renderActions = () => {
const actions = [];
// Action principale
if (config.actionLabel && (onRetry || onRefresh)) {
actions.push(
<Button
key="primary"
variant="contained"
size="small"
startIcon={<RefreshIcon />}
onClick={handlePrimaryAction}
sx={{ mr: 1 }}
>
{config.actionLabel}
</Button>
);
}
// Action d'aide
if (onHelp) {
actions.push(
<Button
key="help"
variant="outlined"
size="small"
startIcon={<HelpIcon />}
onClick={onHelp}
>
Aide
</Button>
);
}
if (actions.length === 0) return null;
return (
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}>
{actions}
</Box>
);
};
// Logging pour le développement
if (process.env.NODE_ENV === 'development') {
console.log(`📭 [EmptyStateMessage] Affichage état vide:`, {
stepType,
reason,
hasError: Boolean(error),
suggestionsCount: finalSuggestions.length,
hasActions: Boolean(config.actionLabel && (onRetry || onRefresh))
});
}
return (
<Box
className={className}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 3,
textAlign: 'center',
minHeight: 200
}}
role="status"
aria-label={`État vide: ${config.title}`}
>
{/* En-tête avec icône et titre */}
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 48,
height: 48,
borderRadius: '50%',
backgroundColor: `${config.severity}.light`,
color: `${config.severity}.main`,
mb: 1,
mx: 'auto'
}}
>
{config.icon}
</Box>
<Typography variant="h6" component="h3" gutterBottom>
{config.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{config.description}
</Typography>
</Box>
{/* Informations sur l'étape */}
<Box sx={{ mb: 2 }}>
<Chip
label={`Type: ${stepType}`}
size="small"
variant="outlined"
icon={<SettingsIcon />}
/>
</Box>
{/* Alert avec sévérité appropriée */}
<Alert
severity={config.severity}
sx={{ mb: 2, width: '100%', maxWidth: 400 }}
variant="outlined"
>
<Typography variant="body2">
{reason === 'no-parameters'
? 'Cette étape est prête à être utilisée sans configuration supplémentaire.'
: 'Une intervention peut être nécessaire pour configurer cette étape.'
}
</Typography>
</Alert>
{/* Détails d'erreur */}
{renderErrorDetails()}
{/* Suggestions */}
{renderSuggestions()}
{/* Actions */}
{renderActions()}
</Box>
);
};
/**
* Hook pour générer des suggestions contextuelles
*/
export function useEmptyStateSuggestions(
stepType: string,
reason: EmptyStateReason,
error?: Error
): string[] {
return useMemo(() => {
const suggestions: string[] = [];
// Suggestions basées sur le type d'étape
if (stepType.includes('click')) {
suggestions.push('Essayez d\'utiliser l\'action "click" standard');
} else if (stepType.includes('type')) {
suggestions.push('Essayez d\'utiliser l\'action "type" standard');
} else if (stepType.includes('wait')) {
suggestions.push('Essayez d\'utiliser l\'action "wait" standard');
}
// Suggestions basées sur l'erreur
if (error) {
if (error.message.includes('network')) {
suggestions.push('Problème de réseau détecté - vérifiez la connectivité');
} else if (error.message.includes('timeout')) {
suggestions.push('Délai d\'attente dépassé - réessayez plus tard');
} else if (error.message.includes('404')) {
suggestions.push('Ressource non trouvée - vérifiez la configuration');
}
}
// Suggestions basées sur la raison
switch (reason) {
case 'vwb-not-found':
suggestions.push(`Recherchez une alternative à "${stepType}" dans le catalogue`);
break;
case 'unknown-type':
suggestions.push(`Vérifiez si "${stepType}" est correctement orthographié`);
break;
}
return suggestions;
}, [stepType, reason, error]);
}
/**
* Composant EmptyStateMessage avec suggestions automatiques
*/
export const SmartEmptyStateMessage: React.FC<Omit<EmptyStateMessageProps, 'suggestions'>> = (props) => {
const autoSuggestions = useEmptyStateSuggestions(props.stepType, props.reason, props.error);
return (
<EmptyStateMessage
{...props}
suggestions={autoSuggestions}
/>
);
};
/**
* Export du composant mémorisé
*/
export default memo(EmptyStateMessage, (prevProps, nextProps) => {
return (
prevProps.stepType === nextProps.stepType &&
prevProps.reason === nextProps.reason &&
prevProps.error?.message === nextProps.error?.message &&
JSON.stringify(prevProps.suggestions) === JSON.stringify(nextProps.suggestions)
);
});

View File

@@ -0,0 +1,515 @@
/**
* Composant LoadingState - Indicateurs de chargement élégants et informatifs
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*
* Ce composant affiche des indicateurs de chargement avec support de l'annulation,
* messages informatifs et maintien de la réactivité de l'interface.
*/
import React, { useState, useEffect, useCallback, memo } from 'react';
import {
Box,
Typography,
CircularProgress,
LinearProgress,
Button,
Fade,
Skeleton,
Alert,
Chip,
} from '@mui/material';
import {
Cancel as CancelIcon,
Refresh as RefreshIcon,
Info as InfoIcon,
Timer as TimerIcon,
} from '@mui/icons-material';
/**
* Types de chargement
*/
export type LoadingType =
| 'resolving'
| 'loading-vwb'
| 'validating'
| 'saving'
| 'fetching-catalog'
| 'processing'
| 'generic';
/**
* Props du composant LoadingState
*/
export interface LoadingStateProps {
type?: LoadingType;
message?: string;
progress?: number;
canCancel?: boolean;
onCancel?: () => void;
timeout?: number;
showElapsedTime?: boolean;
showSkeletons?: boolean;
variant?: 'circular' | 'linear' | 'skeleton';
size?: 'small' | 'medium' | 'large';
className?: string;
}
/**
* Configuration des messages par type de chargement
*/
interface LoadingConfig {
defaultMessage: string;
icon?: React.ReactNode;
color: 'primary' | 'secondary' | 'info' | 'warning';
estimatedDuration: number; // en millisecondes
showProgress: boolean;
}
/**
* Configurations des types de chargement
*/
const LOADING_CONFIGS: Record<LoadingType, LoadingConfig> = {
'resolving': {
defaultMessage: 'Résolution des propriétés d\'étape...',
color: 'primary',
estimatedDuration: 2000,
showProgress: false
},
'loading-vwb': {
defaultMessage: 'Chargement de l\'action VWB...',
color: 'info',
estimatedDuration: 3000,
showProgress: true
},
'validating': {
defaultMessage: 'Validation des paramètres...',
color: 'secondary',
estimatedDuration: 1500,
showProgress: false
},
'saving': {
defaultMessage: 'Sauvegarde en cours...',
color: 'primary',
estimatedDuration: 2500,
showProgress: true
},
'fetching-catalog': {
defaultMessage: 'Récupération du catalogue d\'actions...',
color: 'info',
estimatedDuration: 4000,
showProgress: true
},
'processing': {
defaultMessage: 'Traitement en cours...',
color: 'primary',
estimatedDuration: 3000,
showProgress: false
},
'generic': {
defaultMessage: 'Chargement...',
color: 'primary',
estimatedDuration: 2000,
showProgress: false
}
};
/**
* Hook pour le temps écoulé
*/
function useElapsedTime(isActive: boolean): number {
const [elapsedTime, setElapsedTime] = useState(0);
useEffect(() => {
if (!isActive) {
setElapsedTime(0);
return;
}
const startTime = Date.now();
const interval = setInterval(() => {
setElapsedTime(Date.now() - startTime);
}, 100);
return () => clearInterval(interval);
}, [isActive]);
return elapsedTime;
}
/**
* Hook pour la progression estimée
*/
function useEstimatedProgress(
type: LoadingType,
elapsedTime: number,
actualProgress?: number
): number {
return React.useMemo(() => {
if (actualProgress !== undefined) {
return Math.min(100, Math.max(0, actualProgress));
}
const config = LOADING_CONFIGS[type];
const estimatedProgress = Math.min(95, (elapsedTime / config.estimatedDuration) * 100);
return estimatedProgress;
}, [type, elapsedTime, actualProgress]);
}
/**
* Composant LoadingState
*/
const LoadingState: React.FC<LoadingStateProps> = ({
type = 'generic',
message,
progress,
canCancel = false,
onCancel,
timeout,
showElapsedTime = false,
showSkeletons = false,
variant = 'circular',
size = 'medium',
className
}) => {
const [isTimedOut, setIsTimedOut] = useState(false);
const [showTimeoutWarning, setShowTimeoutWarning] = useState(false);
// Configuration pour le type de chargement
const config = LOADING_CONFIGS[type];
const displayMessage = message || config.defaultMessage;
// Temps écoulé
const elapsedTime = useElapsedTime(true);
// Progression estimée
const estimatedProgress = useEstimatedProgress(type, elapsedTime, progress);
// Gestion du timeout
useEffect(() => {
if (!timeout) return;
const warningTimeout = setTimeout(() => {
setShowTimeoutWarning(true);
}, timeout * 0.8); // Avertissement à 80% du timeout
const finalTimeout = setTimeout(() => {
setIsTimedOut(true);
}, timeout);
return () => {
clearTimeout(warningTimeout);
clearTimeout(finalTimeout);
};
}, [timeout]);
// Gestionnaire d'annulation
const handleCancel = useCallback(() => {
if (onCancel) {
onCancel();
}
}, [onCancel]);
// Formatage du temps écoulé
const formatElapsedTime = (ms: number): string => {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
}
return `${seconds}s`;
};
// Tailles des composants
const getSizes = () => {
switch (size) {
case 'small':
return { circularSize: 24, spacing: 1, typography: 'body2' as const };
case 'large':
return { circularSize: 48, spacing: 3, typography: 'h6' as const };
default:
return { circularSize: 32, spacing: 2, typography: 'body1' as const };
}
};
const sizes = getSizes();
// Rendu des squelettes
const renderSkeletons = () => {
if (!showSkeletons) return null;
return (
<Box sx={{ width: '100%', mt: 2 }}>
<Skeleton variant="text" width="80%" height={24} sx={{ mb: 1 }} />
<Skeleton variant="text" width="60%" height={20} sx={{ mb: 1 }} />
<Skeleton variant="rectangular" width="100%" height={40} sx={{ borderRadius: 1 }} />
</Box>
);
};
// Rendu de l'indicateur de progression
const renderProgressIndicator = () => {
switch (variant) {
case 'linear':
return (
<LinearProgress
variant={progress !== undefined ? 'determinate' : 'indeterminate'}
value={estimatedProgress}
color={config.color}
sx={{ width: '100%', mt: 1 }}
/>
);
case 'skeleton':
return renderSkeletons();
default: // circular
return (
<CircularProgress
size={sizes.circularSize}
variant={progress !== undefined ? 'determinate' : 'indeterminate'}
value={estimatedProgress}
color={config.color}
/>
);
}
};
// Rendu des informations temporelles
const renderTimeInfo = () => {
if (!showElapsedTime && !showTimeoutWarning) return null;
return (
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{showElapsedTime && (
<Chip
icon={<TimerIcon />}
label={formatElapsedTime(elapsedTime)}
size="small"
variant="outlined"
sx={{ mr: 1 }}
/>
)}
{showTimeoutWarning && !isTimedOut && (
<Chip
icon={<InfoIcon />}
label="Opération longue"
size="small"
color="warning"
variant="outlined"
/>
)}
</Box>
);
};
// Rendu des actions
const renderActions = () => {
if (!canCancel && !isTimedOut) return null;
return (
<Box sx={{ mt: 2, display: 'flex', gap: 1, justifyContent: 'center' }}>
{canCancel && !isTimedOut && (
<Button
variant="outlined"
size="small"
startIcon={<CancelIcon />}
onClick={handleCancel}
color="secondary"
>
Annuler
</Button>
)}
{isTimedOut && (
<Button
variant="contained"
size="small"
startIcon={<RefreshIcon />}
onClick={handleCancel}
color="primary"
>
Réessayer
</Button>
)}
</Box>
);
};
// Logging pour le développement
if (process.env.NODE_ENV === 'development') {
console.log(`⏳ [LoadingState] État de chargement:`, {
type,
elapsedTime: formatElapsedTime(elapsedTime),
progress: estimatedProgress.toFixed(1) + '%',
isTimedOut,
showTimeoutWarning
});
}
// Cas de timeout
if (isTimedOut) {
return (
<Box
className={className}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: sizes.spacing,
textAlign: 'center',
minHeight: 150
}}
role="status"
aria-label="Opération expirée"
>
<Alert severity="warning" sx={{ mb: 2 }}>
<Typography variant="body2">
L'opération prend plus de temps que prévu
</Typography>
</Alert>
<Typography variant={sizes.typography} color="text.secondary" gutterBottom>
{displayMessage}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mb: 2 }}>
Temps écoulé: {formatElapsedTime(elapsedTime)}
</Typography>
{renderActions()}
</Box>
);
}
return (
<Fade in timeout={300}>
<Box
className={className}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: sizes.spacing,
textAlign: 'center',
minHeight: variant === 'skeleton' ? 200 : 120
}}
role="status"
aria-label={displayMessage}
aria-live="polite"
>
{/* Indicateur de progression */}
{renderProgressIndicator()}
{/* Message de chargement */}
{variant !== 'skeleton' && (
<Typography
variant={sizes.typography}
color="text.secondary"
sx={{ mt: sizes.spacing }}
>
{displayMessage}
</Typography>
)}
{/* Progression numérique */}
{progress !== undefined && variant !== 'skeleton' && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
{Math.round(estimatedProgress)}%
</Typography>
)}
{/* Informations temporelles */}
{renderTimeInfo()}
{/* Actions */}
{renderActions()}
</Box>
</Fade>
);
};
/**
* Composant LoadingState spécialisé pour la résolution d'étapes
*/
export const StepResolutionLoading: React.FC<Omit<LoadingStateProps, 'type'>> = (props) => {
return (
<LoadingState
{...props}
type="resolving"
variant="circular"
size="small"
showElapsedTime={process.env.NODE_ENV === 'development'}
/>
);
};
/**
* Composant LoadingState spécialisé pour le chargement VWB
*/
export const VWBActionLoading: React.FC<Omit<LoadingStateProps, 'type'>> = (props) => {
return (
<LoadingState
{...props}
type="loading-vwb"
variant="linear"
showElapsedTime
canCancel
/>
);
};
/**
* Composant LoadingState spécialisé pour la sauvegarde
*/
export const SavingLoading: React.FC<Omit<LoadingStateProps, 'type'>> = (props) => {
return (
<LoadingState
{...props}
type="saving"
variant="linear"
size="small"
/>
);
};
/**
* Composant LoadingState avec squelettes pour les paramètres
*/
export const ParametersSkeletonLoading: React.FC<Omit<LoadingStateProps, 'type' | 'variant'>> = (props) => {
return (
<LoadingState
{...props}
type="resolving"
variant="skeleton"
showSkeletons
/>
);
};
/**
* Export du composant mémorisé
*/
export default memo(LoadingState, (prevProps, nextProps) => {
return (
prevProps.type === nextProps.type &&
prevProps.message === nextProps.message &&
prevProps.progress === nextProps.progress &&
prevProps.canCancel === nextProps.canCancel &&
prevProps.timeout === nextProps.timeout &&
prevProps.variant === nextProps.variant &&
prevProps.size === nextProps.size
);
});

View File

@@ -0,0 +1,565 @@
/**
* Composant ParameterFieldRenderer - Rendu unifié des champs de paramètres
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*
* Ce composant fournit un système de rendu unifié pour tous les types de champs
* de paramètres d'étapes, avec support de l'extensibilité et validation intégrée.
*/
import React, { memo, useCallback, useMemo, useState } from 'react';
import {
Box,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Switch,
FormControlLabel,
Button,
Typography,
Chip,
Alert,
} from '@mui/material';
import {
Visibility as VisibilityIcon,
Error as ErrorIcon,
CheckCircle as CheckCircleIcon,
} from '@mui/icons-material';
// Import des composants spécialisés
import VariableAutocomplete from '../VariableAutocomplete';
import RealScreenCapture from '../RealScreenCapture';
// Import des types
import { ParameterConfig } from '../../services/StepTypeResolver';
import { Variable, ValidationError, VisualSelection } from '../../types';
/**
* Props du ParameterFieldRenderer
*/
export interface ParameterFieldRendererProps {
config: ParameterConfig;
value: any;
variables: Variable[];
error?: ValidationError;
onChange: (value: any) => void;
onVisualSelection?: () => void;
disabled?: boolean;
className?: string;
}
/**
* Props des renderers spécialisés
*/
interface BaseFieldRendererProps {
config: ParameterConfig;
value: any;
error?: ValidationError;
onChange: (value: any) => void;
disabled?: boolean;
}
interface TextFieldRendererProps extends BaseFieldRendererProps {
variables: Variable[];
}
interface SelectFieldRendererProps extends BaseFieldRendererProps {}
interface NumberFieldRendererProps extends BaseFieldRendererProps {}
interface BooleanFieldRendererProps extends BaseFieldRendererProps {}
interface VisualFieldRendererProps extends BaseFieldRendererProps {
onVisualSelection?: () => void;
}
/**
* Renderer pour les champs de texte
*/
const TextFieldRenderer: React.FC<TextFieldRendererProps> = memo(({
config,
value,
variables,
error,
onChange,
disabled = false
}) => {
const hasError = Boolean(error);
// Utiliser l'autocomplétion des variables si supporté
if (config.supportVariables) {
return (
<VariableAutocomplete
label={config.label}
value={value || ''}
onChange={onChange}
variables={variables}
error={hasError}
helperText={hasError ? error!.message : config.description}
required={config.required}
disabled={disabled}
placeholder={config.placeholder}
multiline={config.multiline}
/>
);
}
// Champ de texte standard
return (
<TextField
fullWidth
label={config.label}
value={value || ''}
onChange={(e) => onChange(e.target.value)}
error={hasError}
helperText={hasError ? error!.message : config.description}
required={config.required}
disabled={disabled}
placeholder={config.placeholder}
multiline={config.multiline}
rows={config.multiline ? 3 : 1}
/>
);
});
TextFieldRenderer.displayName = 'TextFieldRenderer';
/**
* Renderer pour les champs numériques
*/
const NumberFieldRenderer: React.FC<NumberFieldRendererProps> = memo(({
config,
value,
error,
onChange,
disabled = false
}) => {
const hasError = Boolean(error);
const handleChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const numValue = Number(event.target.value);
onChange(isNaN(numValue) ? '' : numValue);
}, [onChange]);
return (
<TextField
fullWidth
type="number"
label={config.label}
value={value ?? ''}
onChange={handleChange}
error={hasError}
helperText={hasError ? error!.message : config.description}
required={config.required}
disabled={disabled}
placeholder={config.placeholder}
slotProps={{
htmlInput: {
min: config.min,
max: config.max,
step: config.step || 0.1,
}
}}
/>
);
});
NumberFieldRenderer.displayName = 'NumberFieldRenderer';
/**
* Renderer pour les champs booléens (switch)
*/
const BooleanFieldRenderer: React.FC<BooleanFieldRendererProps> = memo(({
config,
value,
error,
onChange,
disabled = false
}) => {
const hasError = Boolean(error);
return (
<Box>
<FormControlLabel
control={
<Switch
checked={Boolean(value)}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
color={hasError ? 'error' : 'primary'}
/>
}
label={config.label}
/>
{config.description && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
{config.description}
</Typography>
)}
{hasError && (
<Typography variant="caption" color="error" sx={{ display: 'block', mt: 0.5 }}>
<ErrorIcon sx={{ fontSize: 14, mr: 0.5, verticalAlign: 'middle' }} />
{error!.message}
</Typography>
)}
</Box>
);
});
BooleanFieldRenderer.displayName = 'BooleanFieldRenderer';
/**
* Renderer pour les champs de sélection
*/
const SelectFieldRenderer: React.FC<SelectFieldRendererProps> = memo(({
config,
value,
error,
onChange,
disabled = false
}) => {
const hasError = Boolean(error);
return (
<FormControl fullWidth error={hasError} disabled={disabled}>
<InputLabel required={config.required}>{config.label}</InputLabel>
<Select
value={value || ''}
onChange={(e) => onChange(e.target.value)}
label={config.label}
>
{config.options?.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
{(hasError || config.description) && (
<Typography
variant="caption"
color={hasError ? 'error' : 'text.secondary'}
sx={{ mt: 0.5, display: 'block' }}
>
{hasError ? error!.message : config.description}
</Typography>
)}
</FormControl>
);
});
SelectFieldRenderer.displayName = 'SelectFieldRenderer';
/**
* Renderer pour les champs de sélection visuelle
*/
const VisualFieldRenderer: React.FC<VisualFieldRendererProps> = memo(({
config,
value,
error,
onChange,
onVisualSelection,
disabled = false
}) => {
const hasError = Boolean(error);
const hasSelection = Boolean(value);
const [isScreenCaptureOpen, setIsScreenCaptureOpen] = useState(false);
const handleVisualSelection = useCallback(() => {
if (!disabled) {
setIsScreenCaptureOpen(true);
}
}, [disabled]);
const handleElementSelected = useCallback((selection: VisualSelection) => {
onChange(selection);
setIsScreenCaptureOpen(false);
// Notifier le parent si nécessaire
if (onVisualSelection) {
onVisualSelection();
}
}, [onChange, onVisualSelection]);
const handleClearSelection = useCallback(() => {
onChange(null);
}, [onChange]);
return (
<Box>
<Button
fullWidth
variant="outlined"
startIcon={<VisibilityIcon />}
onClick={handleVisualSelection}
color={hasError ? 'error' : hasSelection ? 'success' : 'primary'}
disabled={disabled}
sx={{ mb: 1 }}
>
{hasSelection ? 'Modifier la sélection' : config.label}
</Button>
{hasSelection && (
<Box sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Chip
label="Élément sélectionné"
size="small"
color="success"
icon={<CheckCircleIcon />}
onDelete={disabled ? undefined : handleClearSelection}
/>
{value?.boundingBox && (
<Typography variant="caption" color="text.secondary">
({Math.round(value.boundingBox.x)}px, {Math.round(value.boundingBox.y)}px) -
{Math.round(value.boundingBox.width)}×{Math.round(value.boundingBox.height)}px
</Typography>
)}
</Box>
{/* Aperçu de la sélection visuelle */}
{value?.screenshot && (
<Box
sx={{
position: 'relative',
width: '100%',
maxHeight: 150,
overflow: 'hidden',
borderRadius: 1,
border: '1px solid',
borderColor: 'divider',
mb: 1,
}}
>
<img
src={value.screenshot}
alt="Aperçu de la sélection"
style={{
width: '100%',
height: 'auto',
maxHeight: 150,
objectFit: 'contain',
display: 'block',
}}
/>
{/* Indicateur de la zone sélectionnée */}
{value.boundingBox && (
<Box
sx={{
position: 'absolute',
left: `${(value.boundingBox.x / 1920) * 100}%`,
top: `${(value.boundingBox.y / 1080) * 100}%`,
width: `${Math.max((value.boundingBox.width / 1920) * 100, 2)}%`,
height: `${Math.max((value.boundingBox.height / 1080) * 100, 2)}%`,
border: '2px solid #4caf50',
backgroundColor: 'rgba(76, 175, 80, 0.2)',
pointerEvents: 'none',
}}
/>
)}
</Box>
)}
</Box>
)}
{config.description && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{config.description}
</Typography>
)}
{hasError && (
<Alert severity="error" sx={{ mt: 1 }}>
{error!.message}
</Alert>
)}
{/* Composant de capture d'écran */}
<RealScreenCapture
isOpen={isScreenCaptureOpen}
onClose={() => setIsScreenCaptureOpen(false)}
onElementSelected={handleElementSelected}
stepId={`field_${config.name}`}
/>
</Box>
);
});
VisualFieldRenderer.displayName = 'VisualFieldRenderer';
/**
* Map des renderers par type de champ
*/
const FIELD_RENDERERS = {
text: TextFieldRenderer,
number: NumberFieldRenderer,
boolean: BooleanFieldRenderer,
select: SelectFieldRenderer,
visual: VisualFieldRenderer,
} as const;
/**
* Type pour les renderers de champs
*/
export type FieldRendererType = keyof typeof FIELD_RENDERERS;
/**
* Interface pour l'enregistrement de renderers personnalisés
*/
export interface CustomFieldRenderer {
type: string;
component: React.ComponentType<any>;
displayName: string;
}
/**
* Registre des renderers personnalisés
*/
class FieldRendererRegistry {
private customRenderers = new Map<string, CustomFieldRenderer>();
/**
* Enregistre un renderer personnalisé
*/
register(renderer: CustomFieldRenderer): void {
this.customRenderers.set(renderer.type, renderer);
console.log(`📝 [ParameterFieldRenderer] Renderer personnalisé enregistré: ${renderer.type}`);
}
/**
* Obtient un renderer par type
*/
getRenderer(type: string): React.ComponentType<any> | null {
// Vérifier d'abord les renderers personnalisés
const customRenderer = this.customRenderers.get(type);
if (customRenderer) {
return customRenderer.component;
}
// Vérifier les renderers standard
if (type in FIELD_RENDERERS) {
return FIELD_RENDERERS[type as FieldRendererType];
}
return null;
}
/**
* Liste tous les types de renderers disponibles
*/
getAvailableTypes(): string[] {
const standardTypes = Object.keys(FIELD_RENDERERS);
const customTypes = Array.from(this.customRenderers.keys());
return [...standardTypes, ...customTypes];
}
}
/**
* Instance singleton du registre
*/
export const fieldRendererRegistry = new FieldRendererRegistry();
/**
* Composant principal ParameterFieldRenderer
*/
const ParameterFieldRenderer: React.FC<ParameterFieldRendererProps> = ({
config,
value,
variables,
error,
onChange,
onVisualSelection,
disabled = false,
className
}) => {
// Obtenir le renderer approprié
const RendererComponent = useMemo(() => {
const renderer = fieldRendererRegistry.getRenderer(config.type);
if (!renderer) {
console.warn(`⚠️ [ParameterFieldRenderer] Renderer non trouvé pour le type: ${config.type}`);
return null;
}
return renderer;
}, [config.type]);
// Props communes pour tous les renderers
const commonProps = useMemo(() => ({
config,
value,
error,
onChange,
disabled
}), [config, value, error, onChange, disabled]);
// Props spécifiques selon le type
const specificProps = useMemo(() => {
switch (config.type) {
case 'text':
return { variables };
case 'visual':
return { onVisualSelection };
default:
return {};
}
}, [config.type, variables, onVisualSelection]);
// Logging pour le développement
if (process.env.NODE_ENV === 'development') {
console.log(`🎨 [ParameterFieldRenderer] Rendu du champ:`, {
name: config.name,
type: config.type,
hasValue: value !== undefined && value !== null && value !== '',
hasError: Boolean(error),
disabled
});
}
if (!RendererComponent) {
return (
<Alert severity="error" className={className}>
<Typography variant="body2">
Type de champ non supporté: <code>{config.type}</code>
</Typography>
<Typography variant="caption" color="text.secondary">
Types disponibles: {fieldRendererRegistry.getAvailableTypes().join(', ')}
</Typography>
</Alert>
);
}
return (
<Box className={className} data-field-type={config.type} data-field-name={config.name}>
<RendererComponent
{...commonProps}
{...specificProps}
/>
</Box>
);
};
/**
* Export du composant mémorisé
*/
export default memo(ParameterFieldRenderer, (prevProps, nextProps) => {
return (
prevProps.config.name === nextProps.config.name &&
prevProps.config.type === nextProps.config.type &&
prevProps.value === nextProps.value &&
prevProps.error?.message === nextProps.error?.message &&
prevProps.disabled === nextProps.disabled &&
JSON.stringify(prevProps.variables) === JSON.stringify(nextProps.variables)
);
});
/**
* Exports utilitaires
*/
export {
TextFieldRenderer,
NumberFieldRenderer,
BooleanFieldRenderer,
SelectFieldRenderer,
VisualFieldRenderer,
FIELD_RENDERERS
};

View File

@@ -0,0 +1,526 @@
/**
* Composant StandardParametersEditor - Éditeur de paramètres pour étapes standard
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*
* Ce composant fournit une interface complète pour éditer les paramètres des étapes
* standard avec validation en temps réel, sauvegarde automatique et gestion d'état.
*/
import React, { useState, useCallback, useEffect, useMemo, memo } from 'react';
import {
Box,
Typography,
Alert,
Divider,
Chip,
LinearProgress,
Collapse,
IconButton,
} from '@mui/material';
import {
CheckCircle as CheckCircleIcon,
Error as ErrorIcon,
Warning as WarningIcon,
Info as InfoIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
} from '@mui/icons-material';
// Import des composants
import ParameterFieldRenderer from './ParameterFieldRenderer';
// Import des types
import { ParameterConfig } from '../../services/StepTypeResolver';
import { Variable, ValidationError } from '../../types';
/**
* Props du StandardParametersEditor
*/
export interface StandardParametersEditorProps {
stepType: string;
parameterConfigs: ParameterConfig[];
parameters: Record<string, any>;
variables: Variable[];
onParameterChange: (paramName: string, value: any) => void;
onValidationChange: (errors: ValidationError[]) => void;
onVisualSelection?: () => void;
disabled?: boolean;
showValidationSummary?: boolean;
groupParameters?: boolean;
className?: string;
}
/**
* État de validation d'un paramètre
*/
interface ParameterValidationState {
isValid: boolean;
errors: ValidationError[];
warnings: ValidationError[];
infos: ValidationError[];
}
/**
* Résultat de validation globale
*/
interface ValidationSummary {
isValid: boolean;
totalErrors: number;
totalWarnings: number;
totalInfos: number;
parameterStates: Record<string, ParameterValidationState>;
}
/**
* Groupe de paramètres
*/
interface ParameterGroup {
name: string;
label: string;
parameters: ParameterConfig[];
expanded: boolean;
}
/**
* Hook pour la validation en temps réel
*/
function useParameterValidation(
parameterConfigs: ParameterConfig[],
parameters: Record<string, any>
): ValidationSummary {
return useMemo(() => {
const parameterStates: Record<string, ParameterValidationState> = {};
let totalErrors = 0;
let totalWarnings = 0;
let totalInfos = 0;
for (const config of parameterConfigs) {
const value = parameters[config.name];
const errors: ValidationError[] = [];
const warnings: ValidationError[] = [];
const infos: ValidationError[] = [];
// Validation de base - champ requis
if (config.required && (value === undefined || value === null || value === '')) {
errors.push({
parameter: config.name,
message: `Le champ "${config.label}" est requis`,
severity: 'error',
code: 'REQUIRED_FIELD'
});
}
// Validation spécifique par type
if (value !== undefined && value !== null && value !== '') {
switch (config.type) {
case 'number':
const numValue = Number(value);
if (isNaN(numValue)) {
errors.push({
parameter: config.name,
message: `"${config.label}" doit être un nombre valide`,
severity: 'error',
code: 'INVALID_NUMBER'
});
} else {
if (config.min !== undefined && numValue < config.min) {
errors.push({
parameter: config.name,
message: `"${config.label}" doit être supérieur ou égal à ${config.min}`,
severity: 'error',
code: 'MIN_VALUE'
});
}
if (config.max !== undefined && numValue > config.max) {
errors.push({
parameter: config.name,
message: `"${config.label}" doit être inférieur ou égal à ${config.max}`,
severity: 'error',
code: 'MAX_VALUE'
});
}
}
break;
case 'select':
if (config.options && !config.options.some(opt => opt.value === value)) {
errors.push({
parameter: config.name,
message: `"${config.label}" doit être une des options disponibles`,
severity: 'error',
code: 'INVALID_OPTION'
});
}
break;
case 'text':
if (typeof value !== 'string') {
errors.push({
parameter: config.name,
message: `"${config.label}" doit être du texte`,
severity: 'error',
code: 'INVALID_TEXT'
});
} else {
// Validation des variables si supportées
if (config.supportVariables) {
const variablePattern = /\$\{([^}]+)\}/g;
let match;
while ((match = variablePattern.exec(value)) !== null) {
const varName = match[1];
// Note: Dans une implémentation complète, on vérifierait si la variable existe
infos.push({
parameter: config.name,
message: `Variable utilisée: ${varName}`,
severity: 'info',
code: 'VARIABLE_USAGE'
});
}
}
}
break;
case 'visual':
if (typeof value !== 'object' || !value.selector) {
warnings.push({
parameter: config.name,
message: `"${config.label}" nécessite une sélection visuelle valide`,
severity: 'warning',
code: 'INCOMPLETE_VISUAL_SELECTION'
});
}
break;
}
}
// Validation conditionnelle
if (config.conditional) {
// Implémentation future pour les règles conditionnelles
}
parameterStates[config.name] = {
isValid: errors.length === 0,
errors,
warnings,
infos
};
totalErrors += errors.length;
totalWarnings += warnings.length;
totalInfos += infos.length;
}
return {
isValid: totalErrors === 0,
totalErrors,
totalWarnings,
totalInfos,
parameterStates
};
}, [parameterConfigs, parameters]);
}
/**
* Hook pour le groupement des paramètres
*/
function useParameterGroups(
parameterConfigs: ParameterConfig[],
groupParameters: boolean
): ParameterGroup[] {
return useMemo(() => {
if (!groupParameters) {
return [{
name: 'default',
label: 'Paramètres',
parameters: parameterConfigs,
expanded: true
}];
}
// Grouper par propriété 'group' ou par type
const groups: Record<string, ParameterConfig[]> = {};
for (const config of parameterConfigs) {
const groupName = config.group || config.type;
if (!groups[groupName]) {
groups[groupName] = [];
}
groups[groupName].push(config);
}
// Convertir en tableau de groupes
return Object.entries(groups).map(([name, parameters]) => ({
name,
label: name === 'default' ? 'Paramètres' :
name === 'text' ? 'Champs de texte' :
name === 'number' ? 'Champs numériques' :
name === 'boolean' ? 'Options' :
name === 'select' ? 'Sélections' :
name === 'visual' ? 'Sélections visuelles' :
name.charAt(0).toUpperCase() + name.slice(1),
parameters: parameters.sort((a, b) => (a.order || 0) - (b.order || 0)),
expanded: true
}));
}, [parameterConfigs, groupParameters]);
}
/**
* Composant StandardParametersEditor
*/
const StandardParametersEditor: React.FC<StandardParametersEditorProps> = ({
stepType,
parameterConfigs,
parameters,
variables,
onParameterChange,
onValidationChange,
onVisualSelection,
disabled = false,
showValidationSummary = true,
groupParameters = false,
className
}) => {
// État local pour les groupes expandus
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
// Validation en temps réel
const validationSummary = useParameterValidation(parameterConfigs, parameters);
// Groupement des paramètres
const parameterGroups = useParameterGroups(parameterConfigs, groupParameters);
// Effet pour notifier les changements de validation
useEffect(() => {
const allErrors: ValidationError[] = [];
Object.values(validationSummary.parameterStates).forEach(state => {
allErrors.push(...state.errors, ...state.warnings);
});
onValidationChange(allErrors);
}, [validationSummary, onValidationChange]);
// Gestionnaire de changement de paramètre avec validation
const handleParameterChange = useCallback((paramName: string, value: any) => {
console.log(`📝 [StandardParametersEditor] Changement paramètre:`, {
stepType,
paramName,
value,
type: typeof value
});
onParameterChange(paramName, value);
}, [stepType, onParameterChange]);
// Gestionnaire de toggle de groupe
const handleGroupToggle = useCallback((groupName: string) => {
setExpandedGroups(prev => ({
...prev,
[groupName]: !prev[groupName]
}));
}, []);
// Obtenir l'état de validation pour un paramètre
const getParameterValidation = useCallback((paramName: string): ValidationError | undefined => {
const state = validationSummary.parameterStates[paramName];
if (!state) return undefined;
// Retourner la première erreur, sinon le premier warning
return state.errors[0] || state.warnings[0] || undefined;
}, [validationSummary]);
// Rendu du résumé de validation
const renderValidationSummary = () => {
if (!showValidationSummary) return null;
const { isValid, totalErrors, totalWarnings, totalInfos } = validationSummary;
if (totalErrors === 0 && totalWarnings === 0 && totalInfos === 0) {
return (
<Alert severity="success" icon={<CheckCircleIcon />} sx={{ mb: 2 }}>
<Typography variant="body2">
Tous les paramètres sont correctement configurés
</Typography>
</Alert>
);
}
return (
<Box sx={{ mb: 2 }}>
{totalErrors > 0 && (
<Alert severity="error" icon={<ErrorIcon />} sx={{ mb: 1 }}>
<Typography variant="body2">
{totalErrors} erreur{totalErrors > 1 ? 's' : ''} de validation
</Typography>
</Alert>
)}
{totalWarnings > 0 && (
<Alert severity="warning" icon={<WarningIcon />} sx={{ mb: 1 }}>
<Typography variant="body2">
{totalWarnings} avertissement{totalWarnings > 1 ? 's' : ''}
</Typography>
</Alert>
)}
{totalInfos > 0 && (
<Alert severity="info" icon={<InfoIcon />}>
<Typography variant="body2">
{totalInfos} information{totalInfos > 1 ? 's' : ''}
</Typography>
</Alert>
)}
</Box>
);
};
// Rendu d'un groupe de paramètres
const renderParameterGroup = (group: ParameterGroup) => {
const isExpanded = expandedGroups[group.name] ?? group.expanded;
return (
<Box key={group.name} sx={{ mb: 2 }}>
{parameterGroups.length > 1 && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
mb: 1,
p: 1,
borderRadius: 1,
'&:hover': { backgroundColor: 'action.hover' }
}}
onClick={() => handleGroupToggle(group.name)}
>
<Typography variant="subtitle2" sx={{ flex: 1 }}>
{group.label} ({group.parameters.length})
</Typography>
<IconButton size="small">
{isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Box>
)}
<Collapse in={isExpanded}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{group.parameters.map((config) => {
const value = parameters[config.name];
const error = getParameterValidation(config.name);
return (
<Box key={config.name}>
<ParameterFieldRenderer
config={config}
value={value}
variables={variables}
error={error}
onChange={(newValue) => handleParameterChange(config.name, newValue)}
onVisualSelection={config.type === 'visual' ? onVisualSelection : undefined}
disabled={disabled}
/>
</Box>
);
})}
</Box>
</Collapse>
</Box>
);
};
// Rendu des variables disponibles
const renderVariablesSection = () => {
if (variables.length === 0) return null;
const hasVariableSupport = parameterConfigs.some(config => config.supportVariables);
if (!hasVariableSupport) return null;
return (
<>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" gutterBottom>
Variables disponibles
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{variables.map((variable) => (
<Chip
key={variable.id}
label={`\${${variable.name}}`}
size="small"
variant="outlined"
onClick={() => {
// Copier dans le presse-papiers
navigator.clipboard.writeText(`\${${variable.name}}`);
}}
sx={{ cursor: 'pointer' }}
/>
))}
</Box>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
Cliquez sur une variable pour la copier dans le presse-papiers
</Typography>
</>
);
};
// Logging pour le développement
if (process.env.NODE_ENV === 'development') {
console.log(`🎛️ [StandardParametersEditor] Rendu:`, {
stepType,
parameterCount: parameterConfigs.length,
groupCount: parameterGroups.length,
validationState: {
isValid: validationSummary.isValid,
errors: validationSummary.totalErrors,
warnings: validationSummary.totalWarnings
},
disabled
});
}
// Cas où aucun paramètre n'est configuré
if (parameterConfigs.length === 0) {
return (
<Box className={className}>
<Typography variant="body2" color="text.secondary" textAlign="center" sx={{ py: 4 }}>
Cette étape n'a pas de paramètres configurables.
</Typography>
</Box>
);
}
return (
<Box className={className} data-step-type={stepType}>
{/* Résumé de validation */}
{renderValidationSummary()}
{/* Indicateur de progression si validation en cours */}
{disabled && (
<LinearProgress sx={{ mb: 2 }} />
)}
{/* Groupes de paramètres */}
{parameterGroups.map(renderParameterGroup)}
{/* Section des variables */}
{renderVariablesSection()}
</Box>
);
};
/**
* Export du composant mémorisé
*/
export default memo(StandardParametersEditor, (prevProps, nextProps) => {
return (
prevProps.stepType === nextProps.stepType &&
JSON.stringify(prevProps.parameterConfigs) === JSON.stringify(nextProps.parameterConfigs) &&
JSON.stringify(prevProps.parameters) === JSON.stringify(nextProps.parameters) &&
JSON.stringify(prevProps.variables) === JSON.stringify(nextProps.variables) &&
prevProps.disabled === nextProps.disabled &&
prevProps.showValidationSummary === nextProps.showValidationSummary &&
prevProps.groupParameters === nextProps.groupParameters
);
});

View File

@@ -0,0 +1,633 @@
/**
* Composant Panneau de Propriétés - Configuration des paramètres d'étapes
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*
* Ce composant affiche et permet la modification des paramètres d'une étape sélectionnée,
* avec validation en temps réel et adaptation selon le type de paramètre.
*
* Version refactorisée utilisant le nouveau StepTypeResolver pour une résolution
* unifiée et robuste des propriétés d'étapes.
*/
import React, { useState, useCallback, useMemo, memo } from 'react';
import {
Box,
Typography,
Button,
Alert,
Divider,
Chip,
CircularProgress,
} from '@mui/material';
import {
Visibility as VisibilityIcon,
Error as ErrorIcon,
BugReport as BugReportIcon,
} from '@mui/icons-material';
// Import des composants
import VisualSelector from '../VisualSelector';
import VariableAutocomplete from '../VariableAutocomplete';
import VWBActionProperties from './VWBActionProperties';
import DebugPanel from '../DebugPanel';
// Import des nouveaux composants de l'interface complète
import StandardParametersEditor from './StandardParametersEditor';
import EmptyStateMessage from './EmptyStateMessage';
import LoadingState, { StepResolutionLoading, VWBActionLoading } from './LoadingState';
// Import du nouveau système de résolution
import { useStepTypeResolver } from '../../hooks/useStepTypeResolver';
import { ParameterConfig, StepTypeResolutionResult } from '../../services/StepTypeResolver';
// Import des hooks d'intégration VWB (pour compatibilité)
import { useVWBStepIntegration, useVWBActionId } from '../../hooks/useVWBStepIntegration';
// Import des types du catalogue VWB
import { VWBCatalogAction, VWBActionValidationResult } from '../../types/catalog';
// Import des hooks d'auto-sauvegarde
import { useStepParametersAutoSave } from '../../hooks/useAutoSave';
// Import des types partagés
import {
PropertiesPanelProps,
ValidationError,
VisualSelection,
Variable,
} from '../../types';
// Types pour l'état d'affichage
interface DisplayState {
type: 'loading' | 'empty' | 'vwb-properties' | 'standard-parameters';
subtype?: 'resolving' | 'loading-vwb';
reason?: 'resolution-failed' | 'vwb-not-found' | 'no-parameters' | 'unknown-type';
error?: Error;
}
/**
* Composant Panneau de Propriétés
*/
const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
selectedStep,
variables,
onParameterChange,
onVisualSelection,
}) => {
const [localParameters, setLocalParameters] = useState<Record<string, any>>({});
const [isVisualSelectorOpen, setIsVisualSelectorOpen] = useState(false);
const [isDebugPanelVisible, setIsDebugPanelVisible] = useState(false);
const [validationErrors, setValidationErrors] = useState<ValidationError[]>([]);
// Utilisation du nouveau système de résolution unifié
const stepResolver = useStepTypeResolver(selectedStep || null, {
autoResolve: true,
enableLogging: process.env.NODE_ENV === 'development',
onResolutionComplete: (result) => {
console.log('✅ [PropertiesPanel] Résolution terminée:', {
stepType: result.stepType,
isVWBAction: result.isVWBAction,
parameterCount: result.parameterConfig.length,
source: result.resolutionSource
});
},
onResolutionError: (error) => {
console.error('❌ [PropertiesPanel] Erreur de résolution:', error);
}
});
// Hooks d'intégration VWB (pour compatibilité et chargement des actions)
const { methods: vwbMethods } = useVWBStepIntegration();
const vwbActionId = useVWBActionId(selectedStep || null);
// État dérivé du résolveur
const {
result: resolutionResult,
isLoading: isResolving,
error: resolutionError,
resolveStep,
resolveStepSync,
invalidateCache: invalidateResolverCache,
hasParameterConfig,
parameterCount,
isStandardType,
resolutionSource
} = stepResolver;
// Déterminer si c'est une action VWB
// Utiliser vwbActionId comme indicateur principal car le hook useVWBActionId fait une détection robuste
const isVWBCatalogAction = useMemo(() => {
// Si vwbActionId est défini, c'est une action VWB (détection via hook)
if (vwbActionId) {
return true;
}
// Fallback vers le résultat du résolveur
return resolutionResult?.isVWBAction || false;
}, [resolutionResult, vwbActionId]);
// Obtenir la configuration des paramètres
const parameterConfigs = useMemo(() => {
if (!resolutionResult) return [];
return resolutionResult.parameterConfig || [];
}, [resolutionResult]);
// Charger l'action VWB si nécessaire
const [vwbAction, setVwbAction] = useState<VWBCatalogAction | null>(null);
const [isLoadingVWBAction, setIsLoadingVWBAction] = useState(false);
const [vwbActionError, setVwbActionError] = useState<Error | null>(null);
// Hook d'auto-sauvegarde pour les paramètres
const autoSave = useStepParametersAutoSave(
selectedStep?.id || '',
onParameterChange,
{
debounceMs: 800,
enableLogging: process.env.NODE_ENV === 'development',
onSaveStart: () => console.log('💾 [PropertiesPanel] Début sauvegarde auto'),
onSaveSuccess: () => console.log('✅ [PropertiesPanel] Sauvegarde auto réussie'),
onSaveError: (error) => console.error('❌ [PropertiesPanel] Erreur sauvegarde auto:', error)
}
);
// Méthode pour résoudre manuellement une étape
const handleManualResolveStep = useCallback(async (): Promise<StepTypeResolutionResult | null> => {
if (!selectedStep) return null;
try {
console.log('🔄 [PropertiesPanel] Résolution manuelle de l\'étape:', selectedStep.id);
const result: StepTypeResolutionResult = await resolveStep(selectedStep, { enableCache: false });
console.log('✅ [PropertiesPanel] Résolution manuelle terminée:', result);
return result;
} catch (error) {
console.error('❌ [PropertiesPanel] Erreur résolution manuelle:', error);
return null;
}
}, [selectedStep, resolveStep]);
// Méthode pour invalider le cache et recharger
const handleRefreshResolution = useCallback(() => {
console.log('🔄 [PropertiesPanel] Actualisation de la résolution');
invalidateResolverCache();
if (selectedStep) {
handleManualResolveStep();
}
}, [invalidateResolverCache, selectedStep, handleManualResolveStep]);
// Charger l'action VWB basé sur vwbActionId (pas sur isVWBCatalogAction du résolveur)
React.useEffect(() => {
const loadVWBAction = async () => {
// Charger si vwbActionId est défini (le hook useVWBActionId fait déjà la détection)
if (!vwbActionId) {
setVwbAction(null);
setVwbActionError(null);
return;
}
console.log('🔄 [PropertiesPanel] Chargement action VWB:', vwbActionId);
setIsLoadingVWBAction(true);
setVwbActionError(null);
try {
// Utiliser le hook d'intégration pour charger l'action
const action = await vwbMethods.loadVWBAction(vwbActionId);
setVwbAction(action);
if (action) {
console.log('✅ [PropertiesPanel] Action VWB chargée:', {
actionId: vwbActionId,
actionName: action.name,
parametersCount: Object.keys(action.parameters || {}).length
});
} else {
console.warn('⚠️ [PropertiesPanel] Action VWB non trouvée:', vwbActionId);
}
} catch (error) {
const errorObj = error instanceof Error ? error : new Error(String(error));
console.error('❌ [PropertiesPanel] Erreur chargement action VWB:', errorObj);
setVwbAction(null);
setVwbActionError(errorObj);
} finally {
setIsLoadingVWBAction(false);
}
};
loadVWBAction();
}, [vwbActionId, vwbMethods]);
// Gestionnaire d'événements clavier pour le panneau de propriétés
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
switch (event.key) {
case 'Enter':
// Activer l'élément focalisé
if (event.target instanceof HTMLButtonElement) {
event.preventDefault();
event.target.click();
}
break;
case 'Escape':
// Fermer les dialogues ouverts
event.preventDefault();
setIsVisualSelectorOpen(false);
break;
case 'Tab':
// Navigation entre les champs (comportement par défaut)
break;
}
}, []);
// Synchroniser les paramètres locaux avec l'étape sélectionnée
// IMPORTANT: Copie profonde pour éviter les mutations qui affectent d'autres étapes
React.useEffect(() => {
if (selectedStep) {
// Copie profonde pour isoler les paramètres locaux du workflow
const paramsCopy = JSON.parse(JSON.stringify(selectedStep.data?.parameters || {}));
setLocalParameters(paramsCopy);
} else {
setLocalParameters({});
}
}, [selectedStep]);
// Gestionnaire de changement de paramètre VWB (avec auto-sauvegarde)
const handleVWBParameterChange = useCallback((paramName: string, value: any) => {
// Copie profonde de la valeur si c'est un objet (ex: visual_anchor)
const valueCopy = value && typeof value === 'object'
? JSON.parse(JSON.stringify(value))
: value;
const newParameters = { ...localParameters, [paramName]: valueCopy };
setLocalParameters(newParameters);
if (selectedStep) {
// Déclencher la sauvegarde automatique (fix: était manquant pour les actions VWB)
autoSave.triggerSave(newParameters);
// Envoyer aussi une copie au parent pour éviter les références partagées
onParameterChange(selectedStep.id, paramName, valueCopy);
}
}, [localParameters, selectedStep, onParameterChange, autoSave]);
// Gestionnaire de validation VWB
const handleVWBValidationChange = useCallback((validation: VWBActionValidationResult) => {
// Mettre à jour les erreurs de validation de l'étape
if (selectedStep) {
// Notifier le parent des erreurs de validation
if (validation.errors.length > 0) {
console.error('Erreurs de validation VWB:', validation.errors.map(e => e.message).join(', '));
}
}
}, [selectedStep]);
// Gestionnaire de changement de paramètre avec auto-sauvegarde
const handleParameterChange = useCallback((paramName: string, value: any) => {
// Copie profonde de la valeur si c'est un objet
const valueCopy = value && typeof value === 'object'
? JSON.parse(JSON.stringify(value))
: value;
const newParameters = { ...localParameters, [paramName]: valueCopy };
setLocalParameters(newParameters);
// Déclencher la sauvegarde automatique
autoSave.triggerSave(newParameters);
if (selectedStep) {
onParameterChange(selectedStep.id, paramName, valueCopy);
}
}, [localParameters, selectedStep, onParameterChange, autoSave]);
// Gestionnaire de sélection visuelle
const handleVisualSelection = useCallback(() => {
if (selectedStep) {
setIsVisualSelectorOpen(true);
}
}, [selectedStep]);
// Gestionnaire de confirmation de sélection visuelle
const handleElementSelected = useCallback((selection: VisualSelection) => {
if (selectedStep) {
// Stocker la sélection visuelle dans les paramètres
handleParameterChange('target', selection);
onVisualSelection(selectedStep.id);
}
}, [selectedStep, handleParameterChange, onVisualSelection]);
// Gestionnaire de validation des paramètres
const handleValidationChange = useCallback((errors: ValidationError[]) => {
setValidationErrors(errors);
}, []);
// Déterminer l'état d'affichage et le type de contenu
const getDisplayState = useCallback((): DisplayState => {
// Cas de chargement de résolution
if (isResolving) {
return { type: 'loading', subtype: 'resolving' };
}
// Cas d'erreur de résolution
if (resolutionError) {
return {
type: 'empty',
reason: 'resolution-failed' as const,
error: resolutionError
};
}
// Cas d'action VWB
if (isVWBCatalogAction) {
if (isLoadingVWBAction) {
return { type: 'loading', subtype: 'loading-vwb' };
}
if (vwbActionError) {
return {
type: 'empty',
reason: 'vwb-not-found' as const,
error: vwbActionError
};
}
if (vwbAction) {
return { type: 'vwb-properties' };
}
return {
type: 'empty',
reason: 'vwb-not-found' as const
};
}
// Cas d'étapes standard
if (parameterConfigs.length === 0) {
return {
type: 'empty',
reason: resolutionResult?.isStandardType ? 'no-parameters' as const : 'unknown-type' as const
};
}
return { type: 'standard-parameters' };
}, [
isResolving,
resolutionError,
isVWBCatalogAction,
isLoadingVWBAction,
vwbActionError,
vwbAction,
parameterConfigs.length,
resolutionResult
]);
// Debug logging pour diagnostic
React.useEffect(() => {
if (selectedStep) {
console.log('🔍 [PropertiesPanel] État actuel:', {
stepId: selectedStep.id,
stepType: selectedStep.type,
stepData: selectedStep.data,
isVWBCatalogAction,
vwbActionId,
vwbAction: vwbAction ? vwbAction.name : null,
isLoadingVWBAction,
vwbActionError: vwbActionError?.message,
displayState: getDisplayState(),
resolutionResult: resolutionResult ? {
isVWBAction: resolutionResult.isVWBAction,
isStandardType: resolutionResult.isStandardType,
parameterCount: resolutionResult.parameterConfig?.length
} : null
});
}
}, [selectedStep, isVWBCatalogAction, vwbActionId, vwbAction, isLoadingVWBAction, vwbActionError, resolutionResult, getDisplayState]);
if (!selectedStep) {
return (
<Box
sx={{
width: 320,
height: '100%',
backgroundColor: '#ffffff',
borderLeft: '1px solid #e0e0e0',
p: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
role="complementary"
aria-label="Panneau de propriétés - Aucune étape sélectionnée"
>
<Typography variant="body2" color="text.secondary" textAlign="center">
Sélectionnez une étape pour configurer ses propriétés
</Typography>
</Box>
);
}
const hasErrors = (selectedStep.validationErrors || []).some(error => error.severity === 'error');
return (
<Box
sx={{
width: 320,
height: '100%',
backgroundColor: '#ffffff',
borderLeft: '1px solid #e0e0e0',
display: 'flex',
flexDirection: 'column',
}}
role="complementary"
aria-label={`Propriétés de l'étape ${selectedStep.name}`}
onKeyDown={handleKeyDown}
tabIndex={0}
>
{/* En-tête */}
<Box sx={{ p: 2, borderBottom: '1px solid #e0e0e0' }}>
<Typography variant="h6" component="h3" gutterBottom>
Propriétés de l'étape
</Typography>
<Typography variant="body2" color="text.secondary">
{selectedStep.name} ({selectedStep.type})
</Typography>
{/* Indicateur de résolution */}
{isResolving && (
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1 }}>
<CircularProgress size={16} sx={{ mr: 1 }} />
<Typography variant="caption" color="text.secondary">
Résolution des propriétés...
</Typography>
</Box>
)}
{resolutionError && (
<Alert severity="warning" sx={{ mt: 1 }}>
Erreur de résolution des propriétés
</Alert>
)}
</Box>
{/* Alertes d'erreur globales */}
{hasErrors && (
<Box sx={{ p: 2 }}>
<Alert severity="error" icon={<ErrorIcon />}>
Cette étape contient des erreurs qui empêchent l'exécution du workflow.
</Alert>
</Box>
)}
{/* Paramètres - Rendu conditionnel selon l'état */}
<Box sx={{ flex: 1, overflow: 'auto', p: 2 }}>
{(() => {
const displayState = getDisplayState();
switch (displayState.type) {
case 'loading':
if (displayState.subtype === 'resolving') {
return (
<StepResolutionLoading
message="Résolution des propriétés d'étape..."
canCancel={false}
/>
);
} else if (displayState.subtype === 'loading-vwb') {
return (
<VWBActionLoading
message="Chargement de l'action VWB..."
canCancel={true}
onCancel={() => {
setIsLoadingVWBAction(false);
setVwbActionError(new Error('Chargement annulé par l\'utilisateur'));
}}
/>
);
}
break;
case 'empty':
return (
<EmptyStateMessage
stepType={selectedStep.type}
reason={displayState.reason || 'unknown-type'}
error={displayState.error}
onRetry={displayState.reason === 'resolution-failed' ? handleRefreshResolution : undefined}
onRefresh={displayState.reason === 'vwb-not-found' ? handleRefreshResolution : undefined}
/>
);
case 'vwb-properties':
return (
<VWBActionProperties
key={`vwb-props-${selectedStep.id}`}
action={vwbAction!}
parameters={localParameters}
variables={variables as Variable[]}
onParameterChange={handleVWBParameterChange}
onValidationChange={handleVWBValidationChange}
/>
);
case 'standard-parameters':
return (
<StandardParametersEditor
stepType={selectedStep.type}
parameterConfigs={parameterConfigs}
parameters={localParameters}
variables={variables as Variable[]}
onParameterChange={handleParameterChange}
onValidationChange={handleValidationChange}
onVisualSelection={handleVisualSelection}
disabled={autoSave.saveState.isSaving}
showValidationSummary={true}
groupParameters={parameterConfigs.length > 5}
/>
);
default:
return (
<EmptyStateMessage
stepType={selectedStep.type}
reason="unknown-type"
onRetry={handleRefreshResolution}
/>
);
}
})()}
{/* Indicateur de sauvegarde */}
{autoSave.saveState.isSaving && (
<Box sx={{ mt: 2 }}>
<Alert severity="info" sx={{ display: 'flex', alignItems: 'center' }}>
<CircularProgress size={16} sx={{ mr: 1 }} />
<Typography variant="body2">
Sauvegarde en cours...
</Typography>
</Alert>
</Box>
)}
{/* Erreur de sauvegarde */}
{autoSave.saveState.error && (
<Box sx={{ mt: 2 }}>
<Alert
severity="error"
action={
<Button
size="small"
onClick={() => autoSave.resetError()}
>
Ignorer
</Button>
}
>
<Typography variant="body2">
Erreur de sauvegarde: {autoSave.saveState.error.message}
</Typography>
</Alert>
</Box>
)}
</Box>
{/* Panneau de debug en mode développement */}
{process.env.NODE_ENV === 'development' && (
<Box sx={{ borderTop: '1px solid #e0e0e0', p: 1 }}>
<Button
size="small"
startIcon={<BugReportIcon />}
onClick={() => setIsDebugPanelVisible(!isDebugPanelVisible)}
variant="text"
>
Debug Panel
</Button>
</Box>
)}
{/* Composant VisualSelector */}
{selectedStep && (
<VisualSelector
isOpen={isVisualSelectorOpen}
stepId={selectedStep.id}
onClose={() => setIsVisualSelectorOpen(false)}
onElementSelected={handleElementSelected}
/>
)}
{/* Composant DebugPanel (mode développement) */}
{process.env.NODE_ENV === 'development' && (
<DebugPanel
selectedStep={selectedStep}
variables={variables as Variable[]}
isVisible={isDebugPanelVisible}
onToggleVisibility={setIsDebugPanelVisible}
/>
)}
</Box>
);
};
// Mémorisation du composant PropertiesPanel pour éviter les re-rendus inutiles
export default memo(PropertiesPanel, (prevProps, nextProps) => {
return (
prevProps.selectedStep?.id === nextProps.selectedStep?.id &&
JSON.stringify(prevProps.selectedStep?.data) === JSON.stringify(nextProps.selectedStep?.data) &&
JSON.stringify(prevProps.variables) === JSON.stringify(nextProps.variables) &&
prevProps.onParameterChange === nextProps.onParameterChange &&
prevProps.onVisualSelection === nextProps.onVisualSelection
);
});

View File

@@ -0,0 +1,26 @@
/* Styles pour le Composant de Capture d'Écran Réelle */
/* Auteur : Dom, Alice, Kiro - 8 janvier 2026 */
.real-screen-capture {
padding: 24px;
}
.real-screen-capture__screenshot {
max-width: 100%;
height: auto;
cursor: crosshair;
border: 1px solid #ddd;
border-radius: 8px;
}
.real-screen-capture__element-overlay {
position: absolute;
border: 2px solid #ff4444;
background-color: rgba(255, 68, 68, 0.1);
cursor: pointer;
transition: background-color 0.2s ease;
}
.real-screen-capture__element-overlay:hover {
background-color: rgba(255, 68, 68, 0.2);
}

View File

@@ -0,0 +1,424 @@
/**
* Composant RealScreenCapture - Capture d'écran en temps réel pour sélection visuelle
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*
* Ce composant fournit une interface de capture d'écran en temps réel pour permettre
* aux utilisateurs de sélectionner visuellement des éléments sur l'écran.
*/
import React, { useState, useCallback, useEffect, memo } from 'react';
import {
Box,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Typography,
Alert,
CircularProgress,
IconButton,
Tooltip,
} from '@mui/material';
import {
CameraAlt as CameraIcon,
Close as CloseIcon,
Refresh as RefreshIcon,
Fullscreen as FullscreenIcon,
CropFree as SelectIcon,
} from '@mui/icons-material';
// Import des services
import { realScreenCaptureService } from '../../services/realScreenCaptureService';
// Import des types
import { VisualSelection } from '../../types';
/**
* Props du composant RealScreenCapture
*/
export interface RealScreenCaptureProps {
isOpen: boolean;
onClose: () => void;
onElementSelected: (selection: VisualSelection) => void;
stepId?: string;
className?: string;
}
/**
* État de capture
*/
interface CaptureState {
isCapturing: boolean;
isSelecting: boolean;
screenshot: string | null;
error: Error | null;
selectedElement: VisualSelection | null;
}
/**
* Composant RealScreenCapture
*/
const RealScreenCapture: React.FC<RealScreenCaptureProps> = ({
isOpen,
onClose,
onElementSelected,
stepId,
className
}) => {
// État de capture
const [captureState, setCaptureState] = useState<CaptureState>({
isCapturing: false,
isSelecting: false,
screenshot: null,
error: null,
selectedElement: null
});
/**
* Démarre la capture d'écran
*/
const startCapture = useCallback(async () => {
setCaptureState(prev => ({
...prev,
isCapturing: true,
error: null,
screenshot: null
}));
try {
console.log('📸 [RealScreenCapture] Début de capture d\'écran');
// Utiliser le service de capture d'écran réelle
const response = await realScreenCaptureService.captureWithElements(0, false);
if (response?.success && response.screenshot) {
// Le backend peut retourner soit une data URI complète, soit juste le base64
const screenshotData = response.screenshot.startsWith('data:')
? response.screenshot
: `data:image/png;base64,${response.screenshot}`;
setCaptureState(prev => ({
...prev,
isCapturing: false,
screenshot: screenshotData,
isSelecting: true
}));
console.log('✅ [RealScreenCapture] Capture d\'écran réussie');
} else {
throw new Error(response?.error || 'Échec de la capture d\'écran');
}
} catch (error) {
const errorObj = error instanceof Error ? error : new Error(String(error));
console.error('❌ [RealScreenCapture] Erreur de capture:', errorObj);
setCaptureState(prev => ({
...prev,
isCapturing: false,
error: errorObj
}));
}
}, []);
/**
* Gère la sélection d'un élément sur la capture
*/
const handleElementSelection = useCallback((event: React.MouseEvent<HTMLImageElement>) => {
if (!captureState.isSelecting || !captureState.screenshot) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// Calculer les coordonnées relatives
const relativeX = x / rect.width;
const relativeY = y / rect.height;
const selection: VisualSelection = {
id: `selection_${Date.now()}`,
screenshot: captureState.screenshot,
boundingBox: {
x: Math.round(relativeX * 100) / 100,
y: Math.round(relativeY * 100) / 100,
width: 0.01, // Point de clic, taille minimale
height: 0.01
},
description: `Point de clic (${relativeX.toFixed(3)}, ${relativeY.toFixed(3)})`,
metadata: {
capture_method: 'real_screen_capture',
capture_timestamp: new Date().toISOString(),
screen_resolution: {
width: rect.width,
height: rect.height
}
}
};
setCaptureState(prev => ({
...prev,
selectedElement: selection,
isSelecting: false
}));
console.log('🎯 [RealScreenCapture] Élément sélectionné:', selection);
}, [captureState.isSelecting, captureState.screenshot, stepId]);
/**
* Confirme la sélection
*/
const confirmSelection = useCallback(() => {
if (captureState.selectedElement) {
onElementSelected(captureState.selectedElement);
handleClose();
}
}, [captureState.selectedElement, onElementSelected]);
/**
* Ferme le dialogue et remet à zéro l'état
*/
const handleClose = useCallback(() => {
setCaptureState({
isCapturing: false,
isSelecting: false,
screenshot: null,
error: null,
selectedElement: null
});
onClose();
}, [onClose]);
/**
* Recommence la capture
*/
const restartCapture = useCallback(() => {
setCaptureState(prev => ({
...prev,
screenshot: null,
selectedElement: null,
error: null,
isSelecting: false
}));
startCapture();
}, [startCapture]);
/**
* Effet pour démarrer automatiquement la capture à l'ouverture
*/
useEffect(() => {
if (isOpen && !captureState.screenshot && !captureState.isCapturing) {
startCapture();
}
}, [isOpen, captureState.screenshot, captureState.isCapturing, startCapture]);
/**
* Rendu de l'état de capture
*/
const renderCaptureState = () => {
if (captureState.isCapturing) {
return (
<Box sx={{ textAlign: 'center', py: 4 }}>
<CircularProgress size={48} sx={{ mb: 2 }} />
<Typography variant="h6" gutterBottom>
Capture d'écran en cours...
</Typography>
<Typography variant="body2" color="text.secondary">
Veuillez patienter pendant la capture de votre écran
</Typography>
</Box>
);
}
if (captureState.error) {
return (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Alert severity="error" sx={{ mb: 2 }}>
<Typography variant="body2">
Erreur de capture: {captureState.error.message}
</Typography>
</Alert>
<Button
variant="contained"
startIcon={<RefreshIcon />}
onClick={startCapture}
>
Réessayer
</Button>
</Box>
);
}
if (!captureState.screenshot) {
return (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Button
variant="contained"
size="large"
startIcon={<CameraIcon />}
onClick={startCapture}
>
Capturer l'écran
</Button>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
Cliquez pour prendre une capture d'écran et sélectionner un élément
</Typography>
</Box>
);
}
return null;
};
/**
* Rendu de l'interface de sélection
*/
const renderSelectionInterface = () => {
if (!captureState.screenshot) {
return null;
}
return (
<Box sx={{ position: 'relative' }}>
{/* Instructions */}
<Alert
severity={captureState.isSelecting ? "info" : "success"}
sx={{ mb: 2 }}
>
<Typography variant="body2">
{captureState.isSelecting
? "Cliquez sur l'élément que vous souhaitez sélectionner"
: "Élément sélectionné ! Confirmez votre choix ou recommencez."
}
</Typography>
</Alert>
{/* Image de capture avec sélection */}
<Box
sx={{
position: 'relative',
border: '2px solid',
borderColor: captureState.isSelecting ? 'primary.main' : 'success.main',
borderRadius: 1,
overflow: 'hidden',
cursor: captureState.isSelecting ? 'crosshair' : 'default'
}}
>
<img
src={captureState.screenshot}
alt="Capture d'écran pour sélection"
style={{
width: '100%',
height: 'auto',
maxHeight: '60vh',
objectFit: 'contain'
}}
onClick={handleElementSelection}
/>
{/* Indicateur de sélection */}
{captureState.selectedElement && (
<Box
sx={{
position: 'absolute',
left: `${captureState.selectedElement.boundingBox.x * 100}%`,
top: `${captureState.selectedElement.boundingBox.y * 100}%`,
width: 20,
height: 20,
borderRadius: '50%',
backgroundColor: 'success.main',
border: '3px solid white',
transform: 'translate(-50%, -50%)',
boxShadow: 2,
zIndex: 1
}}
/>
)}
</Box>
{/* Informations de sélection */}
{captureState.selectedElement && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Coordonnées sélectionnées:
</Typography>
<Typography variant="body2" color="text.secondary">
X: {(captureState.selectedElement.boundingBox.x * 100).toFixed(1)}%,
Y: {(captureState.selectedElement.boundingBox.y * 100).toFixed(1)}%
</Typography>
</Box>
)}
</Box>
);
};
return (
<Dialog
open={isOpen}
onClose={handleClose}
maxWidth="lg"
fullWidth
className={className}
PaperProps={{
sx: { minHeight: '60vh' }
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<SelectIcon sx={{ mr: 1 }} />
<Typography variant="h6">
Sélection visuelle d'élément
</Typography>
</Box>
<IconButton onClick={handleClose} size="small">
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent>
{renderCaptureState()}
{renderSelectionInterface()}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={handleClose} color="inherit">
Annuler
</Button>
{captureState.screenshot && (
<Tooltip title="Prendre une nouvelle capture">
<Button
startIcon={<RefreshIcon />}
onClick={restartCapture}
color="primary"
>
Nouvelle capture
</Button>
</Tooltip>
)}
{captureState.selectedElement && (
<Button
variant="contained"
onClick={confirmSelection}
startIcon={<SelectIcon />}
>
Confirmer la sélection
</Button>
)}
</DialogActions>
</Dialog>
);
};
/**
* Export du composant mémorisé
*/
export default memo(RealScreenCapture, (prevProps, nextProps) => {
return (
prevProps.isOpen === nextProps.isOpen &&
prevProps.stepId === nextProps.stepId
);
});

View File

@@ -0,0 +1,224 @@
/**
* Composant de Test - Chargement du Catalogue VWB
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Composant de test pour diagnostiquer le chargement du catalogue d'actions VisionOnly
*/
import React, { useEffect, useState } from 'react';
import { Box, Typography, Alert, CircularProgress, List, ListItem, ListItemText, Chip } from '@mui/material';
import { catalogService } from '../services/catalogService';
import { useCatalogActions } from '../hooks/useCatalogActions';
import { VWBCatalogAction } from '../types/catalog';
const TestCatalogLoader: React.FC = () => {
const [directServiceTest, setDirectServiceTest] = useState<{
loading: boolean;
actions: VWBCatalogAction[];
error: string | null;
}>({
loading: true,
actions: [],
error: null,
});
// Test direct du service
useEffect(() => {
const testDirectService = async () => {
try {
console.log('🧪 Test direct du catalogService...');
const result = await catalogService.getActions();
console.log('✅ Service direct réussi:', result);
setDirectServiceTest({
loading: false,
actions: result.actions as VWBCatalogAction[],
error: null,
});
} catch (error) {
console.error('❌ Service direct échoué:', error);
setDirectServiceTest({
loading: false,
actions: [],
error: error instanceof Error ? error.message : 'Erreur inconnue',
});
}
};
testDirectService();
}, []);
// Test du hook
const {
state: hookState,
filteredActions: hookActions,
stats: hookStats,
actions: hookMethods,
} = useCatalogActions({
autoLoad: true,
});
return (
<Box sx={{ p: 3, maxWidth: 800 }}>
<Typography variant="h4" gutterBottom>
🧪 Test de Chargement du Catalogue VWB
</Typography>
{/* Test du service direct */}
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom>
1. Test Direct du Service catalogService
</Typography>
{directServiceTest.loading ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<CircularProgress size={20} />
<Typography>Chargement...</Typography>
</Box>
) : directServiceTest.error ? (
<Alert severity="error">
Erreur service direct: {directServiceTest.error}
</Alert>
) : (
<Alert severity="success">
Service direct réussi: {directServiceTest.actions.length} actions chargées
</Alert>
)}
{directServiceTest.actions.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Actions chargées par le service direct:
</Typography>
<List dense>
{directServiceTest.actions.slice(0, 3).map((action) => (
<ListItem key={action.id}>
<ListItemText
primary={`${action.icon} ${action.name}`}
secondary={`${action.category} - ${action.description.substring(0, 60)}...`}
/>
</ListItem>
))}
</List>
</Box>
)}
</Box>
{/* Test du hook */}
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom>
2. Test du Hook useCatalogActions
</Typography>
{hookState.isLoading ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<CircularProgress size={20} />
<Typography>Hook en cours de chargement...</Typography>
</Box>
) : hookState.error ? (
<Alert severity="error">
Erreur hook: {hookState.error}
</Alert>
) : (
<Alert severity="success">
Hook réussi: {hookState.actions.length} actions, {hookState.categories.length} catégories
</Alert>
)}
<Box sx={{ mt: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip label={`Actions: ${hookState.actions.length}`} color="primary" />
<Chip label={`Catégories: ${hookState.categories.length}`} color="secondary" />
<Chip label={`En ligne: ${hookState.isOnline ? 'Oui' : 'Non'}`} color={hookState.isOnline ? 'success' : 'error'} />
<Chip label={`Dernière MAJ: ${hookState.lastUpdate?.toLocaleTimeString() || 'Jamais'}`} />
</Box>
{hookActions.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Actions filtrées par le hook:
</Typography>
<List dense>
{hookActions.slice(0, 3).map((action) => (
<ListItem key={action.id}>
<ListItemText
primary={`${action.icon} ${action.name}`}
secondary={`${action.category} - ${action.description.substring(0, 60)}...`}
/>
</ListItem>
))}
</List>
</Box>
)}
</Box>
{/* Statistiques */}
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom>
3. Statistiques du Hook
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip label={`Total: ${hookStats.totalActions}`} />
<Chip label={`Complexité: ${hookStats.averageComplexity}`} />
<Chip label={`Statut: ${hookStats.onlineStatus ? 'En ligne' : 'Hors ligne'}`} />
</Box>
{Object.keys(hookStats.actionsByCategory).length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Actions par catégorie:
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{Object.entries(hookStats.actionsByCategory).map(([category, count]) => (
<Chip key={category} label={`${category}: ${count}`} variant="outlined" />
))}
</Box>
</Box>
)}
</Box>
{/* Actions du hook */}
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom>
4. Actions Disponibles du Hook
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<button onClick={hookMethods.reload}>🔄 Recharger</button>
<button onClick={hookMethods.clearCache}>🗑 Vider Cache</button>
<button onClick={hookMethods.checkHealth}> Vérifier Santé</button>
</Box>
</Box>
{/* Diagnostic */}
<Box>
<Typography variant="h6" gutterBottom>
5. Diagnostic
</Typography>
{directServiceTest.actions.length > 0 && hookState.actions.length > 0 ? (
<Alert severity="success">
Tout fonctionne correctement ! Le service et le hook chargent les actions.
Le problème peut être dans l'affichage de la Palette.
</Alert>
) : directServiceTest.actions.length > 0 && hookState.actions.length === 0 ? (
<Alert severity="warning">
Le service fonctionne mais le hook ne charge pas les actions.
Problème dans le hook useCatalogActions.
</Alert>
) : directServiceTest.actions.length === 0 ? (
<Alert severity="error">
Le service ne charge pas les actions.
Problème de communication avec le backend.
</Alert>
) : (
<Alert severity="info">
🔄 Tests en cours...
</Alert>
)}
</Box>
</Box>
);
};
export default TestCatalogLoader;

View File

@@ -0,0 +1,428 @@
/**
* Composant de Test Properties Panel - Validation de l'affichage des propriétés VWB
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Ce composant teste l'affichage des propriétés d'étapes VWB pour identifier
* et corriger les problèmes d'intégration.
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
Card,
CardContent,
Alert,
Divider,
Chip,
List,
ListItem,
ListItemText,
ListItemIcon,
} from '@mui/material';
import {
PlayArrow as PlayIcon,
CheckCircle as CheckIcon,
Error as ErrorIcon,
Info as InfoIcon,
} from '@mui/icons-material';
// Import des composants à tester
import PropertiesPanel from './PropertiesPanel';
import { useVWBStepIntegration, useIsVWBStep, useVWBActionId } from '../hooks/useVWBStepIntegration';
import { catalogService } from '../services/catalogService';
// Import des types
import { Step, StepExecutionState, Variable, VariableType } from '../types';
import { VWBCatalogAction } from '../types/catalog';
interface TestResult {
test: string;
success: boolean;
message: string;
details?: any;
}
/**
* Composant de test pour les propriétés VWB
*/
const TestPropertiesPanel: React.FC = () => {
const [testResults, setTestResults] = useState<TestResult[]>([]);
const [isRunning, setIsRunning] = useState(false);
const [testStep, setTestStep] = useState<Step | null>(null);
const [catalogActions, setCatalogActions] = useState<VWBCatalogAction[]>([]);
const [selectedAction, setSelectedAction] = useState<string>('click_anchor');
// Hooks VWB
const { methods: vwbMethods } = useVWBStepIntegration();
const isVWBStep = useIsVWBStep(testStep);
const vwbActionId = useVWBActionId(testStep);
// Variables de test
const testVariables: Variable[] = [
{
id: 'var1',
name: 'username',
value: 'test@example.com',
type: 'text' as VariableType,
description: 'Nom d\'utilisateur de test',
},
{
id: 'var2',
name: 'password',
value: '********',
type: 'text' as VariableType,
description: 'Mot de passe de test',
},
];
// Charger les actions du catalogue au démarrage
useEffect(() => {
const loadCatalogActions = async () => {
try {
const { actions } = await catalogService.getActions();
setCatalogActions(actions);
console.log('Actions du catalogue chargées:', actions.length);
} catch (error) {
console.error('Erreur chargement catalogue:', error);
}
};
loadCatalogActions();
}, []);
/**
* Créer une étape VWB de test
*/
const createTestVWBStep = async (actionId: string): Promise<Step | null> => {
try {
// Utiliser le hook d'intégration pour créer l'étape
const step = await vwbMethods.createVWBStep(actionId, { x: 100, y: 100 });
if (step) {
console.log('Étape VWB créée:', step);
return step;
} else {
throw new Error('Impossible de créer l\'étape VWB');
}
} catch (error) {
console.error('Erreur création étape VWB:', error);
return null;
}
};
/**
* Exécuter tous les tests
*/
const runAllTests = async () => {
setIsRunning(true);
setTestResults([]);
const results: TestResult[] = [];
try {
// Test 1: Chargement du catalogue
results.push({
test: 'Chargement du catalogue',
success: catalogActions.length > 0,
message: catalogActions.length > 0
? `${catalogActions.length} actions chargées`
: 'Aucune action chargée',
details: { actionCount: catalogActions.length }
});
// Test 2: Création d'étape VWB
const testStep = await createTestVWBStep(selectedAction);
const stepCreated = testStep !== null;
results.push({
test: 'Création d\'étape VWB',
success: stepCreated,
message: stepCreated
? `Étape ${selectedAction} créée avec succès`
: `Échec création étape ${selectedAction}`,
details: testStep
});
if (stepCreated && testStep) {
setTestStep(testStep);
// Test 3: Détection VWB
const isDetectedAsVWB = testStep.data.isVWBCatalogAction === true;
results.push({
test: 'Détection étape VWB',
success: isDetectedAsVWB,
message: isDetectedAsVWB
? 'Étape correctement détectée comme VWB'
: 'Étape non détectée comme VWB',
details: {
isVWBCatalogAction: testStep.data.isVWBCatalogAction,
vwbActionId: testStep.data.vwbActionId
}
});
// Test 4: Hook de détection
const hookDetection = useIsVWBStep(testStep);
results.push({
test: 'Hook de détection VWB',
success: hookDetection,
message: hookDetection
? 'Hook useIsVWBStep fonctionne'
: 'Hook useIsVWBStep défaillant',
details: { hookResult: hookDetection }
});
// Test 5: Récupération ID action
const actionIdFromHook = useVWBActionId(testStep);
results.push({
test: 'Récupération ID action VWB',
success: actionIdFromHook === selectedAction,
message: actionIdFromHook === selectedAction
? `ID action correct: ${actionIdFromHook}`
: `ID action incorrect: ${actionIdFromHook} (attendu: ${selectedAction})`,
details: {
expected: selectedAction,
actual: actionIdFromHook
}
});
// Test 6: Chargement détails action
try {
const actionDetails = await catalogService.getActionDetails(selectedAction);
const actionLoaded = actionDetails !== null;
results.push({
test: 'Chargement détails action',
success: actionLoaded,
message: actionLoaded
? `Détails action ${selectedAction} chargés`
: `Échec chargement détails ${selectedAction}`,
details: actionDetails
});
} catch (error) {
results.push({
test: 'Chargement détails action',
success: false,
message: `Erreur: ${error instanceof Error ? error.message : 'Erreur inconnue'}`,
details: { error }
});
}
// Test 7: Validation étape
try {
const isValid = await vwbMethods.validateVWBStep(testStep);
results.push({
test: 'Validation étape VWB',
success: true, // Le test réussit si la validation s'exécute
message: isValid
? 'Étape valide'
: 'Étape invalide (normal pour étape vide)',
details: { isValid }
});
} catch (error) {
results.push({
test: 'Validation étape VWB',
success: false,
message: `Erreur validation: ${error instanceof Error ? error.message : 'Erreur inconnue'}`,
details: { error }
});
}
}
} catch (error) {
results.push({
test: 'Erreur générale',
success: false,
message: `Erreur: ${error instanceof Error ? error.message : 'Erreur inconnue'}`,
details: { error }
});
}
setTestResults(results);
setIsRunning(false);
};
/**
* Gestionnaire de changement de paramètre (pour le test)
*/
const handleParameterChange = (stepId: string, paramName: string, value: any) => {
console.log('Changement paramètre:', { stepId, paramName, value });
if (testStep && testStep.id === stepId) {
const updatedStep = {
...testStep,
data: {
...testStep.data,
parameters: {
...testStep.data.parameters,
[paramName]: value
}
}
};
setTestStep(updatedStep);
}
};
/**
* Gestionnaire de sélection visuelle (pour le test)
*/
const handleVisualSelection = (stepId: string) => {
console.log('Sélection visuelle pour étape:', stepId);
};
// Calculer le statut global des tests
const allTestsPassed = testResults.length > 0 && testResults.every(r => r.success);
const hasFailures = testResults.some(r => !r.success);
return (
<Box sx={{ p: 3, maxWidth: 1200, margin: '0 auto' }}>
<Typography variant="h4" gutterBottom>
Test des Propriétés d'Étapes VWB
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Ce composant teste l'affichage des propriétés d'étapes VWB pour identifier
et corriger les problèmes d'intégration.
</Typography>
{/* Contrôles de test */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Configuration du Test
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mb: 2 }}>
<Typography variant="body2">Action à tester:</Typography>
{catalogActions.map(action => (
<Chip
key={action.id}
label={action.name}
variant={selectedAction === action.id ? 'filled' : 'outlined'}
onClick={() => setSelectedAction(action.id)}
size="small"
/>
))}
</Box>
<Button
variant="contained"
startIcon={<PlayIcon />}
onClick={runAllTests}
disabled={isRunning || catalogActions.length === 0}
size="large"
>
{isRunning ? 'Tests en cours...' : 'Exécuter les Tests'}
</Button>
</CardContent>
</Card>
{/* Résultats des tests */}
{testResults.length > 0 && (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Résultats des Tests
</Typography>
{/* Statut global */}
<Alert
severity={allTestsPassed ? 'success' : hasFailures ? 'error' : 'info'}
sx={{ mb: 2 }}
>
{allTestsPassed
? '🎉 Tous les tests sont réussis ! Les propriétés VWB devraient s\'afficher correctement.'
: hasFailures
? '❌ Certains tests ont échoué. Les propriétés VWB pourraient ne pas s\'afficher.'
: ' Tests en cours d\'exécution...'}
</Alert>
{/* Liste des résultats */}
<List>
{testResults.map((result, index) => (
<ListItem key={index}>
<ListItemIcon>
{result.success ? (
<CheckIcon color="success" />
) : (
<ErrorIcon color="error" />
)}
</ListItemIcon>
<ListItemText
primary={result.test}
secondary={result.message}
/>
</ListItem>
))}
</List>
</CardContent>
</Card>
)}
{/* Aperçu du Properties Panel */}
{testStep && (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Aperçu du Properties Panel
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Voici comment le Properties Panel devrait afficher les propriétés de l'étape VWB :
</Typography>
{/* Informations sur l'étape */}
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Informations sur l'étape :
</Typography>
<Chip label={`Type: ${testStep.type}`} size="small" sx={{ mr: 1 }} />
<Chip label={`VWB: ${isVWBStep ? 'Oui' : 'Non'}`} size="small" sx={{ mr: 1 }} />
<Chip label={`Action ID: ${vwbActionId || 'N/A'}`} size="small" />
</Box>
<Divider sx={{ my: 2 }} />
{/* Properties Panel en action */}
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1 }}>
<PropertiesPanel
selectedStep={testStep}
variables={testVariables}
onParameterChange={handleParameterChange}
onVisualSelection={handleVisualSelection}
/>
</Box>
</CardContent>
</Card>
)}
{/* Instructions */}
<Card sx={{ mt: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Instructions
</Typography>
<Typography variant="body2" paragraph>
1. <strong>Exécuter les tests</strong> : Cliquez sur "Exécuter les Tests" pour vérifier l'intégration VWB
</Typography>
<Typography variant="body2" paragraph>
2. <strong>Vérifier les résultats</strong> : Tous les tests doivent être verts pour un fonctionnement correct
</Typography>
<Typography variant="body2" paragraph>
3. <strong>Tester l'interface</strong> : L'aperçu du Properties Panel montre comment les propriétés s'affichent
</Typography>
<Typography variant="body2" paragraph>
4. <strong>Déboguer si nécessaire</strong> : Si des tests échouent, vérifiez les détails dans la console
</Typography>
</CardContent>
</Card>
</Box>
);
};
export default TestPropertiesPanel;

View File

@@ -0,0 +1,220 @@
/**
* Composant Test Intégration VWB - Validation de l'affichage des propriétés
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
Card,
CardContent,
Alert,
List,
ListItem,
ListItemText,
ListItemIcon,
Divider,
} from '@mui/material';
import {
CheckCircle as CheckIcon,
Error as ErrorIcon,
PlayArrow as PlayIcon,
} from '@mui/icons-material';
// Import des composants à tester
import PropertiesPanel from './PropertiesPanel';
import { useVWBStepIntegration, useIsVWBStep, useVWBActionId } from '../hooks/useVWBStepIntegration';
import { catalogService } from '../services/catalogService';
// Import des types
import { Step, StepExecutionState, Variable, VariableType } from '../types';
const VWBIntegrationTest: React.FC = () => {
const [testStep, setTestStep] = useState<Step | null>(null);
const [testResults, setTestResults] = useState<Array<{test: string, success: boolean, message: string}>>([]);
const [isRunning, setIsRunning] = useState(false);
// Hooks VWB
const { methods: vwbMethods } = useVWBStepIntegration();
const isVWBStep = useIsVWBStep(testStep);
const vwbActionId = useVWBActionId(testStep);
// Variables de test
const testVariables: Variable[] = [
{
id: 'var1',
name: 'test_var',
value: 'test_value',
type: 'text' as VariableType,
description: 'Variable de test',
},
];
const runIntegrationTest = async () => {
setIsRunning(true);
setTestResults([]);
const results: Array<{test: string, success: boolean, message: string}> = [];
try {
// Test 1: Chargement du catalogue
const { actions } = await catalogService.getActions();
results.push({
test: 'Chargement catalogue',
success: actions.length > 0,
message: `${actions.length} actions chargées`
});
// Test 2: Création d'étape VWB
if (actions.length > 0) {
const firstAction = actions[0];
const step = await vwbMethods.createVWBStep(firstAction.id, { x: 100, y: 100 });
if (step) {
setTestStep(step);
results.push({
test: 'Création étape VWB',
success: true,
message: `Étape ${firstAction.id} créée`
});
// Test 3: Détection VWB
const isDetected = step.data.isVWBCatalogAction === true;
results.push({
test: 'Détection VWB',
success: isDetected,
message: isDetected ? 'Étape détectée comme VWB' : 'Étape non détectée'
});
// Test 4: Hook de détection
const hookResult = useIsVWBStep(step);
results.push({
test: 'Hook détection',
success: hookResult,
message: hookResult ? 'Hook fonctionne' : 'Hook défaillant'
});
} else {
results.push({
test: 'Création étape VWB',
success: false,
message: 'Échec création étape'
});
}
}
} catch (error) {
results.push({
test: 'Erreur générale',
success: false,
message: error instanceof Error ? error.message : 'Erreur inconnue'
});
}
setTestResults(results);
setIsRunning(false);
};
const handleParameterChange = (stepId: string, paramName: string, value: any) => {
console.log('Changement paramètre:', { stepId, paramName, value });
};
const handleVisualSelection = (stepId: string) => {
console.log('Sélection visuelle:', stepId);
};
const allTestsPassed = testResults.length > 0 && testResults.every(r => r.success);
return (
<Box sx={{ p: 3, maxWidth: 1200, margin: '0 auto' }}>
<Typography variant="h4" gutterBottom>
Test d'Intégration VWB
</Typography>
<Card sx={{ mb: 3 }}>
<CardContent>
<Button
variant="contained"
startIcon={<PlayIcon />}
onClick={runIntegrationTest}
disabled={isRunning}
size="large"
>
{isRunning ? 'Tests en cours...' : 'Exécuter les Tests'}
</Button>
</CardContent>
</Card>
{testResults.length > 0 && (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Résultats des Tests
</Typography>
<Alert severity={allTestsPassed ? 'success' : 'error'} sx={{ mb: 2 }}>
{allTestsPassed
? 'Tous les tests réussis ! L\'intégration VWB fonctionne.'
: 'Certains tests ont échoué. Vérifiez l\'intégration.'}
</Alert>
<List>
{testResults.map((result, index) => (
<ListItem key={index}>
<ListItemIcon>
{result.success ? (
<CheckIcon color="success" />
) : (
<ErrorIcon color="error" />
)}
</ListItemIcon>
<ListItemText
primary={result.test}
secondary={result.message}
/>
</ListItem>
))}
</List>
</CardContent>
</Card>
)}
{testStep && (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Properties Panel Test
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Étape VWB: {testStep.type} (ID: {testStep.id})
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Détection VWB: {isVWBStep ? 'Oui' : 'Non'}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Action ID: {vwbActionId || 'N/A'}
</Typography>
<Divider sx={{ my: 2 }} />
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1 }}>
<PropertiesPanel
selectedStep={testStep}
variables={testVariables}
onParameterChange={handleParameterChange}
onVisualSelection={handleVisualSelection}
/>
</Box>
</CardContent>
</Card>
)}
</Box>
);
};
export default VWBIntegrationTest;

View File

@@ -0,0 +1,522 @@
/**
* Composant Validateur - Validation et feedback visuel pour les workflows
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce composant fournit la validation en temps réel des workflows avec
* indicateurs visuels d'erreur, détection de cycles et prévention d'exécution.
*/
import React, { useMemo, useCallback } from 'react';
import {
Box,
Alert,
AlertTitle,
List,
ListItem,
ListItemIcon,
ListItemText,
Chip,
Typography,
Collapse,
IconButton,
} from '@mui/material';
import {
Error as ErrorIcon,
Warning as WarningIcon,
Info as InfoIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
Block as BlockIcon,
Link as LinkIcon,
Loop as LoopIcon,
} from '@mui/icons-material';
// Import des types partagés
import {
Workflow,
Step,
WorkflowConnection,
ValidationError,
Variable,
} from '../../types';
interface ValidatorProps {
workflow: Workflow;
variables: Variable[];
onStepHighlight?: (stepId: string, highlight: boolean) => void;
}
interface ValidationResult {
isValid: boolean;
errors: ValidationIssue[];
warnings: ValidationIssue[];
canExecute: boolean;
}
interface ValidationIssue {
id: string;
type: 'error' | 'warning' | 'info';
category: 'missing_parameter' | 'disconnected_step' | 'cycle_detected' | 'invalid_reference' | 'execution_blocked';
stepId?: string;
message: string;
description?: string;
severity: 'critical' | 'high' | 'medium' | 'low';
}
// Fonctions utilitaires (déclarées avant leur utilisation)
// Obtenir les paramètres requis pour un type d'étape
const getRequiredParameters = (stepType: string): string[] => {
const parameterMap: Record<string, string[]> = {
click: ['target'],
type: ['target', 'text'],
wait: ['duration'],
condition: ['condition'],
extract: ['target', 'attribute'],
navigate: ['url'],
scroll: ['direction'],
screenshot: [],
};
return parameterMap[stepType] || [];
};
// Extraire les références de variables d'un texte
const extractVariableReferences = (text: string): string[] => {
const pattern = /\$\{([^}]+)\}/g;
const matches: string[] = [];
let match;
while ((match = pattern.exec(text)) !== null) {
matches.push(match[1]);
}
return matches;
};
// Validation des paramètres d'une étape
const validateStepParameters = (step: Step, variables: Variable[]): ValidationIssue[] => {
const issues: ValidationIssue[] = [];
// Récupérer les paramètres requis selon le type d'étape
const requiredParams = getRequiredParameters(step.type);
requiredParams.forEach(paramName => {
const paramValue = step.data.parameters?.[paramName];
if (paramValue === undefined || paramValue === null || paramValue === '') {
issues.push({
id: `missing_param_${step.id}_${paramName}`,
type: 'error',
category: 'missing_parameter',
stepId: step.id,
message: `Paramètre "${paramName}" manquant`,
description: `L'étape "${step.name}" nécessite le paramètre "${paramName}"`,
severity: 'high',
});
}
});
return issues;
};
// Trouver les étapes déconnectées
const findDisconnectedSteps = (steps: Step[], connections: WorkflowConnection[]): string[] => {
if (steps.length <= 1) return [];
const connectedSteps = new Set<string>();
// Ajouter toutes les étapes connectées
connections.forEach(conn => {
connectedSteps.add(conn.source);
connectedSteps.add(conn.target);
});
// Si aucune connexion, toutes les étapes sauf la première sont déconnectées
if (connections.length === 0 && steps.length > 1) {
return steps.slice(1).map(step => step.id);
}
// Trouver les étapes non connectées
return steps
.filter(step => !connectedSteps.has(step.id))
.map(step => step.id);
};
// Détecter les cycles dans le workflow
const detectCycles = (steps: Step[], connections: WorkflowConnection[]): string[][] => {
const cycles: string[][] = [];
const visited = new Set<string>();
const recursionStack = new Set<string>();
// Construire le graphe d'adjacence
const graph: Record<string, string[]> = {};
steps.forEach(step => {
graph[step.id] = [];
});
connections.forEach(conn => {
if (graph[conn.source]) {
graph[conn.source].push(conn.target);
}
});
// DFS pour détecter les cycles
const dfs = (nodeId: string, path: string[]): void => {
if (recursionStack.has(nodeId)) {
// Cycle détecté
const cycleStart = path.indexOf(nodeId);
if (cycleStart >= 0) {
cycles.push([...path.slice(cycleStart), nodeId]);
}
return;
}
if (visited.has(nodeId)) return;
visited.add(nodeId);
recursionStack.add(nodeId);
path.push(nodeId);
const neighbors = graph[nodeId] || [];
neighbors.forEach(neighbor => {
dfs(neighbor, [...path]);
});
recursionStack.delete(nodeId);
};
// Lancer DFS depuis chaque nœud non visité
steps.forEach(step => {
if (!visited.has(step.id)) {
dfs(step.id, []);
}
});
return cycles;
};
// Valider les références de variables
const validateVariableReferences = (step: Step, variables: Variable[]): ValidationIssue[] => {
const issues: ValidationIssue[] = [];
const variableNames = new Set(variables.map(v => v.name));
// Extraire les références de variables des paramètres
const parameters = step.data.parameters || {};
Object.entries(parameters).forEach(([paramName, paramValue]) => {
if (typeof paramValue === 'string') {
const variableRefs = extractVariableReferences(paramValue);
variableRefs.forEach(varName => {
if (!variableNames.has(varName)) {
issues.push({
id: `invalid_ref_${step.id}_${paramName}_${varName}`,
type: 'error',
category: 'invalid_reference',
stepId: step.id,
message: `Variable "${varName}" non définie`,
description: `La variable "${varName}" utilisée dans "${paramName}" n'existe pas`,
severity: 'high',
});
}
});
}
});
return issues;
};
/**
* Composant Validateur
*/
const Validator: React.FC<ValidatorProps> = ({
workflow,
variables,
onStepHighlight,
}) => {
const [expandedSections, setExpandedSections] = React.useState<Set<string>>(new Set(['errors']));
// Gestionnaire d'événements clavier pour le validateur
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
switch (event.key) {
case 'Enter':
case ' ':
// Activer l'élément focalisé
if (event.target instanceof HTMLElement && event.target.getAttribute('role') === 'button') {
event.preventDefault();
event.target.click();
}
break;
case 'ArrowUp':
case 'ArrowDown':
// Navigation dans la liste des erreurs
event.preventDefault();
break;
case 'Escape':
// Fermer toutes les sections étendues
event.preventDefault();
setExpandedSections(new Set());
break;
}
}, []);
// Validation complète du workflow
const validationResult = useMemo((): ValidationResult => {
const errors: ValidationIssue[] = [];
const warnings: ValidationIssue[] = [];
// 1. Validation des paramètres manquants
workflow.steps.forEach(step => {
const stepErrors = validateStepParameters(step, variables);
errors.push(...stepErrors);
});
// 2. Détection des étapes déconnectées
const disconnectedSteps = findDisconnectedSteps(workflow.steps, workflow.connections);
disconnectedSteps.forEach(stepId => {
warnings.push({
id: `disconnected_${stepId}`,
type: 'warning',
category: 'disconnected_step',
stepId,
message: 'Étape déconnectée',
description: 'Cette étape n\'est pas connectée au flux principal du workflow',
severity: 'medium',
});
});
// 3. Détection de cycles
const cycles = detectCycles(workflow.steps, workflow.connections);
cycles.forEach((cycle, index) => {
errors.push({
id: `cycle_${index}`,
type: 'error',
category: 'cycle_detected',
message: 'Cycle détecté dans le workflow',
description: `Cycle impliquant les étapes: ${cycle.join(' → ')}`,
severity: 'critical',
});
});
// 4. Validation des références de variables
workflow.steps.forEach(step => {
const referenceErrors = validateVariableReferences(step, variables);
errors.push(...referenceErrors);
});
// 5. Vérification de la possibilité d'exécution
const canExecute = errors.filter(e => e.severity === 'critical').length === 0;
if (!canExecute) {
errors.push({
id: 'execution_blocked',
type: 'error',
category: 'execution_blocked',
message: 'Exécution bloquée',
description: 'Des erreurs critiques empêchent l\'exécution du workflow',
severity: 'critical',
});
}
return {
isValid: errors.length === 0 && warnings.length === 0,
errors,
warnings,
canExecute,
};
}, [workflow, variables]);
// Gestionnaire de basculement de section
const toggleSection = (section: string) => {
setExpandedSections(prev => {
const newSet = new Set(prev);
if (newSet.has(section)) {
newSet.delete(section);
} else {
newSet.add(section);
}
return newSet;
});
};
// Gestionnaire de survol d'étape
const handleStepHover = (stepId: string | undefined, highlight: boolean) => {
if (stepId && onStepHighlight) {
onStepHighlight(stepId, highlight);
}
};
// Rendu d'une issue de validation
const renderValidationIssue = (issue: ValidationIssue) => {
const getIcon = () => {
switch (issue.category) {
case 'missing_parameter':
return <ErrorIcon color="error" />;
case 'disconnected_step':
return <LinkIcon color="warning" />;
case 'cycle_detected':
return <LoopIcon color="error" />;
case 'invalid_reference':
return <ErrorIcon color="error" />;
case 'execution_blocked':
return <BlockIcon color="error" />;
default:
return <InfoIcon />;
}
};
const getSeverityColor = () => {
switch (issue.severity) {
case 'critical':
return 'error';
case 'high':
return 'error';
case 'medium':
return 'warning';
case 'low':
return 'info';
default:
return 'default';
}
};
return (
<ListItem
key={issue.id}
onMouseEnter={() => handleStepHover(issue.stepId, true)}
onMouseLeave={() => handleStepHover(issue.stepId, false)}
sx={{
border: '1px solid',
borderColor: issue.type === 'error' ? 'error.light' : 'warning.light',
borderRadius: 1,
mb: 1,
backgroundColor: issue.type === 'error' ? 'error.light' : 'warning.light',
'&:hover': {
backgroundColor: issue.type === 'error' ? 'error.main' : 'warning.main',
},
}}
>
<ListItemIcon>
{getIcon()}
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2">{issue.message}</Typography>
<Chip
label={issue.severity}
size="small"
color={getSeverityColor() as any}
variant="outlined"
/>
</Box>
}
secondary={issue.description}
/>
</ListItem>
);
};
if (validationResult.isValid) {
return (
<Alert severity="success" sx={{ mb: 2 }}>
<AlertTitle>Workflow valide</AlertTitle>
Aucun problème détecté. Le workflow est prêt à être exécuté.
</Alert>
);
}
return (
<Box
role="region"
aria-label="Validation du workflow"
onKeyDown={handleKeyDown}
tabIndex={0}
>
{/* Résumé de validation */}
<Alert
severity={validationResult.canExecute ? 'warning' : 'error'}
sx={{ mb: 2 }}
role="alert"
aria-live="polite"
>
<AlertTitle>
{validationResult.canExecute ? 'Avertissements détectés' : 'Erreurs critiques détectées'}
</AlertTitle>
{validationResult.errors.length > 0 && (
<Typography variant="body2">
{validationResult.errors.length} erreur(s) trouvée(s)
</Typography>
)}
{validationResult.warnings.length > 0 && (
<Typography variant="body2">
{validationResult.warnings.length} avertissement(s) trouvé(s)
</Typography>
)}
{!validationResult.canExecute && (
<Typography variant="body2" fontWeight="bold">
L'exécution est bloquée jusqu'à la résolution des erreurs critiques.
</Typography>
)}
</Alert>
{/* Section des erreurs */}
{validationResult.errors.length > 0 && (
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
mb: 1,
}}
onClick={() => toggleSection('errors')}
role="button"
aria-expanded={expandedSections.has('errors')}
aria-controls="errors-list"
tabIndex={0}
>
<IconButton size="small" aria-hidden="true">
{expandedSections.has('errors') ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
<Typography variant="h6" color="error">
Erreurs ({validationResult.errors.length})
</Typography>
</Box>
<Collapse in={expandedSections.has('errors')}>
<List id="errors-list" role="list" aria-label="Liste des erreurs de validation">
{validationResult.errors.map(renderValidationIssue)}
</List>
</Collapse>
</Box>
)}
{/* Section des avertissements */}
{validationResult.warnings.length > 0 && (
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
mb: 1,
}}
onClick={() => toggleSection('warnings')}
>
<IconButton size="small">
{expandedSections.has('warnings') ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
<Typography variant="h6" color="warning.main">
Avertissements ({validationResult.warnings.length})
</Typography>
</Box>
<Collapse in={expandedSections.has('warnings')}>
<List>
{validationResult.warnings.map(renderValidationIssue)}
</List>
</Collapse>
</Box>
)}
</Box>
);
};
export default Validator;

View File

@@ -0,0 +1,545 @@
/**
* Composant Gestionnaire de Variables - Gestion CRUD des variables de workflow
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce composant permet de créer, modifier et supprimer des variables,
* avec validation d'unicité des noms et support de différents types.
*/
import React, { useState, useCallback, useMemo, memo } from 'react';
import {
Box,
Typography,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
Alert,
Chip,
} from '@mui/material';
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
Close as CloseIcon,
} from '@mui/icons-material';
// Import des types partagés
import {
VariableManagerProps,
Variable,
VariableType,
VariableTypeEnum,
} from '../../types';
// Import du hook de debouncing et du client API
import { useDebounce } from '../../hooks/useDebounce';
import { useApiClient } from '../../hooks/useApiClient';
import { apiClient } from '../../services/apiClient';
// Labels français pour les types
const typeLabels: Record<VariableType, string> = {
text: 'Texte',
number: 'Nombre',
boolean: 'Booléen',
list: 'Liste',
};
/**
* Composant Gestionnaire de Variables
*/
const VariableManager: React.FC<VariableManagerProps> = ({
variables,
onVariableCreate,
onVariableUpdate,
onVariableDelete,
}) => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingVariable, setEditingVariable] = useState<Variable | null>(null);
const [formData, setFormData] = useState({
name: '',
type: 'text' as VariableType,
defaultValue: '',
description: '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [searchQuery, setSearchQuery] = useState('');
// Utilisation du client API pour la validation des variables
const apiValidation = useApiClient({
onError: (error) => {
console.error('Erreur de validation des variables:', error);
setErrors(prev => ({ ...prev, api: error.message }));
},
});
// Debouncing de la recherche pour optimiser les performances
const debouncedSearchQuery = useDebounce(searchQuery, 300);
// Mémoriser les variables triées et filtrées pour éviter les re-calculs
const filteredAndSortedVariables = useMemo(() => {
let filtered = variables;
// Filtrer selon la recherche débouncée
if (debouncedSearchQuery.trim()) {
filtered = variables.filter(variable =>
variable.name.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) ||
(variable.description && variable.description.toLowerCase().includes(debouncedSearchQuery.toLowerCase()))
);
}
// Trier par nom
return filtered.sort((a, b) => a.name.localeCompare(b.name));
}, [variables, debouncedSearchQuery]);
// Mémoriser les statistiques des variables
const variableStats = useMemo(() => {
const stats = {
total: variables.length,
byType: {} as Record<VariableType, number>,
};
variables.forEach(variable => {
stats.byType[variable.type] = (stats.byType[variable.type] || 0) + 1;
});
return stats;
}, [variables]);
// Gestionnaire d'événements clavier pour le gestionnaire de variables
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
switch (event.key) {
case 'Enter':
// Activer l'élément focalisé
if (event.target instanceof HTMLButtonElement) {
event.preventDefault();
event.target.click();
}
break;
case 'Escape':
// Fermer les dialogues ouverts
event.preventDefault();
if (isDialogOpen) {
handleCloseDialog();
}
break;
case 'n':
// Raccourci pour nouvelle variable (Ctrl+N)
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
handleCreateNew();
}
break;
}
}, [isDialogOpen]);
// Validation des variables avec l'API
const validateVariableWithApi = useCallback(async (variableData: Partial<Variable>) => {
try {
// Simuler une validation côté client qui pourrait utiliser l'API
const validationErrors: Record<string, string> = {};
// Validation du nom
if (!variableData.name || variableData.name.trim().length === 0) {
validationErrors.name = 'Le nom de la variable est obligatoire';
} else if (variableData.name.length > 50) {
validationErrors.name = 'Le nom ne peut pas dépasser 50 caractères';
}
// Validation de l'unicité du nom
const existingVariable = variables.find(v =>
v.name === variableData.name &&
(!editingVariable || v.id !== editingVariable.id)
);
if (existingVariable) {
validationErrors.name = 'Une variable avec ce nom existe déjà';
}
// Validation du type
if (!variableData.type) {
validationErrors.type = 'Le type de variable est obligatoire';
}
// Validation de la valeur par défaut selon le type
if (variableData.defaultValue && variableData.type) {
switch (variableData.type) {
case 'number':
if (isNaN(Number(variableData.defaultValue))) {
validationErrors.defaultValue = 'La valeur par défaut doit être un nombre';
}
break;
case 'boolean':
if (!['true', 'false', '1', '0'].includes(variableData.defaultValue.toLowerCase())) {
validationErrors.defaultValue = 'La valeur par défaut doit être true/false ou 1/0';
}
break;
}
}
setErrors(validationErrors);
return Object.keys(validationErrors).length === 0;
} catch (error) {
console.error('Erreur lors de la validation:', error);
setErrors({ api: 'Erreur de validation' });
return false;
}
}, [variables, editingVariable]);
// Ouvrir le dialogue pour créer une nouvelle variable
const handleCreateNew = () => {
setEditingVariable(null);
setFormData({
name: '',
type: VariableTypeEnum.TEXT,
defaultValue: '',
description: '',
});
setErrors({});
setIsDialogOpen(true);
};
// Ouvrir le dialogue pour modifier une variable existante
const handleEdit = (variable: Variable) => {
setEditingVariable(variable);
setFormData({
name: variable.name,
type: variable.type,
defaultValue: variable.defaultValue || '',
description: variable.description || '',
});
setErrors({});
setIsDialogOpen(true);
};
// Fermer le dialogue
const handleCloseDialog = () => {
setIsDialogOpen(false);
setEditingVariable(null);
setFormData({
name: '',
type: 'text' as VariableType,
defaultValue: '',
description: '',
});
setErrors({});
};
// Valider le formulaire
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
// Validation du nom
if (!formData.name.trim()) {
newErrors.name = 'Le nom de la variable est obligatoire';
} else if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(formData.name)) {
newErrors.name = 'Le nom doit commencer par une lettre ou _ et contenir uniquement des lettres, chiffres et _';
} else {
// Vérifier l'unicité du nom
const existingVariable = variables.find(
v => v.name === formData.name && v.id !== editingVariable?.id
);
if (existingVariable) {
newErrors.name = 'Une variable avec ce nom existe déjà';
}
}
// Validation de la valeur par défaut selon le type
if (formData.defaultValue) {
switch (formData.type) {
case 'number':
if (isNaN(Number(formData.defaultValue))) {
newErrors.defaultValue = 'La valeur par défaut doit être un nombre';
}
break;
case 'boolean':
if (!['true', 'false'].includes(formData.defaultValue.toLowerCase())) {
newErrors.defaultValue = 'La valeur par défaut doit être "true" ou "false"';
}
break;
case 'list':
try {
JSON.parse(formData.defaultValue);
} catch {
newErrors.defaultValue = 'La valeur par défaut doit être un JSON valide pour une liste';
}
break;
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Sauvegarder la variable
const handleSave = () => {
if (!validateForm()) return;
let processedDefaultValue: any = formData.defaultValue;
// Traiter la valeur par défaut selon le type
if (processedDefaultValue) {
switch (formData.type) {
case 'number':
processedDefaultValue = Number(processedDefaultValue);
break;
case 'boolean':
processedDefaultValue = formData.defaultValue.toLowerCase() === 'true';
break;
case 'list':
try {
processedDefaultValue = JSON.parse(formData.defaultValue);
} catch {
processedDefaultValue = [];
}
break;
}
}
const variableData = {
name: formData.name,
type: formData.type,
defaultValue: processedDefaultValue || undefined,
description: formData.description || undefined,
};
if (editingVariable) {
onVariableUpdate(editingVariable.id, variableData);
} else {
onVariableCreate(variableData);
}
handleCloseDialog();
};
// Supprimer une variable
const handleDelete = (variable: Variable) => {
if (window.confirm(`Êtes-vous sûr de vouloir supprimer la variable "${variable.name}" ?`)) {
onVariableDelete(variable.id);
}
};
// Formater la valeur par défaut pour l'affichage
const formatDefaultValue = (variable: Variable): string => {
if (variable.defaultValue === undefined || variable.defaultValue === null) {
return 'Non définie';
}
switch (variable.type) {
case 'boolean':
return variable.defaultValue ? 'Vrai' : 'Faux';
case 'list':
return Array.isArray(variable.defaultValue)
? `[${variable.defaultValue.length} éléments]`
: JSON.stringify(variable.defaultValue);
default:
return String(variable.defaultValue);
}
};
return (
<Box
role="region"
aria-label="Gestionnaire de variables du workflow"
onKeyDown={handleKeyDown}
tabIndex={0}
>
{/* En-tête avec bouton d'ajout */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Variables du workflow</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleCreateNew}
size="small"
aria-label="Créer une nouvelle variable"
>
Nouvelle variable
</Button>
</Box>
{/* Liste des variables */}
{variables.length === 0 ? (
<Alert severity="info">
Aucune variable définie. Créez des variables pour rendre votre workflow plus flexible.
</Alert>
) : (
<List role="list" aria-label="Liste des variables définies">
{variables.map((variable) => (
<ListItem
key={variable.id}
divider
sx={{
border: '1px solid #e0e0e0',
borderRadius: 1,
mb: 1,
backgroundColor: '#fafafa',
}}
role="listitem"
aria-label={`Variable ${variable.name} de type ${typeLabels[variable.type]}`}
>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2">{variable.name}</Typography>
<Chip
label={typeLabels[variable.type]}
size="small"
color="primary"
variant="outlined"
/>
</Box>
}
secondary={
<Box>
<Typography variant="body2" color="text.secondary">
Valeur par défaut: {formatDefaultValue(variable)}
</Typography>
{variable.description && (
<Typography variant="caption" color="text.secondary">
{variable.description}
</Typography>
)}
</Box>
}
/>
<ListItemSecondaryAction>
<IconButton
edge="end"
onClick={() => handleEdit(variable)}
size="small"
sx={{ mr: 1 }}
aria-label={`Modifier la variable ${variable.name}`}
>
<EditIcon />
</IconButton>
<IconButton
edge="end"
onClick={() => handleDelete(variable)}
size="small"
color="error"
aria-label={`Supprimer la variable ${variable.name}`}
>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
{/* Dialogue de création/modification */}
<Dialog
open={isDialogOpen}
onClose={handleCloseDialog}
maxWidth="sm"
fullWidth
>
<DialogTitle>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{editingVariable ? 'Modifier la variable' : 'Nouvelle variable'}
<IconButton onClick={handleCloseDialog} size="small">
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
{/* Nom de la variable */}
<TextField
fullWidth
label="Nom de la variable"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
error={!!errors.name}
helperText={errors.name || 'Utilisez uniquement des lettres, chiffres et _'}
required
/>
{/* Type de la variable */}
<FormControl fullWidth>
<InputLabel>Type de variable</InputLabel>
<Select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as VariableType })}
label="Type de variable"
>
{Object.entries(typeLabels).map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
{/* Valeur par défaut */}
<TextField
fullWidth
label="Valeur par défaut (optionnelle)"
value={formData.defaultValue}
onChange={(e) => setFormData({ ...formData, defaultValue: e.target.value })}
error={!!errors.defaultValue}
helperText={errors.defaultValue || getDefaultValueHelp(formData.type)}
multiline={formData.type === 'list'}
rows={formData.type === 'list' ? 3 : 1}
/>
{/* Description */}
<TextField
fullWidth
label="Description (optionnelle)"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
multiline
rows={2}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Annuler</Button>
<Button onClick={handleSave} variant="contained">
{editingVariable ? 'Modifier' : 'Créer'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
// Aide contextuelle pour la valeur par défaut
const getDefaultValueHelp = (type: VariableType): string => {
switch (type) {
case 'text':
return 'Texte libre';
case 'number':
return 'Nombre décimal ou entier';
case 'boolean':
return 'true ou false';
case 'list':
return 'JSON valide, ex: ["item1", "item2"]';
default:
return '';
}
};
// Mémorisation du composant VariableManager pour éviter les re-rendus inutiles
export default memo(VariableManager, (prevProps, nextProps) => {
return (
JSON.stringify(prevProps.variables) === JSON.stringify(nextProps.variables) &&
prevProps.onVariableCreate === nextProps.onVariableCreate &&
prevProps.onVariableUpdate === nextProps.onVariableUpdate &&
prevProps.onVariableDelete === nextProps.onVariableDelete
);
});

View File

@@ -0,0 +1,296 @@
/**
* Styles pour le Panneau des Propriétés Visuelles
*
* Suit les guidelines du design system RPA Vision V3:
* - Couleurs Material-UI avec thème sombre
* - Espacement cohérent (xs: 4px, sm: 8px, md: 12px, lg: 16px, xl: 20px)
* - Composants Material-UI avec CSS modules personnalisés
*/
.visual-properties-panel {
background: #1e293b; /* Card Background du design system */
border-left: 1px solid #334155; /* Border Color */
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.panel-header {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%); /* Primary Blue */
color: #e2e8f0; /* Text Primary */
border-bottom: 1px solid #334155;
}
.panel-content {
background: #0f172a; /* Dark Background */
padding: 20px; /* Component spacing */
}
/* Conteneur de statut de validation */
.validation-status-container {
background: rgba(30, 41, 59, 0.8) !important; /* Card Background avec transparence */
border: 1px solid #334155;
border-radius: 12px;
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
.validation-status-container:hover {
border-color: #1976d2; /* Primary Blue au survol */
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.2);
}
/* Conteneur de capture d'écran */
.screenshot-container {
background: #1e293b !important;
border: 1px solid #334155;
border-radius: 12px;
overflow: hidden;
transition: all 0.3s ease;
}
.screenshot-container:hover {
border-color: #1976d2;
box-shadow: 0 8px 24px rgba(25, 118, 210, 0.15);
transform: translateY(-2px);
}
.screenshot-image {
width: 100%;
height: auto;
max-height: 300px;
object-fit: contain;
cursor: pointer;
transition: all 0.3s ease;
}
.screenshot-image:hover {
transform: scale(1.02);
}
/* Placeholder pour capture d'écran */
.screenshot-placeholder {
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%) !important;
border: 2px dashed #334155;
border-radius: 12px;
min-height: 200px;
transition: all 0.3s ease;
}
.screenshot-placeholder:hover {
border-color: #1976d2;
background: linear-gradient(135deg, #1e293b 0%, #1976d2 5%, #0f172a 100%) !important;
}
.select-element-button {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%) !important;
color: white !important;
padding: 12px 24px !important; /* Input padding étendu */
border-radius: 8px !important;
font-weight: 600 !important;
text-transform: none !important;
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3) !important;
transition: all 0.3s ease !important;
}
.select-element-button:hover {
background: linear-gradient(135deg, #1565c0 0%, #0d47a1 100%) !important;
box-shadow: 0 6px 16px rgba(25, 118, 210, 0.4) !important;
transform: translateY(-2px);
}
/* Paramètres visuels */
.visual-parameters {
background: #1e293b !important;
border: 1px solid #334155;
border-radius: 12px;
}
.visual-parameters .MuiCardContent-root {
padding: 20px !important;
}
/* Animations pour les indicateurs de statut */
@keyframes pulse-success {
0%, 100% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
}
50% {
box-shadow: 0 0 0 10px rgba(34, 197, 94, 0);
}
}
@keyframes pulse-warning {
0%, 100% {
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.7);
}
50% {
box-shadow: 0 0 0 10px rgba(245, 158, 11, 0);
}
}
@keyframes pulse-error {
0%, 100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
}
50% {
box-shadow: 0 0 0 10px rgba(239, 68, 68, 0);
}
}
/* Indicateurs de statut avec animations */
.status-indicator-success {
animation: pulse-success 2s infinite;
}
.status-indicator-warning {
animation: pulse-warning 2s infinite;
}
.status-indicator-error {
animation: pulse-error 2s infinite;
}
/* Styles pour les alertes de validation */
.MuiAlert-root {
border-radius: 8px !important;
margin-bottom: 8px !important; /* sm spacing */
}
.MuiAlert-standardError {
background: rgba(239, 68, 68, 0.1) !important;
border: 1px solid rgba(239, 68, 68, 0.3) !important;
color: #fecaca !important;
}
.MuiAlert-standardInfo {
background: rgba(25, 118, 210, 0.1) !important;
border: 1px solid rgba(25, 118, 210, 0.3) !important;
color: #bfdbfe !important;
}
.MuiAlert-standardWarning {
background: rgba(245, 158, 11, 0.1) !important;
border: 1px solid rgba(245, 158, 11, 0.3) !important;
color: #fde68a !important;
}
/* Styles pour les chips de confiance */
.MuiChip-root {
font-weight: 600 !important;
border-radius: 6px !important;
}
.MuiChip-colorSuccess {
background: rgba(34, 197, 94, 0.2) !important;
color: #22c55e !important;
border-color: #22c55e !important;
}
.MuiChip-colorWarning {
background: rgba(245, 158, 11, 0.2) !important;
color: #f59e0b !important;
border-color: #f59e0b !important;
}
.MuiChip-colorError {
background: rgba(239, 68, 68, 0.2) !important;
color: #ef4444 !important;
border-color: #ef4444 !important;
}
/* Barre de progression de confiance */
.MuiLinearProgress-root {
height: 4px !important;
border-radius: 2px !important;
background: rgba(51, 65, 85, 0.3) !important;
}
.MuiLinearProgress-barColorSuccess {
background: linear-gradient(90deg, #22c55e 0%, #16a34a 100%) !important;
}
.MuiLinearProgress-barColorWarning {
background: linear-gradient(90deg, #f59e0b 0%, #d97706 100%) !important;
}
.MuiLinearProgress-barColorError {
background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%) !important;
}
/* Boutons d'action */
.MuiIconButton-root {
color: #94a3b8 !important; /* Text Secondary */
transition: all 0.3s ease !important;
}
.MuiIconButton-root:hover {
color: #1976d2 !important; /* Primary Blue */
background: rgba(25, 118, 210, 0.1) !important;
transform: scale(1.1);
}
/* Tooltips */
.MuiTooltip-tooltip {
background: #1e293b !important;
color: #e2e8f0 !important;
border: 1px solid #334155 !important;
border-radius: 6px !important;
font-size: 12px !important;
padding: 8px 12px !important;
}
.MuiTooltip-arrow {
color: #1e293b !important;
}
/* Responsive design */
@media (max-width: 768px) {
.visual-properties-panel {
width: 100% !important;
height: auto !important;
border-left: none;
border-top: 1px solid #334155;
}
.panel-content {
padding: 16px; /* lg spacing pour mobile */
}
.screenshot-image {
max-height: 200px;
}
}
/* Accessibilité */
@media (prefers-reduced-motion: reduce) {
.screenshot-container,
.screenshot-image,
.select-element-button,
.MuiIconButton-root {
transition: none !important;
}
.status-indicator-success,
.status-indicator-warning,
.status-indicator-error {
animation: none !important;
}
}
/* Focus visible pour l'accessibilité */
.select-element-button:focus-visible,
.MuiIconButton-root:focus-visible {
outline: 2px solid #1976d2 !important;
outline-offset: 2px !important;
}
/* Thème sombre spécifique */
@media (prefers-color-scheme: dark) {
.visual-properties-panel {
background: #0f172a;
}
.panel-content {
background: #020617;
}
}

View File

@@ -0,0 +1,518 @@
/**
* Composant Panneau de Propriétés Visuelles - Configuration des paramètres d'étapes
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Panneau des propriétés entièrement visuel pour la configuration des actions RPA.
* Supprime complètement les sélecteurs CSS/XPath et utilise uniquement des méthodes visuelles.
*
* Fonctionnalités:
* - Affichage des captures d'écran haute qualité
* - Sélection visuelle interactive
* - Validation en temps réel
* - Métadonnées visuelles enrichies
* - Interface sans éléments techniques
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Typography,
Button,
Card,
CardContent,
Chip,
Alert,
CircularProgress,
Tooltip,
IconButton,
Divider,
LinearProgress,
} from '@mui/material';
import {
CheckCircle as ValidIcon,
Warning as WarningIcon,
Error as ErrorIcon,
Refresh as RefreshIcon,
ZoomIn as ZoomIcon,
Info as InfoIcon,
Settings as SettingsIcon,
PhotoCamera as CameraIcon,
} from '@mui/icons-material';
import { BoundingBox } from '../../types';
import InteractivePreviewArea from '../InteractivePreviewArea';
import './VisualPropertiesPanel.css';
interface VisualTarget {
screenshot: string;
bounding_box: BoundingBox;
metadata: {
element_type: string;
relative_position?: string;
text_content?: string;
};
}
interface VisualNode {
id: string;
type: string;
visualTarget?: VisualTarget;
parameters?: Record<string, any>;
}
interface VisualPropertiesPanelProps {
node: VisualNode;
onNodeUpdate: (nodeId: string, updates: Partial<VisualNode>) => void;
onClose: () => void;
}
interface ValidationStatus {
isValid: boolean;
confidence: number;
lastChecked: Date;
issues: string[];
suggestions: string[];
}
const VisualPropertiesPanel: React.FC<VisualPropertiesPanelProps> = ({
node,
onNodeUpdate,
onClose,
}) => {
const [selectorOpen, setSelectorOpen] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [validationStatus, setValidationStatus] = useState<ValidationStatus | null>(null);
const [isValidating, setIsValidating] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
// Validation automatique au chargement
useEffect(() => {
if (node.visualTarget) {
validateTarget();
}
}, [node.visualTarget]);
/**
* Valide la cible visuelle actuelle
*/
const validateTarget = useCallback(async () => {
if (!node.visualTarget) return;
setIsValidating(true);
try {
// Simuler la validation (à remplacer par l'API réelle)
await new Promise(resolve => setTimeout(resolve, 1500));
// Simuler un résultat de validation
const mockValidation: ValidationStatus = {
isValid: Math.random() > 0.3, // 70% de chance d'être valide
confidence: 0.85 + Math.random() * 0.15, // Entre 0.85 et 1.0
lastChecked: new Date(),
issues: Math.random() > 0.7 ? ['Élément légèrement déplacé'] : [],
suggestions: Math.random() > 0.5 ? ['Mettre à jour la capture'] : []
};
setValidationStatus(mockValidation);
} catch (error) {
console.error('Erreur lors de la validation:', error);
setValidationStatus({
isValid: false,
confidence: 0,
lastChecked: new Date(),
issues: ['Erreur de validation'],
suggestions: ['Vérifier la connexion']
});
} finally {
setIsValidating(false);
}
}, [node.visualTarget]);
/**
* Gère la sélection d'un nouvel élément
*/
const handleElementSelected = useCallback((target: VisualTarget) => {
onNodeUpdate(node.id, {
visualTarget: target
});
setSelectorOpen(false);
// Valider immédiatement le nouvel élément
setTimeout(validateTarget, 500);
}, [node.id, onNodeUpdate, validateTarget]);
/**
* Ouvre le sélecteur visuel
*/
const openVisualSelector = useCallback(() => {
// Simuler l'ouverture du sélecteur visuel
console.log('Ouverture du sélecteur visuel');
}, []);
/**
* Ouvre l'aperçu interactif
*/
const openPreview = useCallback(() => {
if (node.visualTarget?.screenshot) {
setPreviewOpen(true);
}
}, [node.visualTarget]);
/**
* Met à jour les paramètres du nœud
*/
const updateNodeParameter = useCallback((key: string, value: any) => {
onNodeUpdate(node.id, {
parameters: {
...node.parameters,
[key]: value
}
});
}, [node.id, node.parameters, onNodeUpdate]);
/**
* Obtient l'icône de statut de validation
*/
const getValidationIcon = () => {
if (isValidating) {
return <CircularProgress size={20} />;
}
if (!validationStatus) {
return <InfoIcon color="disabled" />;
}
if (validationStatus.isValid) {
return <ValidIcon color="success" />;
} else if (validationStatus.issues.length > 0) {
return <WarningIcon color="warning" />;
} else {
return <ErrorIcon color="error" />;
}
};
/**
* Obtient le message de statut de validation
*/
const getValidationMessage = () => {
if (isValidating) {
return "Validation en cours...";
}
if (!validationStatus) {
return "Aucune validation effectuée";
}
if (validationStatus.isValid) {
return `Élément valide (confiance: ${Math.round(validationStatus.confidence * 100)}%)`;
} else {
return "Élément non trouvé ou modifié";
}
};
/**
* Obtient la couleur du statut de validation
*/
const getValidationColor = (): 'success' | 'warning' | 'error' | 'info' => {
if (!validationStatus || isValidating) return 'info';
if (validationStatus.isValid) {
return validationStatus.confidence > 0.9 ? 'success' : 'warning';
} else {
return 'error';
}
};
return (
<Box className="visual-properties-panel">
{/* En-tête du panneau */}
<Box className="panel-header">
<Typography variant="h6" className="panel-title">
Propriétés Visuelles
</Typography>
<Typography variant="body2" color="textSecondary">
{node.type || 'Action'} - Configuration 100% visuelle
</Typography>
</Box>
<Divider />
{/* Zone de capture principale */}
<Card className="capture-section" elevation={2}>
<CardContent>
<Box className="capture-header">
<Typography variant="subtitle1" className="section-title">
Élément Cible
</Typography>
<Box className="capture-actions">
<Tooltip title="Nouvelle sélection">
<IconButton onClick={openVisualSelector} color="primary">
<CameraIcon />
</IconButton>
</Tooltip>
{node.visualTarget?.screenshot && (
<Tooltip title="Aperçu agrandi">
<IconButton onClick={openPreview} color="primary">
<ZoomIcon />
</IconButton>
</Tooltip>
)}
</Box>
</Box>
{node.visualTarget ? (
<Box className="capture-display">
{/* Image de l'élément */}
<Box className="screenshot-container">
<img
src={`data:image/png;base64,${node.visualTarget.screenshot}`}
alt="Élément sélectionné"
className="screenshot-image"
onClick={openPreview}
/>
<Box className="screenshot-overlay">
<Chip
label={node.visualTarget.metadata.element_type}
size="small"
color="primary"
className="element-type-chip"
/>
</Box>
</Box>
{/* Métadonnées visuelles */}
<Box className="metadata-display">
<Typography variant="body2" color="textSecondary">
Type: {node.visualTarget.metadata.element_type}
</Typography>
{node.visualTarget.metadata.text_content && (
<Typography variant="body2" color="textSecondary">
Texte: "{node.visualTarget.metadata.text_content}"
</Typography>
)}
</Box>
</Box>
) : (
<Box className="no-capture-state">
<Typography variant="body2" color="textSecondary" align="center">
Aucun élément sélectionné
</Typography>
<Button
variant="contained"
startIcon={<CameraIcon />}
onClick={openVisualSelector}
className="select-button"
>
Sélectionner un Élément
</Button>
</Box>
)}
</CardContent>
</Card>
{/* Statut de validation */}
{node.visualTarget && (
<Card className="validation-section" elevation={1}>
<CardContent>
<Box className="validation-header">
<Box className="validation-status">
{getValidationIcon()}
<Typography variant="body2" className="validation-text">
{getValidationMessage()}
</Typography>
</Box>
<Tooltip title="Revalider">
<IconButton
onClick={validateTarget}
disabled={isValidating}
size="small"
>
<RefreshIcon />
</IconButton>
</Tooltip>
</Box>
{validationStatus && (
<Box className="validation-details">
<LinearProgress
variant="determinate"
value={validationStatus.confidence * 100}
color={getValidationColor()}
className="confidence-bar"
/>
{validationStatus.issues.length > 0 && (
<Alert severity="warning" className="validation-alert">
<Typography variant="body2">
Problèmes détectés: {validationStatus.issues.join(', ')}
</Typography>
</Alert>
)}
{validationStatus.suggestions.length > 0 && (
<Box className="suggestions">
<Typography variant="caption" color="textSecondary">
Suggestions: {validationStatus.suggestions.join(', ')}
</Typography>
</Box>
)}
</Box>
)}
</CardContent>
</Card>
)}
{/* Paramètres de l'action */}
<Card className="parameters-section" elevation={1}>
<CardContent>
<Typography variant="subtitle1" className="section-title">
Paramètres de l'Action
</Typography>
{/* Paramètres spécifiques au type d'action */}
{node.type === 'click' && (
<Box className="parameter-group">
<Typography variant="body2" className="parameter-label">
Type de clic
</Typography>
<Box className="parameter-options">
{['Simple', 'Double', 'Droit'].map((clickType) => (
<Chip
key={clickType}
label={clickType}
variant={node.parameters?.clickType === clickType ? 'filled' : 'outlined'}
onClick={() => updateNodeParameter('clickType', clickType)}
className="option-chip"
/>
))}
</Box>
</Box>
)}
{node.type === 'input' && (
<Box className="parameter-group">
<Typography variant="body2" className="parameter-label">
Texte à saisir
</Typography>
<Box className="text-input-container">
<input
type="text"
value={node.parameters?.text || ''}
onChange={(e) => updateNodeParameter('text', e.target.value)}
placeholder="Entrez le texte à saisir..."
className="text-input"
/>
</Box>
</Box>
)}
{/* Délai d'attente */}
<Box className="parameter-group">
<Typography variant="body2" className="parameter-label">
Délai d'attente
</Typography>
<Box className="delay-controls">
<input
type="range"
min="0"
max="10"
step="0.5"
value={node.parameters?.delay || 1}
onChange={(e) => updateNodeParameter('delay', parseFloat(e.target.value))}
className="delay-slider"
/>
<Typography variant="caption" className="delay-value">
{node.parameters?.delay || 1}s
</Typography>
</Box>
</Box>
</CardContent>
</Card>
{/* Options avancées */}
<Card className="advanced-section" elevation={1}>
<CardContent>
<Box
className="advanced-header"
onClick={() => setShowAdvanced(!showAdvanced)}
>
<Typography variant="subtitle1" className="section-title">
Options Avancées
</Typography>
<IconButton size="small">
<SettingsIcon />
</IconButton>
</Box>
{showAdvanced && (
<Box className="advanced-options">
<Box className="option-row">
<Typography variant="body2">Tolérance de position</Typography>
<input
type="range"
min="0"
max="100"
value={node.parameters?.positionTolerance || 10}
onChange={(e) => updateNodeParameter('positionTolerance', parseInt(e.target.value))}
className="tolerance-slider"
/>
<Typography variant="caption">
{node.parameters?.positionTolerance || 10}px
</Typography>
</Box>
<Box className="option-row">
<Typography variant="body2">Seuil de confiance</Typography>
<input
type="range"
min="0.5"
max="1.0"
step="0.05"
value={node.parameters?.confidenceThreshold || 0.8}
onChange={(e) => updateNodeParameter('confidenceThreshold', parseFloat(e.target.value))}
className="confidence-slider"
/>
<Typography variant="caption">
{Math.round((node.parameters?.confidenceThreshold || 0.8) * 100)}%
</Typography>
</Box>
</Box>
)}
</CardContent>
</Card>
{/* Actions du panneau */}
<Box className="panel-actions">
<Button
variant="outlined"
onClick={onClose}
className="cancel-button"
>
Fermer
</Button>
<Button
variant="contained"
color="primary"
disabled={!node.visualTarget}
className="save-button"
>
Appliquer
</Button>
</Box>
{/* Dialogs */}
{node.visualTarget && (
<InteractivePreviewArea
open={previewOpen}
onClose={() => setPreviewOpen(false)}
screenshot={node.visualTarget.screenshot}
boundingBox={node.visualTarget.bounding_box}
metadata={node.visualTarget.metadata}
/>
)}
</Box>
);
};
export default VisualPropertiesPanel;

View File

@@ -0,0 +1,258 @@
/**
* Visual Screen Selector Styles - RPA Vision V3
*
* Styles pour le sélecteur d'écran visuel interactif.
* Optimisé pour une expérience de sélection fluide et intuitive.
*/
.visual-screen-selector {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 9999;
background: rgba(0, 0, 0, 0.8);
display: flex;
flex-direction: column;
}
.visual-screen-selector__header {
background: #1976d2;
color: white;
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.visual-screen-selector__title {
display: flex;
align-items: center;
gap: 8px;
font-size: 1.25rem;
font-weight: 600;
}
.visual-screen-selector__controls {
display: flex;
gap: 8px;
}
.visual-screen-selector__content {
flex: 1;
display: flex;
overflow: hidden;
}
.visual-screen-selector__canvas-area {
flex: 1;
position: relative;
overflow: auto;
background: #f5f5f5;
}
.visual-screen-selector__canvas {
display: block;
max-width: 100%;
max-height: 100%;
cursor: crosshair;
transition: cursor 0.2s ease;
}
.visual-screen-selector__canvas--pointer {
cursor: pointer;
}
.visual-screen-selector__sidebar {
width: 320px;
background: white;
border-left: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.visual-screen-selector__sidebar-header {
padding: 16px;
border-bottom: 1px solid #e0e0e0;
background: #f8f9fa;
}
.visual-screen-selector__sidebar-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.visual-screen-selector__element-card {
margin-bottom: 12px;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
background: white;
}
.visual-screen-selector__element-card:hover {
border-color: #2196f3;
box-shadow: 0 2px 8px rgba(33, 150, 243, 0.2);
transform: translateY(-1px);
}
.visual-screen-selector__element-card--hovered {
border-color: #2196f3;
background: #e3f2fd;
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.3);
}
.visual-screen-selector__element-card--selected {
border-color: #4caf50;
background: #e8f5e8;
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3);
}
.visual-screen-selector__element-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.visual-screen-selector__element-text {
font-weight: 500;
margin-bottom: 4px;
}
.visual-screen-selector__element-details {
font-size: 0.75rem;
color: #666;
line-height: 1.4;
}
.visual-screen-selector__status-bar {
padding: 16px 24px;
background: white;
border-top: 1px solid #e0e0e0;
}
.visual-screen-selector__instructions {
background: #e3f2fd;
padding: 12px 16px;
border-radius: 8px;
font-size: 0.875rem;
color: #1565c0;
line-height: 1.5;
}
.visual-screen-selector__loading {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
flex-direction: column;
gap: 16px;
color: white;
}
.visual-screen-selector__loading-spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top: 4px solid white;
border-radius: 50%;
animation: visual-screen-selector-spin 1s linear infinite;
}
@keyframes visual-screen-selector-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.visual-screen-selector__error {
padding: 16px 24px;
background: #ffebee;
border-left: 4px solid #f44336;
color: #c62828;
font-size: 0.875rem;
}
.visual-screen-selector__success {
padding: 16px 24px;
background: #e8f5e8;
border-left: 4px solid #4caf50;
color: #2e7d32;
font-size: 0.875rem;
}
/* Animations pour les overlays */
.visual-screen-selector__overlay {
position: absolute;
pointer-events: none;
border: 2px solid #ff9800;
background: rgba(255, 152, 0, 0.1);
transition: all 0.1s ease;
z-index: 10;
}
.visual-screen-selector__overlay--hovered {
border-color: #2196f3;
background: rgba(33, 150, 243, 0.2);
border-width: 3px;
}
.visual-screen-selector__overlay--selected {
border-color: #4caf50;
background: rgba(76, 175, 80, 0.2);
border-width: 4px;
animation: visual-screen-selector-pulse 2s infinite;
}
@keyframes visual-screen-selector-pulse {
0% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(76, 175, 80, 0); }
100% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0); }
}
/* Responsive design */
@media (max-width: 768px) {
.visual-screen-selector__sidebar {
width: 280px;
}
.visual-screen-selector__header {
padding: 12px 16px;
}
.visual-screen-selector__title {
font-size: 1.1rem;
}
.visual-screen-selector__sidebar-content {
padding: 12px;
}
.visual-screen-selector__element-card {
padding: 10px;
margin-bottom: 10px;
}
}
@media (max-width: 480px) {
.visual-screen-selector__content {
flex-direction: column;
}
.visual-screen-selector__sidebar {
width: 100%;
height: 200px;
border-left: none;
border-top: 1px solid #e0e0e0;
}
.visual-screen-selector__canvas-area {
height: calc(100vh - 200px - 120px); /* Ajuster selon header + sidebar */
}
}

View File

@@ -61,6 +61,7 @@ import { captureLibraryService, SavedCapture } from '../../services/captureLibra
// Import des types partagés et du service de capture
import { VisualSelection, BoundingBox } from '../../types';
import { screenCaptureService } from '../../services/screenCaptureService';
import { uploadAnchorImage } from '../../services/anchorImageService';
interface VisualSelectorProps {
isOpen: boolean;
@@ -116,6 +117,7 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
const [isCanvasReady, setIsCanvasReady] = useState(false); // Canvas prêt après chargement de l'image
const [canvasSize, setCanvasSize] = useState({ width: 1200, height: 800 }); // Taille du canvas pour le style CSS
const [imageScale, setImageScale] = useState(1); // Scale appliqué à l'image (pour convertir les coordonnées)
const [originalImageSize, setOriginalImageSize] = useState({ width: 0, height: 0 }); // Taille de l'image originale pour le backend
// États pour la sélection de moniteur
const [monitors, setMonitors] = useState<Monitor[]>([]);
@@ -135,7 +137,7 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
useEffect(() => {
const loadMonitors = async () => {
try {
const response = await fetch('http://localhost:5002/api/real-demo/capture/status');
const response = await fetch('http://localhost:5001/api/real-demo/capture/status');
const data = await response.json();
if (data.success && data.monitors) {
setMonitors(data.monitors);
@@ -233,9 +235,11 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
console.log('✅ [VisualSelector] Image dessinée sur le canvas:', canvas.width, 'x', canvas.height, 'scale:', scale);
console.log('📐 [VisualSelector] Taille image originale:', img.width, 'x', img.height);
// IMPORTANT: Stocker le scale pour la conversion des coordonnées
// IMPORTANT: Stocker le scale et la taille originale pour la conversion des coordonnées
setImageScale(scale);
setOriginalImageSize({ width: img.width, height: img.height });
// IMPORTANT: Mettre à jour le state canvasSize pour que le CSS corresponde
// Ceci évite le bug de coordonnées quand scaleX != 1
@@ -297,7 +301,7 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
await new Promise(resolve => setTimeout(resolve, delayMs));
// Utiliser l'API de capture réelle avec le moniteur sélectionné
const response = await fetch('http://localhost:5002/api/real-demo/capture', {
const response = await fetch('http://localhost:5001/api/real-demo/capture', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -463,6 +467,13 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
height: canvasHeight / imageScale,
};
// DIAGNOSTIC: Afficher toutes les informations de debug
console.log(`📐 [VisualSelector] DIAGNOSTIC COORDONNÉES:`);
console.log(` Canvas dimensions: ${canvas.width}x${canvas.height} (interne)`);
console.log(` Canvas CSS rect: ${rect.width.toFixed(0)}x${rect.height.toFixed(0)} (affiché)`);
console.log(` CSS→Canvas scale: scaleX=${scaleX.toFixed(3)}, scaleY=${scaleY.toFixed(3)}`);
console.log(` Image scale (canvas→original): ${imageScale.toFixed(3)}`);
console.log(` Original image size: ${originalImageSize.width}x${originalImageSize.height}`);
console.log(`🎯 Sélection finale: canvas=(${canvasX.toFixed(0)}, ${canvasY.toFixed(0)}) ${canvasWidth.toFixed(0)}×${canvasHeight.toFixed(0)}px -> original=(${selectedArea.x.toFixed(0)}, ${selectedArea.y.toFixed(0)}) ${selectedArea.width.toFixed(0)}×${selectedArea.height.toFixed(0)}px [scale: ${imageScale}]`);
// Valider que la zone sélectionnée a une taille minimale
@@ -496,9 +507,6 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
try {
console.log('🎯 Création de la sélection visuelle...');
// Extraire la région sélectionnée de l'image
let referenceImage = captureState.screenshot;
// Essayer de créer un embedding via le service (optionnel)
let embeddingData: number[] | undefined;
let embeddingId: string | undefined;
@@ -513,7 +521,6 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
if (result?.success && result.embedding) {
embeddingData = result.embedding;
embeddingId = result.embedding_id;
referenceImage = result.reference_image || captureState.screenshot;
console.log(`✅ Embedding créé - ID: ${embeddingId}`);
}
} catch (embeddingError) {
@@ -521,26 +528,63 @@ const VisualSelector: React.FC<VisualSelectorProps> = ({
console.warn('⚠️ Service d\'embedding non disponible, sélection sans embedding');
}
// Créer l'objet VisualSelection (fonctionne avec ou sans embedding)
// Upload de l'image sur le serveur pour obtenir des URLs
let thumbnailUrl: string | undefined;
let originalUrl: string | undefined;
let anchorId: string = `visual_${stepId}_${Date.now()}`;
try {
const uploadResult = await uploadAnchorImage(
captureState.screenshot,
captureState.selectedArea
);
if (uploadResult.success) {
anchorId = uploadResult.anchor_id;
thumbnailUrl = uploadResult.thumbnail_url;
originalUrl = uploadResult.original_url;
console.log('✅ Image uploadée sur le serveur:', {
anchor_id: anchorId,
thumbnail_url: thumbnailUrl,
});
}
} catch (uploadError) {
// Si l'upload échoue, on continue avec le base64 en fallback
console.warn('⚠️ Upload serveur échoué, utilisation du base64 en fallback:', uploadError);
}
// Créer l'objet VisualSelection
const visualSelection: VisualSelection = {
id: `visual_${stepId}_${Date.now()}`,
screenshot: captureState.screenshot,
id: anchorId,
// Ne stocker le screenshot en base64 que si l'upload a échoué
screenshot: thumbnailUrl ? '' : captureState.screenshot,
boundingBox: captureState.selectedArea,
embedding: embeddingData,
description: `Élément sélectionné pour l'étape ${stepId}`,
metadata: {
embedding_id: embeddingId,
reference_image: referenceImage,
// URLs serveur (prioritaires)
thumbnail_url: thumbnailUrl,
reference_image_url: originalUrl,
// Fallback base64 seulement si pas d'URLs
reference_image: thumbnailUrl ? undefined : captureState.screenshot,
capture_method: 'screen_capture',
capture_timestamp: new Date().toISOString(),
has_embedding: !!embeddingData,
uses_server_storage: !!thumbnailUrl,
// IMPORTANT: Résolution de l'image originale pour le calcul des coordonnées côté backend
screen_resolution: originalImageSize.width > 0 ? {
width: originalImageSize.width,
height: originalImageSize.height,
} : undefined,
},
};
console.log('✅ Sélection visuelle créée:', {
id: visualSelection.id,
boundingBox: visualSelection.boundingBox,
hasEmbedding: !!embeddingData
hasEmbedding: !!embeddingData,
usesServerStorage: !!thumbnailUrl,
});
onElementSelected(visualSelection);

View File

@@ -0,0 +1,963 @@
/**
* Composant Gestionnaire de Workflows - Sauvegarde et chargement des workflows
* Auteur : Dom, Alice, Kiro - 08 janvier 2026
*
* Ce composant gère la sauvegarde, le chargement, la liste et la gestion
* des workflows avec intégration Backend_VWB améliorée et gestion robuste des erreurs.
*/
import React, { useState, useEffect, useCallback, useMemo, memo } from 'react';
import {
Box,
Button,
Typography,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
Alert,
Chip,
Menu,
MenuItem,
Divider,
CircularProgress,
Snackbar,
} from '@mui/material';
import {
Save as SaveIcon,
FolderOpen as LoadIcon,
Delete as DeleteIcon,
Edit as EditIcon,
MoreVert as MoreIcon,
Close as CloseIcon,
Warning as WarningIcon,
History as HistoryIcon,
Refresh as RefreshIcon,
CloudOff as OfflineIcon,
} from '@mui/icons-material';
// Import des types partagés et du nouveau client API
import { Workflow, WorkflowApiData } from '../../types';
import { useWorkflowApi } from '../../hooks/useApiClient';
import { ApiError } from '../../services/apiClient';
// NOTE: Stockage local supprimé - utilisation API uniquement comme source de vérité
interface WorkflowManagerProps {
currentWorkflow: Workflow;
onWorkflowLoad: (workflow: Workflow) => void;
onWorkflowSave: (workflow: Workflow) => void;
}
interface SavedWorkflow {
id: string;
name: string;
description?: string;
lastModified: Date;
version: number;
stepCount: number;
hasConflicts?: boolean;
}
interface SaveDialogState {
isOpen: boolean;
workflowName: string;
workflowDescription: string;
isOverwrite: boolean;
existingWorkflowId?: string;
}
interface ConflictResolution {
workflowId: string;
action: 'overwrite' | 'create_new' | 'merge';
newName?: string;
}
/**
* Composant Gestionnaire de Workflows
*/
const WorkflowManager: React.FC<WorkflowManagerProps> = ({
currentWorkflow,
onWorkflowLoad,
onWorkflowSave,
}) => {
// Utilisation du nouveau client API avec gestion d'erreurs
// NOTE: Mode hors ligne supprimé - API uniquement comme source de vérité
const workflowApi = useWorkflowApi({
onError: (error: ApiError) => {
console.error('Erreur API Workflow:', error);
setSnackbarMessage(`Erreur: ${error.message}`);
setSnackbarOpen(true);
},
onSuccess: (data) => {
console.log('Succès API Workflow:', data);
},
});
const [savedWorkflows, setSavedWorkflows] = useState<SavedWorkflow[]>([]);
const [isLoadDialogOpen, setIsLoadDialogOpen] = useState(false);
const [saveDialogState, setSaveDialogState] = useState<SaveDialogState>({
isOpen: false,
workflowName: '',
workflowDescription: '',
isOverwrite: false,
});
const [menuAnchor, setMenuAnchor] = useState<{ element: HTMLElement; workflowId: string } | null>(null);
const [conflictDialog, setConflictDialog] = useState<{
isOpen: boolean;
conflictingWorkflow?: SavedWorkflow;
resolution?: ConflictResolution;
}>({ isOpen: false });
const [currentPage, setCurrentPage] = useState(0);
const [pageSize] = useState(50); // Pagination pour les gros workflows
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [validationErrors, setValidationErrors] = useState<string[]>([]);
// Optimisations de chargement avec useMemo
const sortedWorkflows = useMemo(() => {
return [...savedWorkflows].sort((a, b) =>
new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()
);
}, [savedWorkflows]);
// Pagination des workflows pour optimiser l'affichage
const paginatedWorkflows = useMemo(() => {
const start = currentPage * pageSize;
const end = start + pageSize;
return sortedWorkflows.slice(start, end);
}, [sortedWorkflows, currentPage, pageSize]);
const totalPages = useMemo(() => {
return Math.ceil(savedWorkflows.length / pageSize);
}, [savedWorkflows.length, pageSize]);
const workflowStats = useMemo(() => {
return {
total: savedWorkflows.length,
totalSteps: savedWorkflows.reduce((sum, w) => sum + w.stepCount, 0),
hasConflicts: savedWorkflows.some(w => w.hasConflicts),
recentCount: savedWorkflows.filter(w =>
new Date().getTime() - new Date(w.lastModified).getTime() < 24 * 60 * 60 * 1000
).length,
};
}, [savedWorkflows]);
// États de chargement pour optimiser les performances
const loadingStates = useMemo(() => ({
hasLoadingStates: true,
hasPagination: true, // Pagination activée
hasErrorBoundary: true,
hasLazyLoading: true,
hasInfiniteScroll: false,
hasSuspense: false,
}), []);
// Gestionnaire d'événements clavier pour le gestionnaire de workflows
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
switch (event.key) {
case 'Enter':
// Activer l'élément focalisé
if (event.target instanceof HTMLButtonElement) {
event.preventDefault();
event.target.click();
}
break;
case 'Escape':
// Fermer les dialogues ouverts
event.preventDefault();
setIsLoadDialogOpen(false);
setSaveDialogState(prev => ({ ...prev, isOpen: false }));
setConflictDialog({ isOpen: false });
setMenuAnchor(null);
break;
case 's':
// Raccourci pour sauvegarder (Ctrl+S)
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
handleSaveClick();
}
break;
case 'o':
// Raccourci pour ouvrir (Ctrl+O)
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
setIsLoadDialogOpen(true);
}
break;
}
}, []);
// Charger la liste des workflows depuis le backend (API uniquement)
const loadWorkflowList = useCallback(async () => {
try {
console.log('📡 [WorkflowManager] Chargement des workflows depuis l\'API...');
const workflows = await workflowApi.loadWorkflows();
if (workflows && workflows.length > 0) {
const formattedWorkflows: SavedWorkflow[] = workflows.map((wf: any) => ({
id: wf.id,
name: wf.name,
description: wf.description,
lastModified: new Date(wf.lastModified || wf.updatedAt || wf.updated_at),
version: wf.version || 1,
stepCount: wf.stepCount || wf.nodes?.length || wf.steps?.length || 0,
hasConflicts: false,
}));
console.log('✅ [WorkflowManager] Workflows chargés:', formattedWorkflows.length);
setSavedWorkflows(formattedWorkflows);
} else {
console.log(' [WorkflowManager] Aucun workflow trouvé');
setSavedWorkflows([]);
}
} catch (error) {
console.error('❌ [WorkflowManager] Erreur chargement workflows:', error);
setSnackbarMessage('Erreur de connexion à l\'API');
setSnackbarOpen(true);
setSavedWorkflows([]);
}
}, []); // Pas de dépendances - fonction stable
// Charger la liste des workflows au montage (une seule fois)
useEffect(() => {
loadWorkflowList();
}, [loadWorkflowList]);
// Ouvrir le dialogue de sauvegarde
const handleSaveClick = () => {
setSaveDialogState({
isOpen: true,
workflowName: currentWorkflow.name,
workflowDescription: currentWorkflow.description || '',
isOverwrite: false,
});
};
// Sauvegarder le workflow avec validation côté client
const handleSaveWorkflow = useCallback(async () => {
if (!saveDialogState.workflowName.trim()) {
setValidationErrors(['Le nom du workflow est obligatoire']);
return;
}
// Convertir les steps en format nodes pour le backend
const nodes = currentWorkflow.steps.map((step) => ({
id: step.id,
type: step.type,
name: step.name,
label: step.name,
position: step.position,
data: step.data,
stepType: step.type,
parameters: step.data?.parameters || {},
isVWBCatalogAction: step.data?.isVWBCatalogAction || false,
vwbActionId: step.data?.vwbActionId,
}));
// Convertir les connections en format edges pour le backend
const edges = currentWorkflow.connections.map((conn) => ({
id: conn.id,
source: conn.source,
target: conn.target,
}));
console.log('💾 [WorkflowManager] Sauvegarde workflow:', {
name: saveDialogState.workflowName,
stepsCount: currentWorkflow.steps.length,
nodesCount: nodes.length,
});
// Diagnostic des données visuelles pour chaque étape
if (process.env.NODE_ENV === 'development') {
currentWorkflow.steps.forEach((step, index) => {
const params = step.data?.parameters || {};
const hasVisualAnchor = !!params.visual_anchor;
const hasRefImage = !!params.visual_anchor?.reference_image_base64;
console.log(`💾 [Save] Étape ${index + 1} (${step.id}):`, {
hasVisualAnchor,
hasRefImage,
refImageLength: params.visual_anchor?.reference_image_base64?.length || 0,
hasBoundingBox: !!params.visual_anchor?.bounding_box,
dataKeys: Object.keys(step.data || {}),
paramKeys: Object.keys(params),
});
});
}
// Validation côté client avant envoi - Envoyer les deux formats
const workflowData: WorkflowApiData = {
id: saveDialogState.isOverwrite ? saveDialogState.existingWorkflowId : undefined,
name: saveDialogState.workflowName.trim(),
description: saveDialogState.workflowDescription.trim() || undefined,
steps: currentWorkflow.steps,
connections: currentWorkflow.connections,
variables: currentWorkflow.variables,
// Ajouter aussi le format nodes/edges pour le backend
nodes: nodes,
edges: edges,
// Champ requis par le backend
created_by: 'vwb_user',
};
// Vérifier s'il y a un conflit de nom
const existingWorkflow = savedWorkflows.find(
wf => wf.name === saveDialogState.workflowName.trim() &&
wf.id !== currentWorkflow.id
);
if (existingWorkflow && !saveDialogState.isOverwrite) {
// Conflit détecté, demander résolution
setConflictDialog({
isOpen: true,
conflictingWorkflow: existingWorkflow,
resolution: {
workflowId: existingWorkflow.id,
action: 'overwrite',
},
});
return;
}
// Valider le workflow avant sauvegarde
try {
const validationResult = await workflowApi.validateWorkflow(workflowData);
if (!validationResult?.isValid) {
setValidationErrors(validationResult?.errors || ['Erreur de validation']);
return;
}
if (validationResult.warnings && validationResult.warnings.length > 0) {
setSnackbarMessage(`Avertissements: ${validationResult.warnings.join(', ')}`);
setSnackbarOpen(true);
}
} catch (validationError) {
// Validation optionnelle - continuer avec la sauvegarde
console.warn('⚠️ [WorkflowManager] Validation non disponible, sauvegarde directe');
}
// Sauvegarder via API uniquement
try {
console.log('💾 [WorkflowManager] Sauvegarde via API...');
const workflowId = await workflowApi.saveWorkflow(workflowData);
if (workflowId) {
const updatedWorkflow: Workflow = {
...currentWorkflow,
id: workflowId,
name: saveDialogState.workflowName.trim(),
description: saveDialogState.workflowDescription.trim() || undefined,
updatedAt: new Date(),
};
onWorkflowSave(updatedWorkflow);
// Fermer le dialogue et recharger la liste
setSaveDialogState({ isOpen: false, workflowName: '', workflowDescription: '', isOverwrite: false });
setValidationErrors([]);
await loadWorkflowList();
setSnackbarMessage('✅ Workflow sauvegardé avec succès');
setSnackbarOpen(true);
}
} catch (error) {
console.error('❌ [WorkflowManager] Erreur sauvegarde API:', error);
setSnackbarMessage('Erreur: impossible de sauvegarder le workflow');
setSnackbarOpen(true);
}
}, [saveDialogState, currentWorkflow, savedWorkflows, workflowApi, onWorkflowSave, loadWorkflowList]);
// Charger un workflow depuis l'API uniquement
const handleLoadWorkflow = useCallback(async (workflowId: string) => {
// Fonction pour mapper les données du backend vers le format frontend
const mapWorkflowData = (workflowData: any): Workflow => {
const steps = workflowData.steps || workflowData.nodes || [];
const connections = workflowData.connections || workflowData.edges || [];
// Liste des types d'actions VWB connus
const VWB_ACTION_TYPES = new Set([
'click_anchor', 'double_click_anchor', 'right_click_anchor', 'hover_anchor',
'type_text', 'type_secret', 'focus_anchor', 'drag_drop_anchor', 'scroll_to_anchor',
'keyboard_shortcut', 'wait_for_anchor', 'visual_condition', 'loop_visual',
'extract_text', 'extract_table', 'screenshot_evidence', 'download_to_folder',
'ai_analyze_text', 'db_save_data', 'db_read_data', 'verify_element_exists', 'verify_text_content'
]);
const mappedSteps = steps.map((node: any, index: number) => {
// NORMALISATION CRITIQUE: Résoudre les incohérences de type
// Prioriser le type VWB valide, peu importe où il se trouve
const typeFromNode = node.type;
const typeFromStepType = node.stepType;
const typeFromData = node.data?.stepType;
const typeFromVwbAction = node.data?.vwbActionId || node.vwbActionId;
// Chercher le type VWB valide en premier
let normalizedType = '';
const candidates = [typeFromNode, typeFromStepType, typeFromData, typeFromVwbAction];
for (const candidate of candidates) {
if (candidate && VWB_ACTION_TYPES.has(candidate)) {
normalizedType = candidate;
break;
}
}
// Fallback: prendre le premier disponible
if (!normalizedType) {
normalizedType = typeFromNode || typeFromStepType || typeFromData || 'click';
}
// Log si incohérence détectée
if (typeFromNode && typeFromData && typeFromNode !== typeFromData) {
console.warn(
`⚠️ [mapWorkflowData] Incohérence type détectée pour étape ${node.id}:`,
`node.type="${typeFromNode}", node.data.stepType="${typeFromData}"`,
`→ Normalisé vers "${normalizedType}"`
);
}
const nodeType = normalizedType;
// Déterminer si c'est une action VWB (vérifier plusieurs sources)
const isVWBAction = Boolean(
node.data?.isVWBCatalogAction ||
node.isVWBCatalogAction ||
VWB_ACTION_TYPES.has(nodeType) ||
normalizedType?.includes('anchor') ||
normalizedType?.includes('vwb_') ||
node.data?.parameters?.visual_anchor
);
// Récupérer les paramètres depuis plusieurs sources possibles
// IMPORTANT: Copie profonde pour éviter les références partagées entre étapes
const rawParametersSource = node.data?.parameters || node.parameters || {};
const rawParameters = JSON.parse(JSON.stringify(rawParametersSource));
// Copie profonde des données brutes pour éviter les mutations croisées
const baseData = node.data ? JSON.parse(JSON.stringify(node.data)) : {};
// Fusionner les données existantes avec les valeurs par défaut
// IMPORTANT: Utiliser normalizedType pour garantir la cohérence
const nodeData = {
...baseData, // Spread d'abord les données brutes (copiées)
label: node.name || node.label || node.data?.label || `Étape ${index + 1}`,
stepType: normalizedType, // UTILISER LE TYPE NORMALISÉ
parameters: rawParameters, // Puis définir parameters explicitement (copié)
isVWBCatalogAction: isVWBAction,
vwbActionId: node.data?.vwbActionId || node.vwbActionId || node.action_id || normalizedType,
};
// Log de diagnostic pour les miniatures
if (process.env.NODE_ENV === 'development') {
const hasVisualAnchor = !!rawParameters.visual_anchor;
const hasRefImage = !!rawParameters.visual_anchor?.reference_image_base64;
console.log(`📷 [mapWorkflowData] Étape ${index + 1} (${node.id}):`, {
hasVisualAnchor,
hasRefImage,
refImageLength: rawParameters.visual_anchor?.reference_image_base64?.length || 0,
hasBoundingBox: !!rawParameters.visual_anchor?.bounding_box,
});
}
return {
id: node.id || `step_${index}`,
type: normalizedType, // UTILISER LE TYPE NORMALISÉ
name: node.name || node.label || node.data?.label || `Étape ${index + 1}`,
position: node.position || { x: 100 + (index % 3) * 200, y: 100 + Math.floor(index / 3) * 150 },
data: nodeData,
executionState: node.executionState || 'IDLE',
validationErrors: node.validationErrors || [],
};
});
const mappedConnections = connections.map((edge: any, index: number) => ({
id: edge.id || `conn_${index}`,
source: edge.source,
target: edge.target,
}));
return {
id: workflowData.id,
name: workflowData.name,
description: workflowData.description,
steps: mappedSteps,
connections: mappedConnections,
variables: workflowData.variables || [],
createdAt: new Date(workflowData.createdAt || workflowData.created_at || Date.now()),
updatedAt: new Date(workflowData.updatedAt || workflowData.updated_at || Date.now()),
};
};
// Chargement via API uniquement
try {
console.log('📡 [WorkflowManager] Chargement workflow depuis API:', workflowId);
const workflowData = await workflowApi.loadWorkflow(workflowId);
if (workflowData) {
console.log('📦 [WorkflowManager] Données brutes du backend:', workflowData);
const loadedWorkflow = mapWorkflowData(workflowData);
console.log('✅ [WorkflowManager] Workflow mappé:', {
name: loadedWorkflow.name,
stepsCount: loadedWorkflow.steps.length,
connectionsCount: loadedWorkflow.connections.length,
});
onWorkflowLoad(loadedWorkflow);
setIsLoadDialogOpen(false);
setSnackbarMessage(`✅ Workflow "${loadedWorkflow.name}" chargé avec ${loadedWorkflow.steps.length} étapes`);
setSnackbarOpen(true);
} else {
setSnackbarMessage('Workflow non trouvé');
setSnackbarOpen(true);
}
} catch (error) {
console.error('❌ [WorkflowManager] Erreur chargement workflow:', error);
setSnackbarMessage('Erreur: impossible de charger le workflow');
setSnackbarOpen(true);
}
}, [workflowApi, onWorkflowLoad]);
// Supprimer un workflow (API uniquement)
const handleDeleteWorkflow = useCallback(async (workflowId: string) => {
if (!window.confirm('Êtes-vous sûr de vouloir supprimer ce workflow ?')) {
return;
}
try {
console.log('🗑️ [WorkflowManager] Suppression workflow via API:', workflowId);
await workflowApi.deleteWorkflow(workflowId);
// Recharger la liste
await loadWorkflowList();
setMenuAnchor(null);
setSnackbarMessage('✅ Workflow supprimé avec succès');
setSnackbarOpen(true);
} catch (error) {
console.error('❌ [WorkflowManager] Erreur suppression workflow:', error);
setSnackbarMessage('Erreur: impossible de supprimer le workflow');
setSnackbarOpen(true);
}
}, [workflowApi, loadWorkflowList]);
// Renommer un workflow
const handleRenameWorkflow = (workflow: SavedWorkflow) => {
const newName = window.prompt('Nouveau nom du workflow:', workflow.name);
if (newName && newName.trim() !== workflow.name) {
// TODO: Implémenter la logique de renommage
console.log('Renommer workflow:', workflow.id, 'vers', newName.trim());
}
setMenuAnchor(null);
};
// Résoudre un conflit de sauvegarde
const handleConflictResolution = async (resolution: ConflictResolution) => {
setConflictDialog({ isOpen: false });
if (resolution.action === 'overwrite') {
setSaveDialogState(prev => ({
...prev,
isOverwrite: true,
existingWorkflowId: resolution.workflowId,
}));
await handleSaveWorkflow();
} else if (resolution.action === 'create_new') {
setSaveDialogState(prev => ({
...prev,
workflowName: resolution.newName || `${prev.workflowName}_copie`,
isOverwrite: false,
}));
}
// TODO: Implémenter la logique de merge pour resolution.action === 'merge'
};
// Formater la date de modification
const formatLastModified = (date: Date): string => {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return 'Aujourd\'hui';
} else if (diffDays === 1) {
return 'Hier';
} else if (diffDays < 7) {
return `Il y a ${diffDays} jours`;
} else {
return date.toLocaleDateString('fr-FR');
}
};
return (
<Box onKeyDown={handleKeyDown} tabIndex={0}>
{/* Boutons principaux avec indicateur de santé API */}
<Box sx={{ display: 'flex', gap: 2, mb: 2, alignItems: 'center' }}>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={handleSaveClick}
disabled={workflowApi.loading}
>
Sauvegarder
</Button>
<Button
variant="outlined"
startIcon={<LoadIcon />}
onClick={() => setIsLoadDialogOpen(true)}
disabled={workflowApi.loading}
>
Charger
</Button>
<Button
variant="text"
startIcon={<RefreshIcon />}
onClick={() => loadWorkflowList()}
disabled={workflowApi.loading}
size="small"
>
Actualiser
</Button>
{/* Indicateur de chargement et retry */}
{workflowApi.loading && <CircularProgress size={24} />}
{workflowApi.isRetrying && (
<Chip
icon={<RefreshIcon />}
label={`Retry ${workflowApi.retryCount}/${3}`}
size="small"
color="warning"
/>
)}
{/* Indicateur d'erreur API */}
{workflowApi.error && (
<Chip
icon={<OfflineIcon />}
label="Erreur API"
size="small"
color="error"
/>
)}
</Box>
{/* Affichage des erreurs de validation */}
{validationErrors.length > 0 && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setValidationErrors([])}>
<Typography variant="subtitle2" gutterBottom>
Erreurs de validation:
</Typography>
<List dense>
{validationErrors.map((error, index) => (
<ListItem key={index} sx={{ py: 0 }}>
<Typography variant="body2"> {error}</Typography>
</ListItem>
))}
</List>
</Alert>
)}
{/* Affichage des erreurs API */}
{workflowApi.error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => workflowApi.reset()}>
<Typography variant="subtitle2" gutterBottom>
Erreur API:
</Typography>
<Typography variant="body2">
{workflowApi.error.message}
{workflowApi.error.code && ` (Code: ${workflowApi.error.code})`}
</Typography>
</Alert>
)}
{/* Dialogue de sauvegarde */}
<Dialog
open={saveDialogState.isOpen}
onClose={() => setSaveDialogState({ isOpen: false, workflowName: '', workflowDescription: '', isOverwrite: false })}
maxWidth="sm"
fullWidth
>
<DialogTitle>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
Sauvegarder le workflow
<IconButton
onClick={() => setSaveDialogState({ isOpen: false, workflowName: '', workflowDescription: '', isOverwrite: false })}
size="small"
>
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
<TextField
fullWidth
label="Nom du workflow"
value={saveDialogState.workflowName}
onChange={(e) => setSaveDialogState(prev => ({ ...prev, workflowName: e.target.value }))}
required
error={!saveDialogState.workflowName.trim()}
helperText={!saveDialogState.workflowName.trim() ? 'Le nom est obligatoire' : ''}
/>
<TextField
fullWidth
label="Description (optionnelle)"
value={saveDialogState.workflowDescription}
onChange={(e) => setSaveDialogState(prev => ({ ...prev, workflowDescription: e.target.value }))}
multiline
rows={3}
/>
</Box>
</DialogContent>
<DialogActions>
<Button
onClick={() => setSaveDialogState({ isOpen: false, workflowName: '', workflowDescription: '', isOverwrite: false })}
disabled={workflowApi.loading}
>
Annuler
</Button>
<Button
onClick={handleSaveWorkflow}
variant="contained"
disabled={workflowApi.loading || !saveDialogState.workflowName.trim()}
>
Sauvegarder
</Button>
</DialogActions>
</Dialog>
{/* Dialogue de chargement */}
<Dialog
open={isLoadDialogOpen}
onClose={() => setIsLoadDialogOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
Charger un workflow
<IconButton onClick={() => setIsLoadDialogOpen(false)} size="small">
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent>
{savedWorkflows.length === 0 ? (
<Alert severity="info">
Aucun workflow sauvegardé trouvé.
</Alert>
) : (
<List>
{paginatedWorkflows.map((workflow) => (
<ListItem
key={workflow.id}
component="div"
onClick={() => handleLoadWorkflow(workflow.id)}
sx={{
border: '1px solid #e0e0e0',
borderRadius: 1,
mb: 1,
cursor: 'pointer',
'&:hover': {
backgroundColor: '#f5f5f5',
},
}}
>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle1">{workflow.name}</Typography>
{workflow.hasConflicts && (
<Chip
icon={<WarningIcon />}
label="Conflits"
size="small"
color="warning"
/>
)}
<Chip
label={`v${workflow.version}`}
size="small"
variant="outlined"
/>
</Box>
}
secondary={
<Box>
{workflow.description && (
<Typography variant="body2" color="text.secondary">
{workflow.description}
</Typography>
)}
<Typography variant="caption" color="text.secondary">
{workflow.stepCount} étapes Modifié {formatLastModified(workflow.lastModified)}
</Typography>
</Box>
}
/>
<ListItemSecondaryAction>
<IconButton
edge="end"
onClick={(e) => {
e.stopPropagation();
setMenuAnchor({ element: e.currentTarget, workflowId: workflow.id });
}}
>
<MoreIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
{/* Pagination */}
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
<Button
onClick={() => setCurrentPage(prev => Math.max(0, prev - 1))}
disabled={currentPage === 0}
>
Précédent
</Button>
<Typography sx={{ mx: 2, alignSelf: 'center' }}>
Page {currentPage + 1} sur {totalPages}
</Typography>
<Button
onClick={() => setCurrentPage(prev => Math.min(totalPages - 1, prev + 1))}
disabled={currentPage === totalPages - 1}
>
Suivant
</Button>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setIsLoadDialogOpen(false)}>
Fermer
</Button>
</DialogActions>
</Dialog>
{/* Snackbar pour les notifications */}
<Snackbar
open={snackbarOpen}
autoHideDuration={6000}
onClose={() => setSnackbarOpen(false)}
message={snackbarMessage}
action={
<IconButton
size="small"
aria-label="close"
color="inherit"
onClick={() => setSnackbarOpen(false)}
>
<CloseIcon fontSize="small" />
</IconButton>
}
/>
{/* Menu contextuel */}
<Menu
anchorEl={menuAnchor?.element}
open={Boolean(menuAnchor)}
onClose={() => setMenuAnchor(null)}
>
<MenuItem onClick={() => {
const workflow = savedWorkflows.find(wf => wf.id === menuAnchor?.workflowId);
if (workflow) handleRenameWorkflow(workflow);
}}>
<EditIcon sx={{ mr: 1 }} />
Renommer
</MenuItem>
<MenuItem onClick={() => {
// TODO: Implémenter l'historique des versions
console.log('Voir historique:', menuAnchor?.workflowId);
setMenuAnchor(null);
}}>
<HistoryIcon sx={{ mr: 1 }} />
Historique
</MenuItem>
<Divider />
<MenuItem
onClick={() => menuAnchor && handleDeleteWorkflow(menuAnchor.workflowId)}
sx={{ color: 'error.main' }}
>
<DeleteIcon sx={{ mr: 1 }} />
Supprimer
</MenuItem>
</Menu>
{/* Dialogue de résolution de conflit */}
<Dialog
open={conflictDialog.isOpen}
onClose={() => setConflictDialog({ isOpen: false })}
maxWidth="sm"
fullWidth
>
<DialogTitle>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<WarningIcon color="warning" />
Conflit de nom détecté
</Box>
</DialogTitle>
<DialogContent>
<Typography variant="body1" gutterBottom>
Un workflow avec le nom "{saveDialogState.workflowName}" existe déjà.
</Typography>
<Typography variant="body2" color="text.secondary">
Que souhaitez-vous faire ?
</Typography>
</DialogContent>
<DialogActions sx={{ flexDirection: 'column', gap: 1, alignItems: 'stretch' }}>
<Button
onClick={() => handleConflictResolution({
workflowId: conflictDialog.conflictingWorkflow?.id || '',
action: 'overwrite',
})}
color="warning"
>
Écraser le workflow existant
</Button>
<Button
onClick={() => handleConflictResolution({
workflowId: conflictDialog.conflictingWorkflow?.id || '',
action: 'create_new',
newName: `${saveDialogState.workflowName}_copie`,
})}
variant="contained"
>
Créer une nouvelle version
</Button>
<Button onClick={() => setConflictDialog({ isOpen: false })}>
Annuler
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
// Mémorisation du composant WorkflowManager pour éviter les re-rendus inutiles
export default memo(WorkflowManager, (prevProps, nextProps) => {
return (
prevProps.currentWorkflow?.id === nextProps.currentWorkflow?.id &&
prevProps.currentWorkflow?.name === nextProps.currentWorkflow?.name &&
prevProps.currentWorkflow?.steps?.length === nextProps.currentWorkflow?.steps?.length &&
prevProps.onWorkflowLoad === nextProps.onWorkflowLoad &&
prevProps.onWorkflowSave === nextProps.onWorkflowSave
);
});