feat: add sorting functionality to KanbanFilters
- Enhanced `KanbanFilters` to include sorting options, allowing users to sort tasks dynamically. - Implemented dropdown for sorting with options rendered via portal to manage z-index issues. - Updated `TasksContext` to handle sorting preferences and apply sorting logic to both pinned and regular tasks. - Added `sortBy` property to `KanbanFilters` and user preferences for persistent sorting settings.
This commit is contained in:
@@ -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<HTMLDivElement>(null);
|
||||
const sortButtonRef = useRef<HTMLButtonElement>(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'}
|
||||
</Button>
|
||||
|
||||
{/* Bouton de tri */}
|
||||
<div className="relative" ref={sortDropdownRef}>
|
||||
<Button
|
||||
ref={sortButtonRef}
|
||||
variant="ghost"
|
||||
onClick={handleSortToggle}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
|
||||
</svg>
|
||||
Tris
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${isSortExpanded ? '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>
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
Filtres
|
||||
{activeFiltersCount > 0 && (
|
||||
<span className="bg-cyan-500 text-slate-900 text-xs px-2 py-0.5 rounded-full font-medium">
|
||||
{activeFiltersCount}
|
||||
</span>
|
||||
)}
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
@@ -152,12 +234,6 @@ export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps)
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
Filtres
|
||||
{activeFiltersCount > 0 && (
|
||||
<span className="bg-cyan-500 text-slate-900 text-xs px-2 py-0.5 rounded-full font-medium">
|
||||
{activeFiltersCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{activeFiltersCount > 0 && (
|
||||
@@ -176,7 +252,7 @@ export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps)
|
||||
<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">
|
||||
<label className="block text-xs font-mono font-medium text-slate-300 uppercase tracking-wider">
|
||||
Priorités
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -184,7 +260,7 @@ export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps)
|
||||
<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 ${
|
||||
className={`flex items-center gap-2 px-2 py-1 rounded-lg border transition-all text-xs 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'
|
||||
@@ -203,7 +279,7 @@ export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps)
|
||||
{/* 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">
|
||||
<label className="block text-xs font-mono font-medium text-slate-300 uppercase tracking-wider">
|
||||
Tags
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -211,7 +287,7 @@ export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps)
|
||||
<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 ${
|
||||
className={`flex items-center gap-2 px-2 py-1 rounded-lg border transition-all text-xs 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'
|
||||
@@ -234,7 +310,7 @@ export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps)
|
||||
<div className="text-xs text-slate-400 font-mono uppercase tracking-wider mb-2">
|
||||
Filtres actifs
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="space-y-1 text-xs">
|
||||
{filters.search && (
|
||||
<div className="text-slate-300">
|
||||
Recherche: <span className="text-cyan-400">“{filters.search}”</span>
|
||||
@@ -256,6 +332,42 @@ export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dropdown de tri rendu via portail pour éviter les problèmes de z-index */}
|
||||
{isSortExpanded && typeof window !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={sortDropdownRef}
|
||||
className="fixed w-80 bg-slate-800 border border-slate-700 rounded-lg shadow-xl z-[9999] max-h-64 overflow-y-auto"
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left
|
||||
}}
|
||||
>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
handleSortChange(option.key);
|
||||
setIsSortExpanded(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left text-xs font-mono hover:bg-slate-700 transition-colors flex items-center gap-2 ${
|
||||
(filters.sortBy || 'priority-desc') === option.key
|
||||
? 'bg-cyan-600/20 text-cyan-400 border-l-2 border-cyan-400'
|
||||
: 'text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{option.icon}</span>
|
||||
<span className="flex-1">{option.label}</span>
|
||||
{(filters.sortBy || 'priority-desc') === option.key && (
|
||||
<svg className="w-4 h-4 text-cyan-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
200
lib/sort-config.ts
Normal file
200
lib/sort-config.ts
Normal file
@@ -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<T>(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}`;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export interface KanbanFilters {
|
||||
tags?: string[];
|
||||
priorities?: TaskPriority[];
|
||||
showCompleted?: boolean;
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
export interface ViewPreferences {
|
||||
|
||||
@@ -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);
|
||||
|
||||
// Filtrage des tâches régulières (pas les épinglées)
|
||||
return { pinnedTasks: sortedPinned, regularTasks: regular };
|
||||
}, [tasksState.tasks, tags, kanbanFilters.sortBy]);
|
||||
|
||||
// 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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user