feat: unify CardHeader padding across components

- Updated `CardHeader` padding from `pb-3` to `pb-4` in `JiraLogs`, `JiraSync`, `KanbanColumn`, `ObjectivesBoard`, and `DesktopControls` for consistent spacing.
- Refactored `DesktopControls` and `KanbanFilters` to utilize new `ControlPanel`, `ControlSection`, and `ControlGroup` components, enhancing layout structure and maintainability.
- Replaced button elements with `ToggleButton` and `FilterChip` components in various filter sections for improved UI consistency and usability.
This commit is contained in:
Julien Froidefond
2025-09-28 21:53:22 +02:00
parent bdf8ab9fb4
commit 0fcd4d68c1
20 changed files with 1011 additions and 451 deletions

View File

@@ -0,0 +1,46 @@
import { ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface ControlPanelProps {
children: ReactNode;
className?: string;
}
export function ControlPanel({ children, className }: ControlPanelProps) {
return (
<div className={cn(
'bg-[var(--card)]/30 border-b border-[var(--border)]/30 w-full',
className
)}>
<div className="w-full px-6 py-2">
{children}
</div>
</div>
);
}
interface ControlSectionProps {
children: ReactNode;
className?: string;
}
export function ControlSection({ children, className }: ControlSectionProps) {
return (
<div className={cn('flex items-center gap-4', className)}>
{children}
</div>
);
}
interface ControlGroupProps {
children: ReactNode;
className?: string;
}
export function ControlGroup({ children, className }: ControlGroupProps) {
return (
<div className={cn('flex items-center gap-2', className)}>
{children}
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils';
interface FilterChipProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'selected' | 'hidden' | 'priority' | 'tag';
color?: string;
count?: number;
icon?: React.ReactNode;
size?: 'sm' | 'md';
}
const FilterChip = forwardRef<HTMLButtonElement, FilterChipProps>(
({
className,
variant = 'default',
color,
count,
icon,
size = 'sm',
children,
...props
}, ref) => {
const variants = {
default: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)] hover:bg-[var(--card)]/80',
selected: 'border-cyan-400 bg-cyan-400/10 text-cyan-400',
hidden: 'bg-[var(--muted)]/20 text-[var(--muted)] border-[var(--muted)]/30 hover:bg-[var(--muted)]/30',
priority: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]',
tag: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:border-[var(--border)]'
};
const sizes = {
sm: 'px-2 py-1 text-xs',
md: 'px-3 py-1.5 text-sm'
};
return (
<button
ref={ref}
className={cn(
'flex items-center gap-2 rounded border transition-all font-medium cursor-pointer',
variants[variant],
sizes[size],
className
)}
{...props}
>
{icon && (
<div className="flex-shrink-0">
{icon}
</div>
)}
{color && (
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: color }}
/>
)}
<span className="flex-1 text-left">
{children}
{count !== undefined && count > 0 && (
<span className="ml-1 opacity-75">
({count})
</span>
)}
</span>
</button>
);
}
);
FilterChip.displayName = 'FilterChip';
export { FilterChip };

View File

