From 641a009b34ce3dedea39183e0a773501b1503bd4 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Sun, 28 Sep 2025 22:36:22 +0200 Subject: [PATCH] refactor: streamline TaskCard component and enhance UI integration - Removed unused state and effects in `TaskCard`, simplifying the component structure. - Integrated `UITaskCard` for improved UI consistency and modularity. - Updated event handlers for editing and deleting tasks to enhance user interaction. - Enhanced props handling for better customization and flexibility in task display. - Improved emoji handling and title editing functionality for a smoother user experience. --- src/components/kanban/TaskCard.tsx | 543 ++------------------------ src/components/ui/TaskCard.tsx | 591 +++++++++++++++++++++++++---- 2 files changed, 567 insertions(+), 567 deletions(-) diff --git a/src/components/kanban/TaskCard.tsx b/src/components/kanban/TaskCard.tsx index a86ad2e..cdfa57d 100644 --- a/src/components/kanban/TaskCard.tsx +++ b/src/components/kanban/TaskCard.tsx @@ -1,15 +1,9 @@ -import { useState, useEffect, useRef, useTransition } from 'react'; +import { useTransition } from 'react'; import { Task } from '@/lib/types'; -import { TfsConfig } from '@/services/integrations/tfs'; -import { formatDistanceToNow } from 'date-fns'; -import { fr } from 'date-fns/locale'; -import { Card } from '@/components/ui/Card'; -import { Badge } from '@/components/ui/Badge'; -import { TagDisplay } from '@/components/ui/TagDisplay'; +import { TaskCard as UITaskCard } from '@/components/ui/TaskCard'; import { useTasksContext } from '@/contexts/TasksContext'; import { useUserPreferences } from '@/contexts/UserPreferencesContext'; import { useDraggable } from '@dnd-kit/core'; -import { getPriorityConfig, getPriorityColorHex } from '@/lib/status-config'; import { updateTaskTitle, deleteTask } from '@/actions/tasks'; interface TaskCardProps { @@ -19,146 +13,44 @@ interface TaskCardProps { } export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) { - const [isEditingTitle, setIsEditingTitle] = useState(false); - const [editTitle, setEditTitle] = useState(task.title); const [isPending, startTransition] = useTransition(); - const timeoutRef = useRef(null); const { tags: availableTags, refreshTasks } = useTasksContext(); const { preferences } = useUserPreferences(); - // Classes CSS pour les différentes tailles de police - const getFontSizeClasses = () => { - switch (preferences.viewPreferences.fontSize) { - case 'small': - return { - title: 'text-xs', - description: 'text-xs', - meta: 'text-xs', - }; - case 'large': - return { - title: 'text-base', - description: 'text-sm', - meta: 'text-sm', - }; - default: // medium - return { - title: 'text-sm', - description: 'text-xs', - meta: 'text-xs', - }; - } - }; - - const fontClasses = getFontSizeClasses(); - - // Helper pour construire l'URL Jira - const getJiraTicketUrl = (jiraKey: string): string => { - const baseUrl = preferences.jiraConfig.baseUrl; - if (!baseUrl || !jiraKey) return ''; - return `${baseUrl}/browse/${jiraKey}`; - }; - - // Helper pour construire l'URL TFS Pull Request - const getTfsPullRequestUrl = ( - tfsPullRequestId: number, - tfsProject: string, - tfsRepository: string - ): string => { - const tfsConfig = preferences.tfsConfig as TfsConfig; - const baseUrl = tfsConfig?.organizationUrl; - if (!baseUrl || !tfsPullRequestId || !tfsProject || !tfsRepository) - return ''; - return `${baseUrl}/${encodeURIComponent(tfsProject)}/_git/${tfsRepository}/pullrequest/${tfsPullRequestId}`; - }; - // Configuration du draggable const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: task.id, }); - // Mettre à jour le titre local quand la tâche change - useEffect(() => { - setEditTitle(task.title); - }, [task.title]); - - // Nettoyer le timeout au démontage - useEffect(() => { - const currentTimeout = timeoutRef.current; - return () => { - if (currentTimeout) { - clearTimeout(currentTimeout); - } - }; - }, []); - - const handleDelete = async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - + const handleDelete = async () => { if (window.confirm('Êtes-vous sûr de vouloir supprimer cette tâche ?')) { startTransition(async () => { const result = await deleteTask(task.id); if (!result.success) { console.error('Error deleting task:', result.error); - // TODO: Afficher une notification d'erreur } else { - // Rafraîchir les données après suppression réussie await refreshTasks(); } }); } }; - const handleEdit = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); + const handleEdit = () => { if (onEdit) { onEdit(task); } }; - const handleTitleClick = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (!isDragging && !isPending) { - setIsEditingTitle(true); - } - }; - - const handleTitleSave = async () => { - const trimmedTitle = editTitle.trim(); - if (trimmedTitle && trimmedTitle !== task.title) { - startTransition(async () => { - const result = await updateTaskTitle(task.id, trimmedTitle); - if (!result.success) { - console.error('Error updating task title:', result.error); - // Remettre l'ancien titre en cas d'erreur - setEditTitle(task.title); - } else { - // Mettre à jour optimistiquement le titre local - // La Server Action a déjà mis à jour la DB, on synchronise juste l'affichage - task.title = trimmedTitle; - } - }); - } - setIsEditingTitle(false); - }; - - const handleTitleCancel = () => { - setEditTitle(task.title); - setIsEditingTitle(false); - }; - - const handleTitleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleTitleSave(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleTitleCancel(); - } + const handleTitleSave = async (newTitle: string) => { + startTransition(async () => { + const result = await updateTaskTitle(task.id, newTitle); + if (!result.success) { + console.error('Error updating task title:', result.error); + } else { + task.title = newTitle; + } + }); }; // Style de transformation pour le drag @@ -168,389 +60,36 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) { } : undefined; - // Extraire les emojis du titre pour les afficher comme tags visuels - const emojiRegex = - /(?:[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}])(?:[\u{200D}][\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE0F}])*/gu; - const titleEmojis = task.title.match(emojiRegex) || []; - const titleWithoutEmojis = task.title.replace(emojiRegex, '').trim(); - - // Composant titre avec tooltip - const TitleWithTooltip = () => ( -
-

- {titleWithoutEmojis} -

-
- ); - - // Si pas d'emoji dans le titre, utiliser l'emoji du premier tag - let displayEmojis: string[] = titleEmojis; - if (displayEmojis.length === 0 && task.tags && task.tags.length > 0) { - const firstTag = availableTags.find((tag) => tag.name === task.tags[0]); - if (firstTag) { - const tagEmojis = firstTag.name.match(emojiRegex); - if (tagEmojis && tagEmojis.length > 0) { - displayEmojis = [tagEmojis[0]]; // Prendre seulement le premier emoji du tag - } - } - } - - // Styles spéciaux pour les tâches Jira - const isJiraTask = task.source === 'jira'; - const jiraStyles = isJiraTask - ? { - border: '1px solid rgba(0, 130, 201, 0.3)', - borderLeft: '3px solid #0082C9', - background: - 'linear-gradient(135deg, rgba(0, 130, 201, 0.05) 0%, rgba(0, 130, 201, 0.02) 100%)', - } - : {}; - - // Styles spéciaux pour les tâches TFS - const isTfsTask = task.source === 'tfs'; - const tfsStyles = isTfsTask - ? { - border: '1px solid rgba(255, 165, 0, 0.3)', - borderLeft: '3px solid #FFA500', - background: - 'linear-gradient(135deg, rgba(255, 165, 0, 0.05) 0%, rgba(255, 165, 0, 0.02) 100%)', - } - : {}; - - // Combiner les styles spéciaux - const specialStyles = { ...jiraStyles, ...tfsStyles }; - - // Vue compacte : seulement le titre - if (compactView) { - return ( - -
- {displayEmojis.length > 0 && ( -
- {displayEmojis.slice(0, 1).map((emoji, index) => ( - - {emoji} - - ))} -
- )} - - {isEditingTitle ? ( - setEditTitle(e.target.value)} - onKeyDown={handleTitleKeyPress} - onBlur={handleTitleSave} - autoFocus - className={`flex-1 bg-transparent border-none outline-none text-[var(--foreground)] font-mono ${fontClasses.title} font-medium leading-tight`} - /> - ) : ( - - )} - -
- {/* Boutons d'action compacts - masqués en mode édition */} - {!isEditingTitle && onEdit && ( - - )} - - {!isEditingTitle && ( - - )} - - {/* Indicateur de priorité compact */} -
-
-
- - ); - } - - // Vue détaillée : version complète return ( - - {/* Header tech avec titre et status */} -
- {displayEmojis.length > 0 && ( -
- {displayEmojis.slice(0, 2).map((emoji, index) => ( - - {emoji} - - ))} -
- )} - - {isEditingTitle ? ( - setEditTitle(e.target.value)} - onKeyDown={handleTitleKeyPress} - onBlur={handleTitleSave} - autoFocus - className="flex-1 bg-transparent border-none outline-none text-[var(--foreground)] font-mono text-sm font-medium leading-tight" - /> - ) : ( - - )} - -
- {/* Bouton d'édition discret - masqué en mode édition */} - {!isEditingTitle && onEdit && ( - - )} - - {/* Bouton de suppression discret - masqué en mode édition */} - {!isEditingTitle && ( - - )} - - {/* Indicateur de priorité tech */} -
-
-
- - {/* Description tech */} - {task.description && ( -

- {task.description} -

- )} - - {/* Tags avec couleurs */} - {task.tags && task.tags.length > 0 && ( -
- -
- )} - - {/* Footer tech avec séparateur néon - seulement si des données à afficher */} - {(task.dueDate || - (task.source && task.source !== 'manual') || - task.completedAt) && ( -
-
- {task.dueDate ? ( - - - {formatDistanceToNow(task.dueDate, { - addSuffix: true, - locale: fr, - })} - - ) : ( -
- )} - -
- {task.source !== 'manual' && - task.source && - (task.source === 'jira' && task.jiraKey ? ( - preferences.jiraConfig.baseUrl ? ( - e.stopPropagation()} - className="hover:scale-105 transition-transform" - > - - {task.jiraKey} - - - ) : ( - - {task.jiraKey} - - ) - ) : task.source === 'tfs' && task.tfsPullRequestId ? ( - preferences.tfsConfig && - (preferences.tfsConfig as TfsConfig).organizationUrl ? ( - e.stopPropagation()} - className="hover:scale-105 transition-transform" - > - - PR-{task.tfsPullRequestId} - - - ) : ( - - PR-{task.tfsPullRequestId} - - ) - ) : ( - - {task.source} - - ))} - - {/* Badges spécifiques TFS */} - {task.tfsRepository && ( - - {task.tfsRepository} - - )} - - {task.jiraProject && ( - - {task.jiraProject} - - )} - - {task.jiraType && ( - - {task.jiraType} - - )} - - {task.completedAt && ( - - ✓ DONE - - )} -
-
-
- )} - + {...listeners} + /> ); } diff --git a/src/components/ui/TaskCard.tsx b/src/components/ui/TaskCard.tsx index 36dc8f2..47b3c4e 100644 --- a/src/components/ui/TaskCard.tsx +++ b/src/components/ui/TaskCard.tsx @@ -1,83 +1,544 @@ -import { ReactNode } from 'react'; -import { Badge } from './Badge'; +import { HTMLAttributes, forwardRef, useState, useEffect, useRef } from 'react'; import { cn } from '@/lib/utils'; +import { Card } from './Card'; +import { Badge } from './Badge'; -interface TaskCardProps { +interface TaskCardProps extends HTMLAttributes { + // Variants + variant?: 'compact' | 'detailed'; + source?: 'manual' | 'jira' | 'tfs' | 'reminders'; + + // Content title: string; description?: string; - status?: string; - priority?: string; - tags?: ReactNode[]; - metadata?: ReactNode; - actions?: ReactNode; + tags?: string[]; + priority?: 'low' | 'medium' | 'high' | 'urgent'; + + // Status & metadata + status?: 'todo' | 'in_progress' | 'review' | 'done' | 'archived' | 'freeze' | 'backlog' | 'cancelled'; + dueDate?: Date; + completedAt?: Date; + + // Source-specific data + jiraKey?: string; + jiraProject?: string; + jiraType?: string; + tfsPullRequestId?: number; + tfsProject?: string; + tfsRepository?: string; + + // Interactive + isDragging?: boolean; + isPending?: boolean; + isEditing?: boolean; + onEdit?: (e?: React.MouseEvent) => void; + onDelete?: (e?: React.MouseEvent) => void; + onTitleClick?: (e?: React.MouseEvent) => void; + onTitleSave?: (title: string) => void; + + // Styling & behavior className?: string; + fontSize?: 'small' | 'medium' | 'large'; + availableTags?: Array<{ id: string; name: string; color: string }>; + jiraConfig?: { baseUrl?: string }; + tfsConfig?: { organizationUrl?: string }; } -export function TaskCard({ - title, - description, - status, - priority, - tags, - metadata, - actions, - className -}: TaskCardProps) { - return ( -
-
-
-
-

- {title} -

+const TaskCard = forwardRef( + ({ + variant = 'detailed', + source = 'manual', + title, + description, + tags = [], + priority = 'medium', + status, + dueDate, + completedAt, + jiraKey, + jiraProject, + jiraType, + tfsPullRequestId, + tfsProject, + tfsRepository, + isDragging = false, + isPending = false, + onEdit, + onDelete, + onTitleClick, + onTitleSave, + className, + fontSize = 'medium', + availableTags = [], + jiraConfig, + tfsConfig, + children, + ...props + }, ref) => { + + // État local pour l'édition du titre + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [editTitle, setEditTitle] = useState(title); + const timeoutRef = useRef(null); + + // Mettre à jour le titre local quand la prop change + useEffect(() => { + setEditTitle(title); + }, [title]); + + // Nettoyer le timeout au démontage + useEffect(() => { + const currentTimeout = timeoutRef.current; + return () => { + if (currentTimeout) { + clearTimeout(currentTimeout); + } + }; + }, []); + + // Gestionnaires d'événements avec preventDefault/stopPropagation + const handleEdit = (e?: React.MouseEvent) => { + e?.preventDefault(); + e?.stopPropagation(); + onEdit?.(e); + }; + + const handleDelete = (e?: React.MouseEvent) => { + e?.preventDefault(); + e?.stopPropagation(); + onDelete?.(e); + }; + + const handleTitleClick = (e?: React.MouseEvent) => { + e?.preventDefault(); + e?.stopPropagation(); + if (!isDragging && !isPending) { + setIsEditingTitle(true); + } + onTitleClick?.(e); + }; + + const handleTitleSave = async () => { + const trimmedTitle = editTitle.trim(); + if (trimmedTitle && trimmedTitle !== title) { + onTitleSave?.(trimmedTitle); + } + setIsEditingTitle(false); + }; + + const handleTitleCancel = () => { + setEditTitle(title); + setIsEditingTitle(false); + }; + + const handleTitleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleTitleSave(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleTitleCancel(); + } + }; + + // Classes CSS pour les différentes tailles de police + const getFontSizeClasses = () => { + switch (fontSize) { + case 'small': + return { + title: 'text-xs', + description: 'text-xs', + meta: 'text-xs', + }; + case 'large': + return { + title: 'text-base', + description: 'text-sm', + meta: 'text-sm', + }; + default: // medium + return { + title: 'text-sm', + description: 'text-xs', + meta: 'text-xs', + }; + } + }; + + const fontClasses = getFontSizeClasses(); + + // Styles spéciaux pour les sources + const getSourceStyles = () => { + if (source === 'jira') { + return { + border: '2px solid rgba(0, 130, 201, 0.8)', + borderLeft: '6px solid #0052CC', + background: 'linear-gradient(135deg, rgba(0, 130, 201, 0.3) 0%, rgba(0, 130, 201, 0.2) 100%)', + boxShadow: '0 4px 12px rgba(0, 130, 201, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.3)', + }; + } + if (source === 'tfs') { + return { + border: '2px solid rgba(255, 165, 0, 0.8)', + borderLeft: '6px solid #FF8C00', + background: 'linear-gradient(135deg, rgba(255, 165, 0, 0.3) 0%, rgba(255, 165, 0, 0.2) 100%)', + boxShadow: '0 4px 12px rgba(255, 165, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.3)', + }; + } + return {}; + }; + + // Couleurs de priorité + const getPriorityColor = (priority: string) => { + const colors = { + low: '#10b981', // green + medium: '#f59e0b', // amber + high: '#ef4444', // red + urgent: '#dc2626', // red-600 + }; + return colors[priority as keyof typeof colors] || colors.medium; + }; + + // Extraire les emojis du titre + const emojiRegex = /(?:[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}])(?:[\u{200D}][\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE0F}])*/gu; + const titleEmojis = title.match(emojiRegex) || []; + const titleWithoutEmojis = title.replace(emojiRegex, '').trim(); + + // Si pas d'emoji dans le titre, utiliser l'emoji du premier tag + let displayEmojis: string[] = titleEmojis; + if (displayEmojis.length === 0 && tags && tags.length > 0) { + const firstTag = availableTags.find((tag) => tag.name === tags[0]); + if (firstTag) { + const tagEmojis = firstTag.name.match(emojiRegex); + if (tagEmojis && tagEmojis.length > 0) { + displayEmojis = [tagEmojis[0]]; // Prendre seulement le premier emoji du tag + } + } + } + + const sourceStyles = getSourceStyles(); + const priorityColor = getPriorityColor(priority); + + // Vue compacte + if (variant === 'compact') { + return ( + +
+
+ {/* Emojis */} + {displayEmojis.length > 0 && ( +
+ {displayEmojis.slice(0, 1).map((emoji, index) => ( + + {emoji} + + ))} +
+ )} + + {/* Titre ou input d'édition */} + {isEditingTitle ? ( + setEditTitle(e.target.value)} + onKeyDown={handleTitleKeyPress} + onBlur={handleTitleSave} + autoFocus + className={`flex-1 bg-transparent border-none outline-none text-[var(--foreground)] font-mono ${fontClasses.title} font-medium leading-tight`} + /> + ) : ( +

+ {titleWithoutEmojis} +

+ )} + + {/* Actions */} +
+ {!isEditingTitle && onEdit && ( + + )} + + {!isEditingTitle && onDelete && ( + + )} + + {/* Indicateur de priorité */} +
+
+
- + + ); + } + + // Vue détaillée + return ( + +
+ {/* Header */} +
+ {/* Emojis */} + {displayEmojis.length > 0 && ( +
+ {displayEmojis.slice(0, 2).map((emoji, index) => ( + + {emoji} + + ))} +
+ )} + + {/* Titre ou input d'édition */} + {isEditingTitle ? ( + setEditTitle(e.target.value)} + onKeyDown={handleTitleKeyPress} + onBlur={handleTitleSave} + autoFocus + className="flex-1 bg-transparent border-none outline-none text-[var(--foreground)] font-mono text-sm font-medium leading-tight" + /> + ) : ( +

+ {titleWithoutEmojis} +

+ )} + + {/* Actions */} +
+ {!isEditingTitle && onEdit && ( + + )} + + {!isEditingTitle && onDelete && ( + + )} + + {/* Indicateur de priorité */} +
+
+
+ + {/* Description */} {description && ( -

+

{description}

)} - -
- {status && ( - - {status} - - )} - - {priority && ( - - {priority} - - )} - - {tags && tags.length > 0 && ( -
- {tags} + + {/* Tags avec couleurs personnalisées */} + {tags.length > 0 && ( +
+
+ {tags.slice(0, 3).map((tag, index) => { + const tagConfig = availableTags.find(t => t.name === tag); + return ( + + {tag} + + ); + })} + {tags.length > 3 && ( + + +{tags.length - 3} + + )}
- )} -
-
- -
- {metadata && ( -
- {metadata}
)} - {actions && ( -
- {actions} + + {/* Footer avec métadonnées */} + {(dueDate || (source && source !== 'manual') || completedAt) && ( +
+
+ {/* Date d'échéance */} + {dueDate ? ( + + + {dueDate.toLocaleDateString()} + + ) : ( +
+ )} + + {/* Badges source */} +
+ {/* Jira */} + {source === 'jira' && jiraKey && ( + jiraConfig?.baseUrl ? ( + + + {jiraKey} + + + ) : ( + + {jiraKey} + + ) + )} + + {/* TFS */} + {source === 'tfs' && tfsPullRequestId && tfsProject && tfsRepository && ( + tfsConfig?.organizationUrl ? ( + + + PR-{tfsPullRequestId} + + + ) : ( + + PR-{tfsPullRequestId} + + ) + )} + + {/* Projets */} + {jiraProject && ( + + {jiraProject} + + )} + + {tfsRepository && ( + + {tfsRepository} + + )} + + {/* Type Jira */} + {jiraType && ( + + {jiraType} + + )} + + {/* Statut terminé */} + {completedAt && ( + + ✓ DONE + + )} +
+
)} + + {/* Contenu personnalisé */} + {children}
-
-
- ); -} + + ); + } +); + +TaskCard.displayName = 'TaskCard'; + +export { TaskCard }; \ No newline at end of file