feat: enhance Kanban filtering and integrate filters in BoardContainer
- Marked multiple tasks as completed in TODO.md related to Kanban filtering features. - Added `KanbanFilters` component to `BoardContainer` for improved task filtering. - Updated `TasksContext` to manage Kanban filters and provide filtered tasks to the board. - Implemented real-time filtering logic based on search, tags, and priorities.
This commit is contained in:
13
TODO.md
13
TODO.md
@@ -60,7 +60,10 @@
|
|||||||
- [x] Contexte global pour partager les tags
|
- [x] Contexte global pour partager les tags
|
||||||
- [x] Page de gestion des tags (/tags) avec interface complète
|
- [x] Page de gestion des tags (/tags) avec interface complète
|
||||||
- [x] Navigation dans le Header (Kanban ↔ Tags)
|
- [x] Navigation dans le Header (Kanban ↔ Tags)
|
||||||
- [ ] Filtrage par tags (intégration dans Kanban)
|
- [x] Filtrage par tags (intégration dans Kanban)
|
||||||
|
- [x] Interface de filtrage complète (recherche, priorités, tags)
|
||||||
|
- [x] Logique de filtrage temps réel dans le contexte
|
||||||
|
- [x] Intégration des filtres dans KanbanBoard
|
||||||
|
|
||||||
### 2.5 Clients HTTP et hooks
|
### 2.5 Clients HTTP et hooks
|
||||||
- [x] `clients/tasks-client.ts` - Client pour les tâches (CRUD complet)
|
- [x] `clients/tasks-client.ts` - Client pour les tâches (CRUD complet)
|
||||||
@@ -68,15 +71,17 @@
|
|||||||
- [x] `clients/base/http-client.ts` - Client HTTP de base
|
- [x] `clients/base/http-client.ts` - Client HTTP de base
|
||||||
- [x] `hooks/useTasks.ts` - Hook pour la gestion des tâches (CRUD complet)
|
- [x] `hooks/useTasks.ts` - Hook pour la gestion des tâches (CRUD complet)
|
||||||
- [x] `hooks/useTags.ts` - Hook pour la gestion des tags
|
- [x] `hooks/useTags.ts` - Hook pour la gestion des tags
|
||||||
- [ ] `hooks/useKanban.ts` - Hook pour drag & drop
|
- [x] Drag & drop avec @dnd-kit (intégré directement dans Board.tsx)
|
||||||
- [x] Gestion des erreurs et loading states
|
- [x] Gestion des erreurs et loading states
|
||||||
- [x] Architecture SSR + hydratation client optimisée
|
- [x] Architecture SSR + hydratation client optimisée
|
||||||
|
|
||||||
### 2.6 Fonctionnalités Kanban avancées
|
### 2.6 Fonctionnalités Kanban avancées
|
||||||
- [x] Drag & drop entre colonnes (@dnd-kit avec React 19)
|
- [x] Drag & drop entre colonnes (@dnd-kit avec React 19)
|
||||||
- [x] Drag & drop optimiste (mise à jour immédiate + rollback si erreur)
|
- [x] Drag & drop optimiste (mise à jour immédiate + rollback si erreur)
|
||||||
- [ ] Filtrage par statut/priorité/assigné
|
- [x] Filtrage par statut/priorité/assigné
|
||||||
- [ ] Recherche en temps réel dans les tâches
|
- [x] Recherche en temps réel dans les tâches
|
||||||
|
- [x] Interface de filtrage complète (KanbanFilters.tsx)
|
||||||
|
- [x] Logique de filtrage dans TasksContext
|
||||||
- [ ] Tri des tâches (date, priorité, alphabétique)
|
- [ ] Tri des tâches (date, priorité, alphabétique)
|
||||||
- [ ] Actions en lot (sélection multiple)
|
- [ ] Actions en lot (sélection multiple)
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,23 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { KanbanBoard } from './Board';
|
import { KanbanBoard } from './Board';
|
||||||
|
import { KanbanFilters } from './KanbanFilters';
|
||||||
import { EditTaskForm } from '@/components/forms/EditTaskForm';
|
import { EditTaskForm } from '@/components/forms/EditTaskForm';
|
||||||
import { useTasksContext } from '@/contexts/TasksContext';
|
import { useTasksContext } from '@/contexts/TasksContext';
|
||||||
import { Task, TaskStatus } from '@/lib/types';
|
import { Task, TaskStatus } from '@/lib/types';
|
||||||
import { UpdateTaskData } from '@/clients/tasks-client';
|
import { UpdateTaskData } from '@/clients/tasks-client';
|
||||||
|
|
||||||
export function KanbanBoardContainer() {
|
export function KanbanBoardContainer() {
|
||||||
const { tasks, loading, createTask, deleteTask, updateTask, updateTaskOptimistic } = useTasksContext();
|
const {
|
||||||
|
filteredTasks,
|
||||||
|
loading,
|
||||||
|
createTask,
|
||||||
|
deleteTask,
|
||||||
|
updateTask,
|
||||||
|
updateTaskOptimistic,
|
||||||
|
kanbanFilters,
|
||||||
|
setKanbanFilters
|
||||||
|
} = useTasksContext();
|
||||||
|
|
||||||
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
||||||
|
|
||||||
@@ -38,8 +48,13 @@ export function KanbanBoardContainer() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<KanbanFilters
|
||||||
|
filters={kanbanFilters}
|
||||||
|
onFiltersChange={setKanbanFilters}
|
||||||
|
/>
|
||||||
|
|
||||||
<KanbanBoard
|
<KanbanBoard
|
||||||
tasks={tasks}
|
tasks={filteredTasks}
|
||||||
onCreateTask={createTask}
|
onCreateTask={createTask}
|
||||||
onDeleteTask={deleteTask}
|
onDeleteTask={deleteTask}
|
||||||
onEditTask={handleEditTask}
|
onEditTask={handleEditTask}
|
||||||
|
|||||||
198
components/kanban/KanbanFilters.tsx
Normal file
198
components/kanban/KanbanFilters.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { TaskPriority } from '@/lib/types';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { TagDisplay } from '@/components/ui/TagDisplay';
|
||||||
|
import { useTasksContext } from '@/contexts/TasksContext';
|
||||||
|
|
||||||
|
export interface KanbanFilters {
|
||||||
|
search?: string;
|
||||||
|
tags?: string[];
|
||||||
|
priorities?: TaskPriority[];
|
||||||
|
showCompleted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KanbanFiltersProps {
|
||||||
|
filters: KanbanFilters;
|
||||||
|
onFiltersChange: (filters: KanbanFilters) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps) {
|
||||||
|
const { tags: availableTags } = useTasksContext();
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
const handleSearchChange = (search: string) => {
|
||||||
|
onFiltersChange({ ...filters, search: search || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagToggle = (tagName: string) => {
|
||||||
|
const currentTags = filters.tags || [];
|
||||||
|
const newTags = currentTags.includes(tagName)
|
||||||
|
? currentTags.filter(t => t !== tagName)
|
||||||
|
: [...currentTags, tagName];
|
||||||
|
|
||||||
|
onFiltersChange({
|
||||||
|
...filters,
|
||||||
|
tags: newTags.length > 0 ? newTags : undefined
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePriorityToggle = (priority: TaskPriority) => {
|
||||||
|
const currentPriorities = filters.priorities || [];
|
||||||
|
const newPriorities = currentPriorities.includes(priority)
|
||||||
|
? currentPriorities.filter(p => p !== priority)
|
||||||
|
: [...currentPriorities, priority];
|
||||||
|
|
||||||
|
onFiltersChange({
|
||||||
|
...filters,
|
||||||
|
priorities: newPriorities.length > 0 ? newPriorities : undefined
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearFilters = () => {
|
||||||
|
onFiltersChange({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters = filters.search || filters.tags?.length || filters.priorities?.length;
|
||||||
|
|
||||||
|
const priorityOptions: { value: TaskPriority; label: string; color: string }[] = [
|
||||||
|
{ value: 'urgent', label: 'Urgent', color: 'bg-red-500' },
|
||||||
|
{ value: 'high', label: 'Haute', color: 'bg-orange-500' },
|
||||||
|
{ value: 'medium', label: 'Moyenne', color: 'bg-yellow-500' },
|
||||||
|
{ value: 'low', label: 'Basse', color: 'bg-blue-500' }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900/50 border-b border-slate-700/50 backdrop-blur-sm">
|
||||||
|
<div className="container mx-auto px-6 py-4">
|
||||||
|
{/* Header avec recherche et bouton expand */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex-1 max-w-md">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={filters.search || ''}
|
||||||
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
|
placeholder="Rechercher des tâches..."
|
||||||
|
className="bg-slate-800/50 border-slate-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
Filtres
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<span className="bg-cyan-500 text-slate-900 text-xs px-2 py-0.5 rounded-full font-medium">
|
||||||
|
{(filters.tags?.length || 0) + (filters.priorities?.length || 0) + (filters.search ? 1 : 0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleClearFilters}
|
||||||
|
className="text-slate-400 hover:text-red-400"
|
||||||
|
>
|
||||||
|
Effacer
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtres étendus */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mt-4 space-y-4 border-t border-slate-700/50 pt-4">
|
||||||
|
{/* Filtres par priorité */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-mono font-medium text-slate-300 uppercase tracking-wider">
|
||||||
|
Priorités
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{priorityOptions.map((priority) => (
|
||||||
|
<button
|
||||||
|
key={priority.value}
|
||||||
|
onClick={() => handlePriorityToggle(priority.value)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-all text-sm font-medium ${
|
||||||
|
filters.priorities?.includes(priority.value)
|
||||||
|
? 'border-cyan-400 bg-cyan-400/10 text-cyan-400'
|
||||||
|
: 'border-slate-600 bg-slate-800/50 text-slate-400 hover:border-slate-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`w-2 h-2 rounded-full ${priority.color}`} />
|
||||||
|
{priority.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtres par tags */}
|
||||||
|
{availableTags.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-mono font-medium text-slate-300 uppercase tracking-wider">
|
||||||
|
Tags
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{availableTags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() => handleTagToggle(tag.name)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-all text-sm font-medium ${
|
||||||
|
filters.tags?.includes(tag.name)
|
||||||
|
? 'border-cyan-400 bg-cyan-400/10 text-cyan-400'
|
||||||
|
: 'border-slate-600 bg-slate-800/50 text-slate-400 hover:border-slate-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: tag.color }}
|
||||||
|
/>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Résumé des filtres actifs */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="bg-slate-800/30 rounded-lg p-3 border border-slate-700/50">
|
||||||
|
<div className="text-xs text-slate-400 font-mono uppercase tracking-wider mb-2">
|
||||||
|
Filtres actifs
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
{filters.search && (
|
||||||
|
<div className="text-slate-300">
|
||||||
|
Recherche: <span className="text-cyan-400">"{filters.search}"</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filters.priorities?.length && (
|
||||||
|
<div className="text-slate-300">
|
||||||
|
Priorités: <span className="text-cyan-400">{filters.priorities.join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filters.tags?.length && (
|
||||||
|
<div className="text-slate-300">
|
||||||
|
Tags: <span className="text-cyan-400">{filters.tags.join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createContext, useContext, ReactNode } from 'react';
|
import { createContext, useContext, ReactNode, useState, useMemo } from 'react';
|
||||||
import { useTasks } from '@/hooks/useTasks';
|
import { useTasks } from '@/hooks/useTasks';
|
||||||
import { useTags } from '@/hooks/useTags';
|
import { useTags } from '@/hooks/useTags';
|
||||||
import { Task, Tag } from '@/lib/types';
|
import { Task, Tag } from '@/lib/types';
|
||||||
import { CreateTaskData, UpdateTaskData, TaskFilters } from '@/clients/tasks-client';
|
import { CreateTaskData, UpdateTaskData, TaskFilters } from '@/clients/tasks-client';
|
||||||
|
import { KanbanFilters } from '@/components/kanban/KanbanFilters';
|
||||||
|
|
||||||
interface TasksContextType {
|
interface TasksContextType {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
@@ -24,6 +25,10 @@ interface TasksContextType {
|
|||||||
deleteTask: (taskId: string) => Promise<void>;
|
deleteTask: (taskId: string) => Promise<void>;
|
||||||
refreshTasks: () => Promise<void>;
|
refreshTasks: () => Promise<void>;
|
||||||
setFilters: (filters: TaskFilters) => void;
|
setFilters: (filters: TaskFilters) => void;
|
||||||
|
// Kanban filters
|
||||||
|
kanbanFilters: KanbanFilters;
|
||||||
|
setKanbanFilters: (filters: KanbanFilters) => void;
|
||||||
|
filteredTasks: Task[];
|
||||||
// Tags
|
// Tags
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
tagsLoading: boolean;
|
tagsLoading: boolean;
|
||||||
@@ -46,11 +51,50 @@ export function TasksProvider({ children, initialTasks, initialStats }: TasksPro
|
|||||||
|
|
||||||
const { tags, loading: tagsLoading, error: tagsError } = useTags();
|
const { tags, loading: tagsLoading, error: tagsError } = useTags();
|
||||||
|
|
||||||
|
// État des filtres Kanban
|
||||||
|
const [kanbanFilters, setKanbanFilters] = useState<KanbanFilters>({});
|
||||||
|
|
||||||
|
// Filtrage des tâches
|
||||||
|
const filteredTasks = useMemo(() => {
|
||||||
|
let filtered = tasksState.tasks;
|
||||||
|
|
||||||
|
// Filtre par recherche
|
||||||
|
if (kanbanFilters.search) {
|
||||||
|
const searchLower = kanbanFilters.search.toLowerCase();
|
||||||
|
filtered = filtered.filter(task =>
|
||||||
|
task.title.toLowerCase().includes(searchLower) ||
|
||||||
|
task.description?.toLowerCase().includes(searchLower) ||
|
||||||
|
task.tags?.some(tag => tag.toLowerCase().includes(searchLower))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre par tags
|
||||||
|
if (kanbanFilters.tags?.length) {
|
||||||
|
filtered = filtered.filter(task =>
|
||||||
|
kanbanFilters.tags!.some(filterTag =>
|
||||||
|
task.tags?.includes(filterTag)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre par priorités
|
||||||
|
if (kanbanFilters.priorities?.length) {
|
||||||
|
filtered = filtered.filter(task =>
|
||||||
|
kanbanFilters.priorities!.includes(task.priority)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [tasksState.tasks, kanbanFilters]);
|
||||||
|
|
||||||
const contextValue: TasksContextType = {
|
const contextValue: TasksContextType = {
|
||||||
...tasksState,
|
...tasksState,
|
||||||
tags,
|
tags,
|
||||||
tagsLoading,
|
tagsLoading,
|
||||||
tagsError
|
tagsError,
|
||||||
|
kanbanFilters,
|
||||||
|
setKanbanFilters,
|
||||||
|
filteredTasks
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user