- Added theme context and provider for light/dark mode support. - Integrated theme toggle button in the Header component. - Updated UI components to utilize CSS variables for consistent theming. - Enhanced Kanban components and forms with new theme styles for better visual coherence. - Adjusted global styles to define color variables for both themes, improving maintainability.
504 lines
20 KiB
TypeScript
504 lines
20 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import { TaskPriority, TaskStatus } 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';
|
||
import { useColumnVisibility } from '@/hooks/useColumnVisibility';
|
||
import { ColumnVisibilityToggle } from './ColumnVisibilityToggle';
|
||
|
||
export interface KanbanFilters {
|
||
search?: string;
|
||
tags?: string[];
|
||
priorities?: TaskPriority[];
|
||
showCompleted?: boolean;
|
||
compactView?: boolean;
|
||
swimlanesByTags?: boolean;
|
||
swimlanesMode?: 'tags' | 'priority'; // Mode des swimlanes
|
||
pinnedTag?: string; // Tag pour les objectifs principaux
|
||
sortBy?: string; // Clé de l'option de tri sélectionnée
|
||
}
|
||
|
||
interface KanbanFiltersProps {
|
||
filters: KanbanFilters;
|
||
onFiltersChange: (filters: KanbanFilters) => void;
|
||
hiddenStatuses?: Set<TaskStatus>;
|
||
onToggleStatusVisibility?: (status: TaskStatus) => void;
|
||
}
|
||
|
||
export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsHiddenStatuses, onToggleStatusVisibility }: KanbanFiltersProps) {
|
||
const { tags: availableTags, regularTasks } = useTasksContext();
|
||
const { hiddenStatuses: localHiddenStatuses, toggleStatusVisibility: localToggleStatusVisibility } = useColumnVisibility();
|
||
|
||
// Utiliser les props si disponibles, sinon utiliser l'état local
|
||
const hiddenStatuses = propsHiddenStatuses || localHiddenStatuses;
|
||
const toggleStatusVisibility = onToggleStatusVisibility || localToggleStatusVisibility;
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
const [isSortExpanded, setIsSortExpanded] = useState(false);
|
||
const [isSwimlaneModeExpanded, setIsSwimlaneModeExpanded] = useState(false);
|
||
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
||
const swimlaneModeDropdownRef = useRef<HTMLDivElement>(null);
|
||
const sortButtonRef = useRef<HTMLButtonElement>(null);
|
||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
|
||
|
||
// 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 || isSwimlaneModeExpanded) {
|
||
document.addEventListener('mousedown', handleClickOutside);
|
||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||
}
|
||
}, [isSortExpanded, isSwimlaneModeExpanded]);
|
||
|
||
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 handleCompactViewToggle = () => {
|
||
onFiltersChange({
|
||
...filters,
|
||
compactView: !filters.compactView
|
||
});
|
||
};
|
||
|
||
const handleSwimlanesToggle = () => {
|
||
onFiltersChange({
|
||
...filters,
|
||
swimlanesByTags: !filters.swimlanesByTags
|
||
});
|
||
};
|
||
|
||
const handleSwimlaneModeChange = (mode: 'tags' | 'priority') => {
|
||
onFiltersChange({
|
||
...filters,
|
||
swimlanesByTags: true,
|
||
swimlanesMode: mode
|
||
});
|
||
setIsSwimlaneModeExpanded(false);
|
||
};
|
||
|
||
const handleSwimlaneModeToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||
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,
|
||
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({});
|
||
};
|
||
|
||
const activeFiltersCount = (filters.tags?.length || 0) + (filters.priorities?.length || 0) + (filters.search ? 1 : 0);
|
||
|
||
// Calculer les compteurs pour les priorités
|
||
const priorityCounts = useMemo(() => {
|
||
const counts: Record<string, number> = {};
|
||
getAllPriorities().forEach(priority => {
|
||
counts[priority.key] = regularTasks.filter(task => task.priority === priority.key).length;
|
||
});
|
||
return counts;
|
||
}, [regularTasks]);
|
||
|
||
// Calculer les compteurs pour les tags
|
||
const tagCounts = useMemo(() => {
|
||
const counts: Record<string, number> = {};
|
||
availableTags.forEach(tag => {
|
||
counts[tag.name] = regularTasks.filter(task => task.tags?.includes(tag.name)).length;
|
||
});
|
||
return counts;
|
||
}, [regularTasks, availableTags]);
|
||
|
||
const priorityOptions = getAllPriorities().map(priorityConfig => ({
|
||
value: priorityConfig.key,
|
||
label: priorityConfig.label,
|
||
color: priorityConfig.color,
|
||
count: priorityCounts[priorityConfig.key] || 0
|
||
}));
|
||
|
||
// Trier les tags par nombre d'utilisation (décroissant)
|
||
const sortedTags = useMemo(() => {
|
||
return [...availableTags].sort((a, b) => {
|
||
const countA = tagCounts[a.name] || 0;
|
||
const countB = tagCounts[b.name] || 0;
|
||
return countB - countA; // Décroissant
|
||
});
|
||
}, [availableTags, tagCounts]);
|
||
|
||
return (
|
||
<div className="bg-[var(--card)]/50 border-b border-[var(--border)]/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-[var(--card)] border-[var(--border)]"
|
||
/>
|
||
</div>
|
||
|
||
{/* Menu swimlanes */}
|
||
<div className="flex gap-1">
|
||
<Button
|
||
variant={filters.swimlanesByTags ? "primary" : "ghost"}
|
||
onClick={handleSwimlanesToggle}
|
||
className="flex items-center gap-2"
|
||
title="Mode d'affichage"
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
{filters.swimlanesByTags ? (
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||
) : (
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||
)}
|
||
</svg>
|
||
{!filters.swimlanesByTags
|
||
? 'Normal'
|
||
: filters.swimlanesMode === 'priority'
|
||
? 'Par priorité'
|
||
: 'Par tags'
|
||
}
|
||
</Button>
|
||
|
||
{/* Bouton pour changer le mode des swimlanes */}
|
||
{filters.swimlanesByTags && (
|
||
<Button
|
||
variant="ghost"
|
||
onClick={handleSwimlaneModeToggle}
|
||
className="flex items-center gap-1 px-2"
|
||
>
|
||
<svg
|
||
className={`w-3 h-3 transition-transform ${isSwimlaneModeExpanded ? '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>
|
||
|
||
{/* Bouton vue compacte */}
|
||
<Button
|
||
variant={filters.compactView ? "primary" : "ghost"}
|
||
onClick={handleCompactViewToggle}
|
||
className="flex items-center gap-2"
|
||
title={filters.compactView ? "Vue détaillée" : "Vue compacte"}
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
{filters.compactView ? (
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||
) : (
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||
)}
|
||
</svg>
|
||
{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-[var(--primary)] text-[var(--primary-foreground)] 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"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</Button>
|
||
|
||
{activeFiltersCount > 0 && (
|
||
<Button
|
||
variant="ghost"
|
||
onClick={handleClearFilters}
|
||
className="text-[var(--muted-foreground)] hover:text-[var(--destructive)]"
|
||
>
|
||
Effacer
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Filtres étendus */}
|
||
{isExpanded && (
|
||
<div className="mt-4 border-t border-[var(--border)]/50 pt-4">
|
||
{/* Grille responsive pour les filtres */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-6 lg:gap-8">
|
||
{/* Filtres par priorité */}
|
||
<div className="space-y-3">
|
||
<label className="block text-xs font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||
Priorités
|
||
</label>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{priorityOptions.map((priority) => (
|
||
<button
|
||
key={priority.value}
|
||
onClick={() => handlePriorityToggle(priority.value)}
|
||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-all text-xs font-medium whitespace-nowrap ${
|
||
filters.priorities?.includes(priority.value)
|
||
? 'border-cyan-400 bg-cyan-400/10 text-cyan-400'
|
||
: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
|
||
}`}
|
||
>
|
||
<div
|
||
className="w-2 h-2 rounded-full"
|
||
style={{ backgroundColor: getPriorityColorHex(priority.color) }}
|
||
/>
|
||
{priority.label} ({priority.count})
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Filtres par tags */}
|
||
{availableTags.length > 0 && (
|
||
<div className="space-y-3">
|
||
<label className="block text-xs font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||
Tags
|
||
</label>
|
||
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
||
{sortedTags.map((tag) => (
|
||
<button
|
||
key={tag.id}
|
||
onClick={() => handleTagToggle(tag.name)}
|
||
className={`flex items-center gap-2 px-3 py-2 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-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
|
||
}`}
|
||
>
|
||
<div
|
||
className="w-2 h-2 rounded-full"
|
||
style={{ backgroundColor: tag.color }}
|
||
/>
|
||
{tag.name} ({tagCounts[tag.name] || 0})
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Visibilité des colonnes */}
|
||
<div className="col-span-full border-t border-[var(--border)]/50 pt-6 mt-4">
|
||
<ColumnVisibilityToggle
|
||
hiddenStatuses={hiddenStatuses}
|
||
onToggleStatus={toggleStatusVisibility}
|
||
tasks={regularTasks}
|
||
className="text-xs"
|
||
/>
|
||
</div>
|
||
|
||
{/* Résumé des filtres actifs */}
|
||
{activeFiltersCount > 0 && (
|
||
<div className="bg-[var(--card)]/30 rounded-lg p-3 border border-[var(--border)]/50">
|
||
<div className="text-xs text-[var(--muted-foreground)] font-mono uppercase tracking-wider mb-2">
|
||
Filtres actifs
|
||
</div>
|
||
<div className="space-y-1 text-xs">
|
||
{filters.search && (
|
||
<div className="text-[var(--muted-foreground)]">
|
||
Recherche: <span className="text-cyan-400">“{filters.search}”</span>
|
||
</div>
|
||
)}
|
||
{filters.priorities?.length && (
|
||
<div className="text-[var(--muted-foreground)]">
|
||
Priorités: <span className="text-cyan-400">{filters.priorities.join(', ')}</span>
|
||
</div>
|
||
)}
|
||
{filters.tags?.length && (
|
||
<div className="text-[var(--muted-foreground)]">
|
||
Tags: <span className="text-cyan-400">{filters.tags.join(', ')}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</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-[var(--card)] border border-[var(--border)] 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-[var(--card-hover)] 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-[var(--muted-foreground)]'
|
||
}`}
|
||
>
|
||
<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
|
||
)}
|
||
|
||
{/* Dropdown des modes swimlanes rendu via portail pour éviter les problèmes de z-index */}
|
||
{isSwimlaneModeExpanded && typeof window !== 'undefined' && createPortal(
|
||
<div
|
||
ref={swimlaneModeDropdownRef}
|
||
className="fixed bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-xl z-[9999] min-w-[140px]"
|
||
style={{
|
||
top: dropdownPosition.top,
|
||
left: dropdownPosition.left,
|
||
}}
|
||
>
|
||
<button
|
||
onClick={() => handleSwimlaneModeChange('tags')}
|
||
className={`w-full px-3 py-2 text-left text-xs hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2 first:rounded-t-lg ${
|
||
(!filters.swimlanesMode || filters.swimlanesMode === 'tags') ? 'bg-[var(--card-hover)] text-[var(--primary)]' : 'text-[var(--muted-foreground)]'
|
||
}`}
|
||
>
|
||
🏷️ Par tags
|
||
</button>
|
||
<button
|
||
onClick={() => handleSwimlaneModeChange('priority')}
|
||
className={`w-full px-3 py-2 text-left text-xs hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2 last:rounded-b-lg ${
|
||
filters.swimlanesMode === 'priority' ? 'bg-[var(--card-hover)] text-[var(--primary)]' : 'text-[var(--muted-foreground)]'
|
||
}`}
|
||
>
|
||
🎯 Par priorité
|
||
</button>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
</div>
|
||
);
|
||
}
|