diff --git a/components/kanban/KanbanFilters.tsx b/components/kanban/KanbanFilters.tsx index 44d548a..8ed43c0 100644 --- a/components/kanban/KanbanFilters.tsx +++ b/components/kanban/KanbanFilters.tsx @@ -1,11 +1,13 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; import { TaskPriority } from '@/lib/types'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { useTasksContext } from '@/contexts/TasksContext'; import { getAllPriorities, getPriorityColorHex } from '@/lib/status-config'; +import { SORT_OPTIONS } from '@/lib/sort-config'; export interface KanbanFilters { search?: string; @@ -15,6 +17,7 @@ export interface KanbanFilters { compactView?: boolean; swimlanesByTags?: boolean; pinnedTag?: string; // Tag pour les objectifs principaux + sortBy?: string; // Clé de l'option de tri sélectionnée } interface KanbanFiltersProps { @@ -25,6 +28,24 @@ interface KanbanFiltersProps { export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps) { const { tags: availableTags } = useTasksContext(); const [isExpanded, setIsExpanded] = useState(false); + const [isSortExpanded, setIsSortExpanded] = useState(false); + const sortDropdownRef = useRef(null); + const sortButtonRef = useRef(null); + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }); + + // Fermer le dropdown de tri en cliquant à l'extérieur + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (sortDropdownRef.current && !sortDropdownRef.current.contains(event.target as Node)) { + setIsSortExpanded(false); + } + } + + if (isSortExpanded) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isSortExpanded]); const handleSearchChange = (search: string) => { onFiltersChange({ ...filters, search: search || undefined }); @@ -68,6 +89,24 @@ export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps) }); }; + const handleSortChange = (sortKey: string) => { + onFiltersChange({ + ...filters, + sortBy: sortKey + }); + }; + + const handleSortToggle = () => { + if (!isSortExpanded && sortButtonRef.current) { + const rect = sortButtonRef.current.getBoundingClientRect(); + setDropdownPosition({ + top: rect.bottom + window.scrollY + 4, + left: rect.left + window.scrollX + }); + } + setIsSortExpanded(!isSortExpanded); + }; + const handleClearFilters = () => { onFiltersChange({}); }; @@ -139,11 +178,54 @@ export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps) {filters.compactView ? 'Détaillée' : 'Compacte'} + {/* Bouton de tri */} +
+ + +
+ {activeFiltersCount > 0 && ( @@ -176,7 +252,7 @@ export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps)
{/* Filtres par priorité */}
-
); } diff --git a/lib/sort-config.ts b/lib/sort-config.ts new file mode 100644 index 0000000..dc9da95 --- /dev/null +++ b/lib/sort-config.ts @@ -0,0 +1,200 @@ +import { Task, TaskPriority } from './types'; +import { getPriorityConfig } from './status-config'; + +export type SortField = 'priority' | 'tags' | 'createdAt' | 'updatedAt' | 'dueDate' | 'title'; +export type SortDirection = 'asc' | 'desc'; + +export interface SortConfig { + field: SortField; + direction: SortDirection; +} + +export interface SortOption { + key: string; + label: string; + field: SortField; + direction: SortDirection; + icon: string; +} + +// Configuration des options de tri disponibles +export const SORT_OPTIONS: SortOption[] = [ + { + key: 'priority-desc', + label: 'Priorité (Urgente → Faible)', + field: 'priority', + direction: 'desc', + icon: '🔥' + }, + { + key: 'priority-asc', + label: 'Priorité (Faible → Urgente)', + field: 'priority', + direction: 'asc', + icon: '🔵' + }, + { + key: 'tags-asc', + label: 'Tags (A → Z)', + field: 'tags', + direction: 'asc', + icon: '🏷️' + }, + { + key: 'title-asc', + label: 'Titre (A → Z)', + field: 'title', + direction: 'asc', + icon: '📝' + }, + { + key: 'title-desc', + label: 'Titre (Z → A)', + field: 'title', + direction: 'desc', + icon: '📝' + }, + { + key: 'createdAt-desc', + label: 'Date création (Récent → Ancien)', + field: 'createdAt', + direction: 'desc', + icon: '📅' + }, + { + key: 'createdAt-asc', + label: 'Date création (Ancien → Récent)', + field: 'createdAt', + direction: 'asc', + icon: '📅' + }, + { + key: 'dueDate-asc', + label: 'Échéance (Proche → Lointaine)', + field: 'dueDate', + direction: 'asc', + icon: '⏰' + }, + { + key: 'dueDate-desc', + label: 'Échéance (Lointaine → Proche)', + field: 'dueDate', + direction: 'desc', + icon: '⏰' + } +]; + +// Tri par défaut : Priorité (desc) puis Tags (asc) +export const DEFAULT_SORT: SortConfig[] = [ + { field: 'priority', direction: 'desc' }, + { field: 'tags', direction: 'asc' } +]; + +/** + * Compare deux valeurs selon la direction de tri + */ +function compareValues(a: T, b: T, direction: SortDirection): number { + if (a === b) return 0; + if (a == null) return 1; + if (b == null) return -1; + + const result = a < b ? -1 : 1; + return direction === 'asc' ? result : -result; +} + +/** + * Obtient la valeur de priorité numérique pour le tri + */ +function getPriorityValue(priority: TaskPriority): number { + return getPriorityConfig(priority).order; +} + +/** + * Obtient le premier tag pour le tri (ou chaîne vide si pas de tags) + */ +function getFirstTag(task: Task): string { + return task.tags?.[0]?.toLowerCase() || ''; +} + +/** + * Compare deux tâches selon un critère de tri + */ +function compareTasksByField(a: Task, b: Task, sortConfig: SortConfig): number { + const { field, direction } = sortConfig; + + switch (field) { + case 'priority': + return compareValues( + getPriorityValue(a.priority), + getPriorityValue(b.priority), + direction + ); + + case 'tags': + return compareValues( + getFirstTag(a), + getFirstTag(b), + direction + ); + + case 'title': + return compareValues( + a.title.toLowerCase(), + b.title.toLowerCase(), + direction + ); + + case 'createdAt': + return compareValues( + new Date(a.createdAt).getTime(), + new Date(b.createdAt).getTime(), + direction + ); + + case 'updatedAt': + return compareValues( + new Date(a.updatedAt).getTime(), + new Date(b.updatedAt).getTime(), + direction + ); + + case 'dueDate': + return compareValues( + a.dueDate ? new Date(a.dueDate).getTime() : null, + b.dueDate ? new Date(b.dueDate).getTime() : null, + direction + ); + + default: + return 0; + } +} + +/** + * Trie un tableau de tâches selon une configuration de tri multiple + */ +export function sortTasks(tasks: Task[], sortConfigs: SortConfig[] = DEFAULT_SORT): Task[] { + return [...tasks].sort((a, b) => { + for (const sortConfig of sortConfigs) { + const result = compareTasksByField(a, b, sortConfig); + if (result !== 0) { + return result; + } + } + return 0; + }); +} + +/** + * Utilitaire pour obtenir une option de tri par sa clé + */ +export function getSortOption(key: string): SortOption | undefined { + return SORT_OPTIONS.find(option => option.key === key); +} + +/** + * Utilitaire pour créer une clé de tri + */ +export function createSortKey(field: SortField, direction: SortDirection): string { + return `${field}-${direction}`; +} diff --git a/services/user-preferences.ts b/services/user-preferences.ts index 0f16a97..0e83435 100644 --- a/services/user-preferences.ts +++ b/services/user-preferences.ts @@ -6,6 +6,7 @@ export interface KanbanFilters { tags?: string[]; priorities?: TaskPriority[]; showCompleted?: boolean; + sortBy?: string; } export interface ViewPreferences { diff --git a/src/contexts/TasksContext.tsx b/src/contexts/TasksContext.tsx index 3f8303c..064616e 100644 --- a/src/contexts/TasksContext.tsx +++ b/src/contexts/TasksContext.tsx @@ -7,6 +7,7 @@ import { userPreferencesService } from '@/services/user-preferences'; import { Task, Tag } from '@/lib/types'; import { CreateTaskData, UpdateTaskData, TaskFilters } from '@/clients/tasks-client'; import { KanbanFilters } from '@/components/kanban/KanbanFilters'; +import { sortTasks, getSortOption, DEFAULT_SORT, createSortKey } from '@/lib/sort-config'; interface TasksContextType { tasks: Task[]; @@ -69,6 +70,7 @@ export function TasksProvider({ children, initialTasks, initialStats, initialTag tags: savedFilters.tags, priorities: savedFilters.priorities, showCompleted: savedFilters.showCompleted, + sortBy: savedFilters.sortBy || createSortKey('priority', 'desc'), // Tri par défaut compactView: savedViewPrefs.compactView, swimlanesByTags: savedViewPrefs.swimlanesByTags }); @@ -83,7 +85,8 @@ export function TasksProvider({ children, initialTasks, initialStats, initialTag search: newFilters.search, tags: newFilters.tags, priorities: newFilters.priorities, - showCompleted: newFilters.showCompleted + showCompleted: newFilters.showCompleted, + sortBy: newFilters.sortBy }); // Sauvegarder les préférences de vue @@ -94,7 +97,7 @@ export function TasksProvider({ children, initialTasks, initialStats, initialTag }); }; - // Séparer les tâches épinglées (objectifs) des autres + // Séparer les tâches épinglées (objectifs) des autres et les trier const { pinnedTasks, regularTasks } = useMemo(() => { const pinnedTagNames = tags.filter(tag => tag.isPinned).map(tag => tag.name); @@ -110,10 +113,20 @@ export function TasksProvider({ children, initialTasks, initialStats, initialTag } }); - return { pinnedTasks: pinned, regularTasks: regular }; - }, [tasksState.tasks, tags]); + // Trier les tâches épinglées avec le même tri que les autres + const sortedPinned = kanbanFilters.sortBy ? + (() => { + const sortOption = getSortOption(kanbanFilters.sortBy); + return sortOption ? + sortTasks(pinned, [{ field: sortOption.field, direction: sortOption.direction }]) : + sortTasks(pinned, DEFAULT_SORT); + })() : + sortTasks(pinned, DEFAULT_SORT); + + return { pinnedTasks: sortedPinned, regularTasks: regular }; + }, [tasksState.tasks, tags, kanbanFilters.sortBy]); - // Filtrage des tâches régulières (pas les épinglées) + // Filtrage et tri des tâches régulières (pas les épinglées) const filteredTasks = useMemo(() => { let filtered = regularTasks; @@ -143,6 +156,20 @@ export function TasksProvider({ children, initialTasks, initialStats, initialTag ); } + // Tri des tâches + if (kanbanFilters.sortBy) { + const sortOption = getSortOption(kanbanFilters.sortBy); + if (sortOption) { + filtered = sortTasks(filtered, [{ + field: sortOption.field, + direction: sortOption.direction + }]); + } + } else { + // Tri par défaut (priorité desc + tags asc) + filtered = sortTasks(filtered, DEFAULT_SORT); + } + return filtered; }, [regularTasks, kanbanFilters]);