From 05cd099cf4896befd704227c6df23d3b7faac2fc Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Mon, 15 Sep 2025 10:17:36 +0200 Subject: [PATCH] feat: add swimlane mode selection to KanbanFilters and BoardContainer - Introduced `swimlanesMode` in `KanbanFilters` to toggle between 'tags' and 'priority' swimlanes. - Updated `KanbanBoardContainer` to conditionally render `PrioritySwimlanesBoard` based on the selected mode. - Enhanced UI to include dropdown for swimlane mode selection, improving user experience in task organization. - Adjusted `TasksContext` to persist swimlane mode preferences, ensuring consistent behavior across sessions. --- components/kanban/BoardContainer.tsx | 28 +- components/kanban/KanbanFilters.tsx | 126 ++++++-- components/kanban/PrioritySwimlanesBoard.tsx | 65 ++++ components/kanban/SwimlanesBase.tsx | 264 +++++++++++++++++ components/kanban/SwimlanesBoard.tsx | 295 +++---------------- services/user-preferences.ts | 1 + src/contexts/TasksContext.tsx | 4 +- 7 files changed, 495 insertions(+), 288 deletions(-) create mode 100644 components/kanban/PrioritySwimlanesBoard.tsx create mode 100644 components/kanban/SwimlanesBase.tsx diff --git a/components/kanban/BoardContainer.tsx b/components/kanban/BoardContainer.tsx index ebd9938..3ab5b69 100644 --- a/components/kanban/BoardContainer.tsx +++ b/components/kanban/BoardContainer.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { KanbanBoard } from './Board'; import { SwimlanesBoard } from './SwimlanesBoard'; +import { PrioritySwimlanesBoard } from './PrioritySwimlanesBoard'; import { ObjectivesBoard } from './ObjectivesBoard'; import { KanbanFilters } from './KanbanFilters'; import { EditTaskForm } from '@/components/forms/EditTaskForm'; @@ -73,14 +74,25 @@ export function KanbanBoardContainer() { )} {kanbanFilters.swimlanesByTags ? ( - + kanbanFilters.swimlanesMode === 'priority' ? ( + + ) : ( + + ) ) : ( (null); + const swimlaneModeDropdownRef = useRef(null); const sortButtonRef = useRef(null); const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }); - // Fermer le dropdown de tri en cliquant à l'extérieur + // Fermer les dropdowns en cliquant à l'extérieur useEffect(() => { function handleClickOutside(event: MouseEvent) { if (sortDropdownRef.current && !sortDropdownRef.current.contains(event.target as Node)) { setIsSortExpanded(false); } + if (swimlaneModeDropdownRef.current && !swimlaneModeDropdownRef.current.contains(event.target as Node)) { + setIsSwimlaneModeExpanded(false); + } } - if (isSortExpanded) { + if (isSortExpanded || isSwimlaneModeExpanded) { document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); } - }, [isSortExpanded]); + }, [isSortExpanded, isSwimlaneModeExpanded]); const handleSearchChange = (search: string) => { onFiltersChange({ ...filters, search: search || undefined }); @@ -89,6 +95,25 @@ export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps) }); }; + const handleSwimlaneModeChange = (mode: 'tags' | 'priority') => { + onFiltersChange({ + ...filters, + swimlanesByTags: true, + swimlanesMode: mode + }); + setIsSwimlaneModeExpanded(false); + }; + + const handleSwimlaneModeToggle = (event: React.MouseEvent) => { + const button = event.currentTarget; + const rect = button.getBoundingClientRect(); + setDropdownPosition({ + top: rect.bottom + window.scrollY + 4, + left: rect.left + window.scrollX + }); + setIsSwimlaneModeExpanded(!isSwimlaneModeExpanded); + }; + const handleSortChange = (sortKey: string) => { onFiltersChange({ ...filters, @@ -162,27 +187,52 @@ export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps) /> - {/* Bouton swimlanes par tags */} - + + {filters.swimlanesByTags ? ( + + ) : ( + + )} + + {!filters.swimlanesByTags + ? 'Normal' + : filters.swimlanesMode === 'priority' + ? 'Par priorité' + : 'Par tags' + } + + + {/* Bouton pour changer le mode des swimlanes */} + {filters.swimlanesByTags && ( + + )} + {/* Bouton vue compacte */} + + , + document.body + )} ); } diff --git a/components/kanban/PrioritySwimlanesBoard.tsx b/components/kanban/PrioritySwimlanesBoard.tsx new file mode 100644 index 0000000..55b624e --- /dev/null +++ b/components/kanban/PrioritySwimlanesBoard.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { Task, TaskStatus } from '@/lib/types'; +import { useMemo } from 'react'; +import { getAllPriorities } from '@/lib/status-config'; +import { SwimlanesBase, SwimlaneData } from './SwimlanesBase'; + +interface PrioritySwimlanesoardProps { + tasks: Task[]; + onDeleteTask?: (taskId: string) => Promise; + onEditTask?: (task: Task) => void; + onUpdateTitle?: (taskId: string, newTitle: string) => Promise; + onUpdateStatus?: (taskId: string, newStatus: TaskStatus) => Promise; + compactView?: boolean; +} + +export function PrioritySwimlanesBoard({ + tasks, + onDeleteTask, + onEditTask, + onUpdateTitle, + onUpdateStatus, + compactView = false +}: PrioritySwimlanesoardProps) { + + // Grouper les tâches par priorités et créer les données de swimlanes + const swimlanesData = useMemo((): SwimlaneData[] => { + const grouped: { [priorityKey: string]: Task[] } = {}; + + // Initialiser avec toutes les priorités + getAllPriorities().forEach(priority => { + grouped[priority.key] = []; + }); + + tasks.forEach(task => { + if (grouped[task.priority]) { + grouped[task.priority].push(task); + } + }); + + // Convertir en format SwimlaneData en respectant l'ordre de priorité + return getAllPriorities() + .sort((a, b) => b.order - a.order) // Ordre décroissant - plus importantes en haut + .map(priority => ({ + key: priority.key, + label: priority.label, + icon: priority.icon, + color: priority.color, + tasks: grouped[priority.key] || [] + })); + }, [tasks]); + + return ( + + ); +} \ No newline at end of file diff --git a/components/kanban/SwimlanesBase.tsx b/components/kanban/SwimlanesBase.tsx new file mode 100644 index 0000000..dd5ebe3 --- /dev/null +++ b/components/kanban/SwimlanesBase.tsx @@ -0,0 +1,264 @@ +'use client'; + +import { Task, TaskStatus } from '@/lib/types'; +import { TaskCard } from './TaskCard'; +import { useState } from 'react'; +import { useColumnVisibility } from '@/hooks/useColumnVisibility'; +import { ColumnVisibilityToggle } from './ColumnVisibilityToggle'; +import { getAllStatuses } from '@/lib/status-config'; +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + closestCenter, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + SortableContext, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { useDroppable } from '@dnd-kit/core'; + +// Composant pour les colonnes droppables +function DroppableColumn({ + status, + tasks, + onDeleteTask, + onEditTask, + onUpdateTitle, + compactView +}: { + status: TaskStatus; + tasks: Task[]; + onDeleteTask?: (taskId: string) => Promise; + onEditTask?: (task: Task) => void; + onUpdateTitle?: (taskId: string, newTitle: string) => Promise; + compactView: boolean; +}) { + const { setNodeRef } = useDroppable({ + id: status, + }); + + return ( +
+ t.id)} strategy={verticalListSortingStrategy}> +
+ {tasks.map(task => ( + + ))} +
+
+
+ ); +} + +// Interface pour une swimlane +export interface SwimlaneData { + key: string; + label: string; + icon?: string; + color?: string; + tasks: Task[]; +} + +interface SwimlanesBaseProps { + tasks: Task[]; + title: string; + swimlanes: SwimlaneData[]; + onDeleteTask?: (taskId: string) => Promise; + onEditTask?: (task: Task) => void; + onUpdateTitle?: (taskId: string, newTitle: string) => Promise; + onUpdateStatus?: (taskId: string, newStatus: TaskStatus) => Promise; + compactView?: boolean; +} + +export function SwimlanesBase({ + tasks, + title, + swimlanes, + onDeleteTask, + onEditTask, + onUpdateTitle, + onUpdateStatus, + compactView = false +}: SwimlanesBaseProps) { + const [activeTask, setActiveTask] = useState(null); + const [collapsedSwimlanes, setCollapsedSwimlanes] = useState>(new Set()); + + // Gestion de la visibilité des colonnes + const { hiddenStatuses, toggleStatusVisibility, getVisibleStatuses } = useColumnVisibility(); + const allStatuses = getAllStatuses(); + const visibleStatuses = getVisibleStatuses(allStatuses.map(s => ({ id: s.key }))) + .map(s => s.id); + + // Configuration des sensors pour le drag & drop + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + + // Handlers pour le drag & drop + const handleDragStart = (event: DragStartEvent) => { + const task = tasks.find(t => t.id === event.active.id); + setActiveTask(task || null); + }; + + const handleDragEnd = async (event: DragEndEvent) => { + const { active, over } = event; + setActiveTask(null); + + if (!over || !onUpdateStatus) return; + + const taskId = active.id as string; + const newStatus = over.id as TaskStatus; + + // Vérifier si le statut a changé + const task = tasks.find(t => t.id === taskId); + if (task && task.status !== newStatus) { + await onUpdateStatus(taskId, newStatus); + } + }; + + // Basculer l'état d'une swimlane + const toggleSwimlane = (swimlaneKey: string) => { + const newCollapsed = new Set(collapsedSwimlanes); + if (newCollapsed.has(swimlaneKey)) { + newCollapsed.delete(swimlaneKey); + } else { + newCollapsed.add(swimlaneKey); + } + setCollapsedSwimlanes(newCollapsed); + }; + + return ( + +
+ {/* Header */} +
+

