Files
towercontrol/components/kanban/KanbanFilters.tsx
Julien Froidefond 2f18c08b55 feat: reintroduce ColumnVisibilityToggle in KanbanFilters
- Moved `ColumnVisibilityToggle` from `Board` and `SwimlanesBase` to `KanbanFilters` for better accessibility.
- Updated `KanbanFilters` to manage column visibility, enhancing user control over displayed statuses.
- Cleaned up unused imports and adjusted layout for improved UI consistency.
2025-09-15 10:33:49 +02:00

498 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useRef, useMemo } 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';
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;
}
export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps) {
const { tags: availableTags, regularTasks } = useTasksContext();
const { hiddenStatuses, toggleStatusVisibility } = useColumnVisibility();
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-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>
{/* 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-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"
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-slate-400 hover:text-red-400"
>
Effacer
</Button>
)}
</div>
{/* Filtres étendus */}
{isExpanded && (
<div className="mt-4 border-t border-slate-700/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-slate-300 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-slate-600 bg-slate-800/50 text-slate-400 hover:border-slate-500'
}`}
>
<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-slate-300 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-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} ({tagCounts[tag.name] || 0})
</button>
))}
</div>
</div>
)}
</div>
{/* Visibilité des colonnes */}
<div className="col-span-full border-t border-slate-700/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-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-xs">
{filters.search && (
<div className="text-slate-300">
Recherche: <span className="text-cyan-400">&ldquo;{filters.search}&rdquo;</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>
{/* 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
)}
{/* 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-slate-800 border border-slate-700 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-slate-700 transition-colors flex items-center gap-2 first:rounded-t-lg ${
(!filters.swimlanesMode || filters.swimlanesMode === 'tags') ? 'bg-slate-700 text-cyan-400' : 'text-slate-300'
}`}
>
🏷 Par tags
</button>
<button
onClick={() => handleSwimlaneModeChange('priority')}
className={`w-full px-3 py-2 text-left text-xs hover:bg-slate-700 transition-colors flex items-center gap-2 last:rounded-b-lg ${
filters.swimlanesMode === 'priority' ? 'bg-slate-700 text-cyan-400' : 'text-slate-300'
}`}
>
🎯 Par priorité
</button>
</div>,
document.body
)}
</div>
);
}