diff --git a/TODO.md b/TODO.md index 793d551..4207b6e 100644 --- a/TODO.md +++ b/TODO.md @@ -43,7 +43,7 @@ - [x] Formulaire d'édition de tâche (Modal + Form avec pré-remplissage) - [x] Édition inline du titre des tâches (clic sur titre → input) - [x] Suppression de tâche (icône discrète + API call) -- [ ] Changement de statut par drag & drop ou boutons +- [x] Changement de statut par drag & drop (@dnd-kit) - [x] Validation des formulaires et gestion d'erreurs ### 2.4 Gestion des tags @@ -64,7 +64,8 @@ - [x] Architecture SSR + hydratation client optimisée ### 2.6 Fonctionnalités Kanban avancées -- [ ] Drag & drop entre colonnes (react-beautiful-dnd) +- [x] Drag & drop entre colonnes (@dnd-kit avec React 19) +- [x] Drag & drop optimiste (mise à jour immédiate + rollback si erreur) - [ ] Filtrage par statut/priorité/assigné - [ ] Recherche en temps réel dans les tâches - [ ] Tri des tâches (date, priorité, alphabétique) @@ -159,6 +160,7 @@ lib/ - ✅ CRUD tâches complet (création, édition, suppression) - ✅ Création rapide inline (QuickAddTask) - ✅ Édition inline du titre (clic sur titre → input éditable) +- ✅ Drag & drop entre colonnes (@dnd-kit) + optimiste - ✅ Client HTTP et hooks React - ✅ Refactoring Kanban avec nouveaux composants diff --git a/components/kanban/Board.tsx b/components/kanban/Board.tsx index db3948b..e11ec9b 100644 --- a/components/kanban/Board.tsx +++ b/components/kanban/Board.tsx @@ -6,6 +6,16 @@ import { Button } from '@/components/ui/Button'; import { CreateTaskForm } from '@/components/forms/CreateTaskForm'; import { CreateTaskData } from '@/clients/tasks-client'; import { useMemo, useState } from 'react'; +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { TaskCard } from './TaskCard'; interface KanbanBoardProps { tasks: Task[]; @@ -13,11 +23,22 @@ interface KanbanBoardProps { onDeleteTask?: (taskId: string) => Promise; onEditTask?: (task: Task) => void; onUpdateTitle?: (taskId: string, newTitle: string) => Promise; + onUpdateStatus?: (taskId: string, newStatus: TaskStatus) => Promise; loading?: boolean; } -export function KanbanBoard({ tasks, onCreateTask, onDeleteTask, onEditTask, onUpdateTitle, loading = false }: KanbanBoardProps) { +export function KanbanBoard({ tasks, onCreateTask, onDeleteTask, onEditTask, onUpdateTitle, onUpdateStatus, loading = false }: KanbanBoardProps) { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [activeTask, setActiveTask] = useState(null); + + // Configuration des capteurs pour le drag & drop + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, // Évite les clics accidentels + }, + }) + ); // Organiser les tâches par statut const tasksByStatus = useMemo(() => { const grouped = tasks.reduce((acc, task) => { @@ -70,54 +91,99 @@ export function KanbanBoard({ tasks, onCreateTask, onDeleteTask, onEditTask, onU } }; + // Gestion du début du drag + const handleDragStart = (event: DragStartEvent) => { + const { active } = event; + const task = tasks.find(t => t.id === active.id); + setActiveTask(task || null); + }; + + // Gestion de la fin du drag + 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; + + // Trouver la tâche actuelle + const task = tasks.find(t => t.id === taskId); + if (!task || task.status === newStatus) return; + + // Mettre à jour le statut + await onUpdateStatus(taskId, newStatus); + }; + return ( -
- {/* Header avec bouton nouvelle tâche */} -
-
-
-

- Kanban Board -

+ +
+ {/* Header avec bouton nouvelle tâche */} +
+
+
+

+ Kanban Board +