+ {title} +

+
+ + {/* Headers des colonnes avec boutons toggle */} +
+ +
+ + {/* Headers des colonnes visibles */} +
+ {visibleStatuses.map(status => { + const statusConfig = allStatuses.find(s => s.key === status); + return ( +
+

+ {statusConfig?.icon} {statusConfig?.label} +

+
+ ); + })} +
+ + {/* Swimlanes */} +
+
+ {swimlanes.map(swimlane => { + const isCollapsed = collapsedSwimlanes.has(swimlane.key); + + return ( +
+ {/* Header de la swimlane */} +
+ +
+ + {/* Contenu de la swimlane */} + {!isCollapsed && ( +
+ {visibleStatuses.map(status => { + const statusTasks = swimlane.tasks.filter(task => task.status === status); + + return ( + + ); + })} +
+ )} +
+ ); + })} +
+
+
+ + {/* Drag overlay */} + + {activeTask && ( + + )} + +
+ ); +} + diff --git a/components/kanban/SwimlanesBoard.tsx b/components/kanban/SwimlanesBoard.tsx index 2bc61ba..787ca91 100644 --- a/components/kanban/SwimlanesBoard.tsx +++ b/components/kanban/SwimlanesBoard.tsx @@ -1,73 +1,9 @@ 'use client'; import { Task, TaskStatus } from '@/lib/types'; -import { TaskCard } from './TaskCard'; -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { useTasksContext } from '@/contexts/TasksContext'; -import { useColumnVisibility } from '@/hooks/useColumnVisibility'; -import { ColumnVisibilityToggle } from './ColumnVisibilityToggle'; -import { getAllStatuses } from '@/lib/status-config'; -import { - DndContext, - DragEndEvent, - DragOverlay, - DragStartEvent, - closestCenter, - PointerSensor, - useSensor, - useSensors, -} from '@dnd-kit/core'; -import { - SortableContext, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; -import { useDroppable } from '@dnd-kit/core'; -// import { SortableTaskCard } from './SortableTaskCard'; - -// Composant pour les colonnes droppables -function DroppableColumn({ - status, - tasks, - onDeleteTask, - onEditTask, - onUpdateTitle, - compactView -}: { - status: TaskStatus; - tasks: Task[]; - onDeleteTask?: (taskId: string) => Promise; - onEditTask?: (task: Task) => void; - onUpdateTitle?: (taskId: string, newTitle: string) => Promise; - compactView: boolean; -}) { - const { setNodeRef, isOver } = useDroppable({ - id: status, - }); - - return ( -
- t.id)} strategy={verticalListSortingStrategy}> -
- {tasks.map(task => ( - - ))} -
-
-
- ); -} +import { SwimlanesBase, SwimlaneData } from './SwimlanesBase'; interface SwimlanesboardProps { tasks: Task[]; @@ -87,69 +23,9 @@ export function SwimlanesBoard({ compactView = false }: SwimlanesboardProps) { const { tags: availableTags } = useTasksContext(); - const [activeTask, setActiveTask] = useState(null); - const [collapsedSwimlanes, setCollapsedSwimlanes] = useState>(new Set()); - - // Gestion de la visibilité des colonnes - const { hiddenStatuses, toggleStatusVisibility, getVisibleStatuses } = useColumnVisibility(); - // Configuration des capteurs pour le drag & drop - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }) - ); - - // Configuration des statuts basée sur la config centralisée - const statuses = useMemo(() => { - return getAllStatuses().map(statusConfig => ({ - id: statusConfig.key, - title: statusConfig.label, - color: statusConfig.color - })); - }, []); - - // Handlers pour le drag & drop - const handleDragStart = (event: DragStartEvent) => { - const task = tasks.find(t => t.id === event.active.id); - setActiveTask(task || null); - }; - - const handleDragEnd = async (event: DragEndEvent) => { - const { active, over } = event; - setActiveTask(null); - - if (!over || !onUpdateStatus) return; - - const taskId = active.id as string; - const newStatus = over.id as TaskStatus; - - // Vérifier si le statut a changé - const task = tasks.find(t => t.id === taskId); - if (task && task.status !== newStatus) { - await onUpdateStatus(taskId, newStatus); - } - }; - - const toggleSwimlane = (tagName: string) => { - setCollapsedSwimlanes(prev => { - const newSet = new Set(prev); - if (newSet.has(tagName)) { - newSet.delete(tagName); - } else { - newSet.add(tagName); - } - return newSet; - }); - }; - - // Filtrer les statuts visibles - const visibleStatuses = getVisibleStatuses(statuses); - - // Grouper les tâches par tags - const tasksByTag = useMemo(() => { + // Grouper les tâches par tags et créer les données de swimlanes + const swimlanesData = useMemo((): SwimlaneData[] => { const grouped: { [tagName: string]: Task[] } = {}; // Ajouter une catégorie pour les tâches sans tags @@ -168,135 +44,42 @@ export function SwimlanesBoard({ } }); - return grouped; - }, [tasks]); + // Convertir en format SwimlaneData et trier + return Object.entries(grouped) + .sort(([a, tasksA], [b, tasksB]) => { + // Mettre "Sans tag" à la fin + if (a === 'Sans tag') return 1; + if (b === 'Sans tag') return -1; + // Trier par nombre de tâches (décroissant) + return tasksB.length - tasksA.length; + }) + .map(([tagName, tagTasks]) => { + // Obtenir la couleur du tag + const getTagColor = (name: string) => { + if (name === 'Sans tag') return '#64748b'; // slate-500 + const tag = availableTags.find(t => t.name === name); + return tag?.color || '#64748b'; + }; - // Obtenir la couleur d'un tag - const getTagColor = (tagName: string) => { - if (tagName === 'Sans tag') return '#64748b'; // slate-500 - const tag = availableTags.find(t => t.name === tagName); - return tag?.color || '#64748b'; - }; + return { + key: tagName, + label: tagName, + color: getTagColor(tagName), + tasks: tagTasks + }; + }); + }, [tasks, availableTags]); return ( - -
- {/* Header */} -
-
-

- Kanban Swimlanes -

-
- - {/* Headers des colonnes avec boutons toggle */} -
- -
- - {/* Headers des colonnes visibles */} -
- {visibleStatuses.map(status => ( -
-
- {status.title} -
-
- ))} -
- - {/* Swimlanes */} -
-
- {Object.entries(tasksByTag) - .sort(([a, tasksA], [b, tasksB]) => { - // Mettre "Sans tag" à la fin - if (a === 'Sans tag') return 1; - if (b === 'Sans tag') return -1; - // Trier par nombre de tâches (décroissant) - return tasksB.length - tasksA.length; - }) - .map(([tagName, tagTasks]) => ( -
- {/* Header de la swimlane */} -
- -
- - {/* Contenu de la swimlane */} - {!collapsedSwimlanes.has(tagName) && ( -
- {visibleStatuses.map(status => { - const statusTasks = tagTasks.filter(task => task.status === status.id); - return ( - - ); - })} -
- )} -
- ))} -
-
-
- - {/* DragOverlay pour l'aperçu pendant le drag */} - - {activeTask && ( - - )} - -
+ ); -} +} \ No newline at end of file diff --git a/services/user-preferences.ts b/services/user-preferences.ts index 0e83435..2173187 100644 --- a/services/user-preferences.ts +++ b/services/user-preferences.ts @@ -12,6 +12,7 @@ export interface KanbanFilters { export interface ViewPreferences { compactView: boolean; swimlanesByTags: boolean; + swimlanesMode?: 'tags' | 'priority'; showObjectives: boolean; } diff --git a/src/contexts/TasksContext.tsx b/src/contexts/TasksContext.tsx index 40e7413..016773e 100644 --- a/src/contexts/TasksContext.tsx +++ b/src/contexts/TasksContext.tsx @@ -73,7 +73,8 @@ export function TasksProvider({ children, initialTasks, initialStats, initialTag showCompleted: savedFilters.showCompleted, sortBy: savedFilters.sortBy || createSortKey('priority', 'desc'), // Tri par défaut compactView: savedViewPrefs.compactView, - swimlanesByTags: savedViewPrefs.swimlanesByTags + swimlanesByTags: savedViewPrefs.swimlanesByTags, + swimlanesMode: savedViewPrefs.swimlanesMode || 'tags' }); }, []); @@ -94,6 +95,7 @@ export function TasksProvider({ children, initialTasks, initialStats, initialTag userPreferencesService.saveViewPreferences({ compactView: newFilters.compactView || false, swimlanesByTags: newFilters.swimlanesByTags || false, + swimlanesMode: newFilters.swimlanesMode || 'tags', showObjectives: true // Toujours visible pour l'instant }); };