@@ -0,0 +1,168 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from './Card';
import { Badge } from './Badge';
import { Button } from './Button';
interface FilterSummaryProps {
filters: {
search?: string;
priorities?: string[];
tags?: string[];
showWithDueDate?: boolean;
showJiraOnly?: boolean;
hideJiraTasks?: boolean;
jiraProjects?: string[];
jiraTypes?: string[];
showTfsOnly?: boolean;
hideTfsTasks?: boolean;
tfsProjects?: string[];
};
activeFiltersCount: number;
onClearFilters?: () => void;
className?: string;
}
export function FilterSummary({
filters,
activeFiltersCount,
onClearFilters,
className
}: FilterSummaryProps) {
if (activeFiltersCount === 0) return null;
const filterItems: Array<{
label: string;
value: string;
variant: 'default' | 'primary' | 'success' | 'destructive' | 'accent' | 'purple' | 'yellow' | 'green' | 'blue' | 'gray' | 'outline' | 'danger' | 'warning';
}> = [];
// Recherche
if (filters.search) {
filterItems.push({
label: 'Recherche',
value: `"${filters.search}"`,
variant: 'primary'
});
}
// Priorités
if (filters.priorities?.filter(Boolean).length) {
filterItems.push({
label: 'Priorités',
value: filters.priorities.filter(Boolean).join(', '),
variant: 'accent'
});
}
// Tags
if (filters.tags?.filter(Boolean).length) {
filterItems.push({
label: 'Tags',
value: filters.tags.filter(Boolean).join(', '),
variant: 'purple'
});
}
// Affichage avec date de fin
if (filters.showWithDueDate) {
filterItems.push({
label: 'Affichage',
value: 'Avec date de fin',
variant: 'success'
});
}
// Jira
if (filters.showJiraOnly) {
filterItems.push({
label: 'Affichage',
value: 'Jira seulement',
variant: 'blue'
});
}
if (filters.hideJiraTasks) {
filterItems.push({
label: 'Affichage',
value: 'Masquer Jira',
variant: 'destructive'
});
}
if (filters.jiraProjects?.filter(Boolean).length) {
filterItems.push({
label: 'Projets Jira',
value: filters.jiraProjects.filter(Boolean).join(', '),
variant: 'blue'
});
}
if (filters.jiraTypes?.filter(Boolean).length) {
filterItems.push({
label: 'Types Jira',
value: filters.jiraTypes.filter(Boolean).join(', '),
variant: 'purple'
});
}
// TFS
if (filters.showTfsOnly) {
filterItems.push({
label: 'Affichage',
value: 'TFS seulement',
variant: 'yellow'
});
}
if (filters.hideTfsTasks) {
filterItems.push({
label: 'Affichage',
value: 'Masquer TFS',
variant: 'destructive'
});
}
if (filters.tfsProjects?.filter(Boolean).length) {
filterItems.push({
label: 'Projets TFS',
value: filters.tfsProjects.filter(Boolean).join(', '),
variant: 'yellow'
});
}
return (
<Card className={className}>
<CardHeader className="py-2 pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-xs font-mono uppercase tracking-wider text-[var(--muted-foreground)]">
Filtres actifs ({activeFiltersCount})
</CardTitle>
{onClearFilters && (
<Button
variant="ghost"
size="sm"
onClick={onClearFilters}
className="text-[var(--muted-foreground)] hover:text-[var(--destructive)] text-xs"
>
Effacer
</Button>
)}
</div>
</CardHeader>
<CardContent className="pt-3">
<div className="space-y-2">
{filterItems.map((item, index) => (
<div key={index} className="flex items-center gap-2 text-xs">
<span className="text-[var(--muted-foreground)] font-medium">
{item.label}:
</span>
<Badge variant={item.variant} size="sm">
{item.value}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,78 @@
import { InputHTMLAttributes, forwardRef, useState, useEffect, useRef, useCallback } from 'react';
import { Input } from './Input';
import { cn } from '@/lib/utils';
interface SearchInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
value?: string;
onChange?: (value: string) => void;
onDebouncedChange?: (value: string) => void;
debounceMs?: number;
placeholder?: string;
className?: string;
}
const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
({
value = '',
onChange,
onDebouncedChange,
debounceMs = 300,
placeholder = "Rechercher...",
className,
...props
}, ref) => {
const [localValue, setLocalValue] = useState(value);
const timeoutRef = useRef<number | undefined>(undefined);
// Fonction debouncée pour les changements
const debouncedChange = useCallback((searchValue: string) => {
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
timeoutRef.current = window.setTimeout(() => {
onDebouncedChange?.(searchValue);
}, debounceMs);
}, [onDebouncedChange, debounceMs]);
// Gérer les changements locaux
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
onChange?.(newValue);
debouncedChange(newValue);
};
// Synchroniser l'état local quand la valeur externe change
useEffect(() => {
setLocalValue(value);
}, [value]);
// Nettoyer le timeout au démontage
useEffect(() => {
return () => {
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
};
}, []);
return (
<div className={cn('flex-1 min-w-0', className)}>
<Input
ref={ref}
type="text"
value={localValue}
onChange={handleChange}
placeholder={placeholder}
className="bg-[var(--card)] border-[var(--border)] w-full"
{...props}
/>
</div>
);
}
);
SearchInput.displayName = 'SearchInput';
export { SearchInput };

View File

@@ -0,0 +1,78 @@
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils';
interface ToggleButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'accent' | 'secondary' | 'warning' | 'cyan';
size?: 'sm' | 'md';
isActive?: boolean;
icon?: React.ReactNode;
count?: number;
}
const ToggleButton = forwardRef<HTMLButtonElement, ToggleButtonProps>(
({ className, variant = 'primary', size = 'md', isActive = false, icon, count, children, ...props }, ref) => {
const variants = {
primary: isActive
? 'bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30'
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--primary)]/50',
accent: isActive
? 'bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30'
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--accent)]/50',
secondary: isActive
? 'bg-[var(--secondary)]/20 text-[var(--secondary)] border border-[var(--secondary)]/30'
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--secondary)]/50',
warning: isActive
? 'bg-[var(--warning)]/20 text-[var(--warning)] border border-[var(--warning)]/30'
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--warning)]/50',
cyan: isActive
? 'bg-[var(--cyan)]/20 text-[var(--cyan)] border border-[var(--cyan)]/30'
: 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--cyan)]/50'
};
// Déterminer si c'est un bouton avec seulement des icônes
const isIconOnly = icon && !children && count === undefined;
const sizes = {
sm: isIconOnly ? 'px-2 py-1.5 text-sm' : 'px-3 py-1.5 text-sm',
md: isIconOnly ? 'px-2 py-1.5 text-sm' : 'px-3 py-1.5 text-sm'
};
return (
<button
ref={ref}
className={cn(
'flex items-center gap-2 rounded-md font-mono transition-all',
variants[variant],
sizes[size],
className
)}
{...props}
>
{icon && (
<div className="flex-shrink-0">
{icon}
</div>
)}
{children && (
<span className="flex-1 text-left">
{children}
{count !== undefined && count > 0 && (
<span className="ml-1 text-xs opacity-75">
({count})
</span>
)}
</span>
)}
{count !== undefined && count > 0 && !children && (
<span className="text-xs opacity-75">
({count})
</span>
)}
</button>
);
}
);
ToggleButton.displayName = 'ToggleButton';
export { ToggleButton };

View File

@@ -12,6 +12,13 @@ export { ActionCard } from './ActionCard';
export { TaskCard } from './TaskCard';
export { MetricCard } from './MetricCard';
// Composants Kanban
export { ToggleButton } from './ToggleButton';
export { SearchInput } from './SearchInput';
export { ControlPanel, ControlSection, ControlGroup } from './ControlPanel';
export { FilterSummary } from './FilterSummary';
export { FilterChip } from './FilterChip';
// Composants existants
export { Card, CardHeader, CardTitle, CardContent, CardFooter } from './Card';
export { Header } from './Header';