+
+ + {onCreateTask && ( + + )}
- + + {/* Board tech dark */} +
+ {columns.map((column) => ( + + ))} +
+ + {/* Modal de création */} {onCreateTask && ( - + setIsCreateModalOpen(false)} + onSubmit={handleCreateTask} + loading={loading} + /> )}
- {/* Board tech dark */} -
- {columns.map((column) => ( - - ))} -
- - {/* Modal de création */} - {onCreateTask && ( - setIsCreateModalOpen(false)} - onSubmit={handleCreateTask} - loading={loading} - /> - )} -
+ {/* Overlay pour le drag & drop */} + + {activeTask ? ( +
+ +
+ ) : null} +
+ ); } diff --git a/components/kanban/BoardContainer.tsx b/components/kanban/BoardContainer.tsx index 475f3ce..1aa9252 100644 --- a/components/kanban/BoardContainer.tsx +++ b/components/kanban/BoardContainer.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import { KanbanBoard } from './Board'; import { EditTaskForm } from '@/components/forms/EditTaskForm'; import { useTasks } from '@/hooks/useTasks'; -import { Task } from '@/lib/types'; +import { Task, TaskStatus } from '@/lib/types'; import { UpdateTaskData } from '@/clients/tasks-client'; interface BoardContainerProps { @@ -19,7 +19,7 @@ interface BoardContainerProps { } export function KanbanBoardContainer({ initialTasks, initialStats }: BoardContainerProps) { - const { tasks, loading, createTask, deleteTask, updateTask } = useTasks( + const { tasks, loading, syncing, createTask, deleteTask, updateTask, updateTaskOptimistic } = useTasks( { limit: 20 }, { tasks: initialTasks, stats: initialStats } ); @@ -42,6 +42,14 @@ export function KanbanBoardContainer({ initialTasks, initialStats }: BoardContai }); }; + const handleUpdateStatus = async (taskId: string, newStatus: TaskStatus) => { + // Utiliser la mise à jour optimiste pour le drag & drop + await updateTaskOptimistic({ + taskId, + status: newStatus + }); + }; + return ( <> diff --git a/components/kanban/Column.tsx b/components/kanban/Column.tsx index dd74129..2d5b0c7 100644 --- a/components/kanban/Column.tsx +++ b/components/kanban/Column.tsx @@ -5,6 +5,7 @@ import { Card, CardHeader, CardContent } from '@/components/ui/Card'; import { Badge } from '@/components/ui/Badge'; import { CreateTaskData } from '@/clients/tasks-client'; import { useState } from 'react'; +import { useDroppable } from '@dnd-kit/core'; interface KanbanColumnProps { id: TaskStatus; @@ -19,6 +20,11 @@ interface KanbanColumnProps { export function KanbanColumn({ id, title, color, tasks, onCreateTask, onDeleteTask, onEditTask, onUpdateTitle }: KanbanColumnProps) { const [showQuickAdd, setShowQuickAdd] = useState(false); + + // Configuration de la zone droppable + const { setNodeRef, isOver } = useDroppable({ + id: id, + }); // Couleurs tech/cyberpunk const techStyles = { gray: { @@ -61,7 +67,13 @@ export function KanbanColumn({ id, title, color, tasks, onCreateTask, onDeleteTa return (
- +
diff --git a/components/kanban/TaskCard.tsx b/components/kanban/TaskCard.tsx index 192c66b..e0e09f4 100644 --- a/components/kanban/TaskCard.tsx +++ b/components/kanban/TaskCard.tsx @@ -4,6 +4,7 @@ import { formatDistanceToNow } from 'date-fns'; import { fr } from 'date-fns/locale'; import { Card } from '@/components/ui/Card'; import { Badge } from '@/components/ui/Badge'; +import { useDraggable } from '@dnd-kit/core'; interface TaskCardProps { task: Task; @@ -16,6 +17,17 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle }: TaskCardProp const [isEditingTitle, setIsEditingTitle] = useState(false); const [editTitle, setEditTitle] = useState(task.title); + // 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); @@ -39,7 +51,7 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle }: TaskCardProp const handleTitleClick = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - if (onUpdateTitle) { + if (onUpdateTitle && !isDragging) { setIsEditingTitle(true); } }; @@ -66,6 +78,11 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle }: TaskCardProp handleTitleCancel(); } }; + // Style de transformation pour le drag + const style = transform ? { + transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, + } : 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{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu; const emojis = task.title.match(emojiRegex) || []; @@ -73,7 +90,15 @@ export function TaskCard({ task, onDelete, onEdit, onUpdateTitle }: TaskCardProp return ( - + {/* Header tech avec titre et status */}
{emojis.length > 0 && ( diff --git a/components/ui/Header.tsx b/components/ui/Header.tsx index 3762f3c..f1a198d 100644 --- a/components/ui/Header.tsx +++ b/components/ui/Header.tsx @@ -10,22 +10,27 @@ interface HeaderProps { todo: number; completionRate: number; }; + syncing?: boolean; } -export function Header({ title, subtitle, stats }: HeaderProps) { +export function Header({ title, subtitle, stats, syncing = false }: HeaderProps) { return (
{/* Titre tech avec glow */}
-
+

{title}

- {subtitle} + {subtitle} {syncing && '• Synchronisation...'}

diff --git a/components/ui/HeaderContainer.tsx b/components/ui/HeaderContainer.tsx index 602cde0..bafd8d8 100644 --- a/components/ui/HeaderContainer.tsx +++ b/components/ui/HeaderContainer.tsx @@ -1,8 +1,7 @@ 'use client'; -import { useState, useEffect } from 'react'; import { Header } from './Header'; -import { tasksClient } from '@/clients/tasks-client'; +import { useTasks } from '@/hooks/useTasks'; interface HeaderContainerProps { title: string; @@ -17,41 +16,17 @@ interface HeaderContainerProps { } export function HeaderContainer({ title, subtitle, initialStats }: HeaderContainerProps) { - const [stats, setStats] = useState(initialStats); - const [isHydrated, setIsHydrated] = useState(false); - - // Hydratation côté client - useEffect(() => { - setIsHydrated(true); - }, []); - - // Rafraîchir les stats périodiquement côté client - useEffect(() => { - if (!isHydrated) return; - - const refreshStats = async () => { - try { - const response = await tasksClient.getTasks({ limit: 1 }); // Juste pour les stats - setStats(response.stats); - } catch (error) { - console.error('Erreur lors du rafraîchissement des stats:', error); - } - }; - - // Rafraîchir les stats toutes les 30 secondes - const interval = setInterval(refreshStats, 30000); - - // Rafraîchir immédiatement après hydratation - refreshStats(); - - return () => clearInterval(interval); - }, [isHydrated]); + const { stats, syncing } = useTasks( + { limit: 1 }, // Juste pour les stats + { tasks: [], stats: initialStats } + ); return (
); } diff --git a/hooks/useTasks.ts b/hooks/useTasks.ts index 542b16b..cefc23c 100644 --- a/hooks/useTasks.ts +++ b/hooks/useTasks.ts @@ -15,12 +15,14 @@ interface UseTasksState { }; loading: boolean; error: string | null; + syncing: boolean; // Pour indiquer les opérations optimistes en cours } interface UseTasksActions { refreshTasks: () => Promise; createTask: (data: CreateTaskData) => Promise; updateTask: (data: UpdateTaskData) => Promise; + updateTaskOptimistic: (data: UpdateTaskData) => Promise; deleteTask: (taskId: string) => Promise; setFilters: (filters: TaskFilters) => void; } @@ -42,7 +44,8 @@ export function useTasks( completionRate: 0 }, loading: false, - error: null + error: null, + syncing: false }); const [filters, setFilters] = useState(initialFilters || {}); @@ -117,6 +120,87 @@ export function useTasks( } }, [refreshTasks]); + /** + * Met à jour une tâche de manière optimiste (pour drag & drop) + */ + const updateTaskOptimistic = useCallback(async (data: UpdateTaskData): Promise => { + const { taskId, ...updates } = data; + + // 1. Sauvegarder l'état actuel pour rollback + const currentTasks = state.tasks; + const taskToUpdate = currentTasks.find(t => t.id === taskId); + + if (!taskToUpdate) { + console.error('Tâche non trouvée pour mise à jour optimiste:', taskId); + return null; + } + + // 2. Mise à jour optimiste immédiate de l'état local + const updatedTask = { ...taskToUpdate, ...updates }; + const updatedTasks = currentTasks.map(task => + task.id === taskId ? updatedTask : task + ); + + // Recalculer les stats + const newStats = { + total: updatedTasks.length, + completed: updatedTasks.filter(t => t.status === 'done').length, + inProgress: updatedTasks.filter(t => t.status === 'in_progress').length, + todo: updatedTasks.filter(t => t.status === 'todo').length, + completionRate: updatedTasks.length > 0 + ? Math.round((updatedTasks.filter(t => t.status === 'done').length / updatedTasks.length) * 100) + : 0 + }; + + setState(prev => ({ + ...prev, + tasks: updatedTasks, + stats: newStats, + error: null, + syncing: true // Indiquer qu'une synchronisation est en cours + })); + + // 3. Appel API en arrière-plan + try { + const response = await tasksClient.updateTask(data); + + // Si l'API retourne des données différentes, on met à jour + if (response.data) { + setState(prev => ({ + ...prev, + tasks: prev.tasks.map(task => + task.id === taskId ? response.data : task + ), + syncing: false // Synchronisation terminée + })); + } else { + setState(prev => ({ ...prev, syncing: false })); + } + + return response.data; + } catch (error) { + // 4. Rollback en cas d'erreur + setState(prev => ({ + ...prev, + tasks: currentTasks, + stats: { + total: currentTasks.length, + completed: currentTasks.filter(t => t.status === 'done').length, + inProgress: currentTasks.filter(t => t.status === 'in_progress').length, + todo: currentTasks.filter(t => t.status === 'todo').length, + completionRate: currentTasks.length > 0 + ? Math.round((currentTasks.filter(t => t.status === 'done').length / currentTasks.length) * 100) + : 0 + }, + error: error instanceof Error ? error.message : 'Erreur lors de la mise à jour', + syncing: false // Arrêter l'indicateur de synchronisation + })); + + console.error('Erreur lors de la mise à jour optimiste:', error); + return null; + } + }, [state.tasks]); + /** * Supprime une tâche */ @@ -148,6 +232,7 @@ export function useTasks( refreshTasks, createTask, updateTask, + updateTaskOptimistic, deleteTask, setFilters }; diff --git a/package-lock.json b/package-lock.json index 98d9b1d..28f51c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "towercontrol-temp", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@prisma/client": "^6.16.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -46,6 +49,59 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", diff --git a/package.json b/package.json index fc3696e..86ee959 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "lint": "eslint" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@prisma/client": "^6.16.1", "clsx": "^2.1.1", "date-fns": "^4.1.0",