Files
towercontrol/src/components/ui/TaskCard.tsx
Julien Froidefond dc7b7c7616 feat: update TaskCard component to include todosCount in padding logic
- Modified padding logic to account for `todosCount`, ensuring proper spacing when there are todos present.
- Updated footer visibility condition to include `todosCount`, enhancing the display of task metadata based on the presence of todos.
2025-09-30 10:19:34 +02:00

549 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { HTMLAttributes, forwardRef, useState, useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
import { Card } from './Card';
import { Badge } from './Badge';
import { formatDateForDisplay } from '@/lib/date-utils';
interface TaskCardProps extends HTMLAttributes<HTMLDivElement> {
// Variants
variant?: 'compact' | 'detailed';
source?: 'manual' | 'jira' | 'tfs' | 'reminders';
// Content
title: string;
description?: string;
tags?: string[];
priority?: 'low' | 'medium' | 'high' | 'urgent';
// Status & metadata
status?: 'todo' | 'in_progress' | 'review' | 'done' | 'archived' | 'freeze' | 'backlog' | 'cancelled';
dueDate?: Date;
completedAt?: Date;
// Source-specific data
jiraKey?: string;
jiraProject?: string;
jiraType?: string;
tfsPullRequestId?: number;
tfsProject?: string;
tfsRepository?: string;
// Task ID for todos count
todosCount?: number;
// Interactive
isDragging?: boolean;
isPending?: boolean;
isEditing?: boolean;
onEdit?: (e?: React.MouseEvent) => void;
onDelete?: (e?: React.MouseEvent) => void;
onTitleClick?: (e?: React.MouseEvent) => void;
onTitleSave?: (title: string) => void;
// Styling & behavior
className?: string;
fontSize?: 'small' | 'medium' | 'large';
availableTags?: Array<{ id: string; name: string; color: string }>;
jiraConfig?: { baseUrl?: string };
tfsConfig?: { organizationUrl?: string };
}
const TaskCard = forwardRef<HTMLDivElement, TaskCardProps>(
({
variant = 'detailed',
source = 'manual',
title,
description,
tags = [],
priority = 'medium',
status,
dueDate,
completedAt,
jiraKey,
jiraProject,
jiraType,
tfsPullRequestId,
tfsProject,
tfsRepository,
todosCount,
isDragging = false,
isPending = false,
onEdit,
onDelete,
onTitleClick,
onTitleSave,
className,
fontSize = 'medium',
availableTags = [],
jiraConfig,
tfsConfig,
children,
...props
}, ref) => {
// État local pour l'édition du titre
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editTitle, setEditTitle] = useState(title);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// Mettre à jour le titre local quand la prop change
useEffect(() => {
setEditTitle(title);
}, [title]);
// Nettoyer le timeout au démontage
useEffect(() => {
const currentTimeout = timeoutRef.current;
return () => {
if (currentTimeout) {
clearTimeout(currentTimeout);
}
};
}, []);
// Gestionnaires d'événements avec preventDefault/stopPropagation
const handleEdit = (e?: React.MouseEvent) => {
e?.preventDefault();
e?.stopPropagation();
onEdit?.(e);
};
const handleDelete = (e?: React.MouseEvent) => {
e?.preventDefault();
e?.stopPropagation();
onDelete?.(e);
};
const handleTitleClick = (e?: React.MouseEvent) => {
e?.preventDefault();
e?.stopPropagation();
if (!isDragging && !isPending) {
setIsEditingTitle(true);
}
onTitleClick?.(e);
};
const handleTitleSave = async () => {
const trimmedTitle = editTitle.trim();
if (trimmedTitle && trimmedTitle !== title) {
onTitleSave?.(trimmedTitle);
}
setIsEditingTitle(false);
};
const handleTitleCancel = () => {
setEditTitle(title);
setIsEditingTitle(false);
};
const handleTitleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleTitleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
handleTitleCancel();
}
};
// Classes CSS pour les différentes tailles de police
const getFontSizeClasses = () => {
switch (fontSize) {
case 'small':
return {
title: 'text-xs',
description: 'text-xs',
meta: 'text-xs',
};
case 'large':
return {
title: 'text-base',
description: 'text-sm',
meta: 'text-sm',
};
default: // medium
return {
title: 'text-sm',
description: 'text-xs',
meta: 'text-xs',
};
}
};
const fontClasses = getFontSizeClasses();
// Styles spéciaux pour les sources
const getSourceStyles = () => {
if (source === 'jira') {
return {
backgroundColor: 'var(--jira-card, #dbeafe)',
borderLeft: '3px solid var(--jira-border, #3b82f6)',
color: 'var(--jira-text, #1e40af)',
};
}
if (source === 'tfs') {
return {
backgroundColor: 'var(--tfs-card, #fed7aa)',
borderLeft: '3px solid var(--tfs-border, #f59e0b)',
color: 'var(--tfs-text, #92400e)',
};
}
return {};
};
// Couleurs de priorité
const getPriorityColor = (priority: string) => {
const colors = {
low: '#10b981', // green
medium: '#f59e0b', // amber
high: '#ef4444', // red
urgent: '#dc2626', // red-600
};
return colors[priority as keyof typeof colors] || colors.medium;
};
// Extraire les emojis du titre
const emojiRegex = /(?:[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}])(?:[\u{200D}][\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE0F}])*/gu;
const titleEmojis = title.match(emojiRegex) || [];
const titleWithoutEmojis = title.replace(emojiRegex, '').trim();
// Si pas d'emoji dans le titre, utiliser l'emoji du premier tag
let displayEmojis: string[] = titleEmojis;
if (displayEmojis.length === 0 && tags && tags.length > 0) {
const firstTag = availableTags.find((tag) => tag.name === tags[0]);
if (firstTag) {
const tagEmojis = firstTag.name.match(emojiRegex);
if (tagEmojis && tagEmojis.length > 0) {
displayEmojis = [tagEmojis[0]]; // Prendre seulement le premier emoji du tag
}
}
}
const sourceStyles = getSourceStyles();
const priorityColor = getPriorityColor(priority);
// Vue compacte
if (variant === 'compact') {
return (
<Card
ref={ref}
className={cn(
'hover:border-[var(--primary)]/30 hover:shadow-lg hover:shadow-[var(--primary)]/10 transition-all duration-300 cursor-pointer group',
isDragging && 'opacity-50 rotate-3 scale-105',
(status === 'done' || status === 'archived') && 'opacity-60',
status === 'freeze' && 'opacity-60 bg-gradient-to-br from-transparent via-[var(--muted)]/10 to-transparent bg-[length:4px_4px] bg-[linear-gradient(45deg,transparent_25%,var(--border)_25%,var(--border)_50%,transparent_50%,transparent_75%,var(--border)_75%,var(--border))]',
isPending && 'opacity-70 pointer-events-none',
className
)}
{...props}
>
<div className="p-2" style={sourceStyles}>
<div className="flex items-center gap-2">
{/* Emojis */}
{displayEmojis.length > 0 && (
<div className="flex gap-1 flex-shrink-0">
{displayEmojis.slice(0, 1).map((emoji, index) => (
<span
key={index}
className="text-base opacity-90 font-emoji"
style={{
fontFamily: 'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
fontVariantEmoji: 'normal',
}}
>
{emoji}
</span>
))}
</div>
)}
{/* Titre ou input d'édition */}
{isEditingTitle ? (
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={handleTitleKeyPress}
onBlur={handleTitleSave}
autoFocus
className={`flex-1 bg-transparent border-none outline-none text-[var(--foreground)] font-mono ${fontClasses.title} font-medium leading-tight`}
/>
) : (
<h4
className={`flex-1 font-mono ${fontClasses.title} font-medium text-[var(--foreground)] leading-tight line-clamp-2 cursor-pointer hover:text-[var(--primary)] transition-colors`}
onClick={handleTitleClick}
title="Cliquer pour éditer"
>
{titleWithoutEmojis}
</h4>
)}
{/* Actions */}
<div className="flex items-center gap-1 flex-shrink-0">
{!isEditingTitle && onEdit && (
<button
onClick={handleEdit}
disabled={isPending}
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded-full bg-[var(--primary)]/20 hover:bg-[var(--primary)]/30 border border-[var(--primary)]/30 hover:border-[var(--primary)]/50 flex items-center justify-center transition-all duration-200 text-[var(--primary)] hover:text-[var(--primary)] text-xs disabled:opacity-50"
title="Modifier la tâche"
>
</button>
)}
{!isEditingTitle && onDelete && (
<button
onClick={handleDelete}
disabled={isPending}
className="opacity-0 group-hover:opacity-100 w-5 h-5 rounded-full bg-[var(--destructive)]/20 hover:bg-[var(--destructive)]/30 border border-[var(--destructive)]/30 hover:border-[var(--destructive)]/50 flex items-center justify-center transition-all duration-200 text-[var(--destructive)] hover:text-[var(--destructive)] text-xs disabled:opacity-50"
title="Supprimer la tâche"
>
×
</button>
)}
{/* Indicateur de priorité */}
<div
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: priorityColor }}
/>
</div>
</div>
</div>
</Card>
);
}
// Vue détaillée
return (
<Card
ref={ref}
className={cn(
'hover:border-[var(--primary)]/30 hover:shadow-lg hover:shadow-[var(--primary)]/10 transition-all duration-300 cursor-pointer group',
isDragging && 'opacity-50 rotate-3 scale-105',
(status === 'done' || status === 'archived') && 'opacity-60',
status === 'freeze' && 'opacity-60 bg-gradient-to-br from-transparent via-[var(--muted)]/10 to-transparent bg-[length:4px_4px] bg-[linear-gradient(45deg,transparent_25%,var(--border)_25%,var(--border)_50%,transparent_50%,transparent_75%,var(--border)_75%,var(--border))]',
isPending && 'opacity-70 pointer-events-none',
className
)}
{...props}
>
<div className={`px-3 pt-3 ${(dueDate || (source && source !== 'manual') || completedAt || (todosCount !== undefined && todosCount > 0)) ? 'pb-2' : 'pb-0'}`} style={sourceStyles}>
{/* Header */}
<div className="flex items-start gap-2 mb-2">
{/* Emojis */}
{displayEmojis.length > 0 && (
<div className="flex gap-1 flex-shrink-0">
{displayEmojis.slice(0, 2).map((emoji, index) => (
<span
key={index}
className="text-sm opacity-80 font-emoji"
style={{
fontFamily: 'Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif',
fontVariantEmoji: 'normal',
}}
>
{emoji}
</span>
))}
</div>
)}
{/* Titre ou input d'édition */}
{isEditingTitle ? (
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={handleTitleKeyPress}
onBlur={handleTitleSave}
autoFocus
className="flex-1 bg-transparent border-none outline-none text-[var(--foreground)] font-mono text-sm font-medium leading-tight"
/>
) : (
<h4
className={`flex-1 font-mono ${fontClasses.title} font-medium text-[var(--foreground)] leading-tight line-clamp-2 cursor-pointer hover:text-[var(--primary)] transition-colors`}
onClick={handleTitleClick}
title="Cliquer pour éditer"
>
{titleWithoutEmojis}
</h4>
)}
{/* Actions */}
<div className="flex items-center gap-1 flex-shrink-0">
{!isEditingTitle && onEdit && (
<button
onClick={handleEdit}
disabled={isPending}
className="opacity-0 group-hover:opacity-100 w-4 h-4 rounded-full bg-[var(--primary)]/20 hover:bg-[var(--primary)]/30 border border-[var(--primary)]/30 hover:border-[var(--primary)]/50 flex items-center justify-center transition-all duration-200 text-[var(--primary)] hover:text-[var(--primary)] text-xs disabled:opacity-50"
title="Modifier la tâche"
>
</button>
)}
{!isEditingTitle && onDelete && (
<button
onClick={handleDelete}
disabled={isPending}
className="opacity-0 group-hover:opacity-100 w-4 h-4 rounded-full bg-[var(--destructive)]/20 hover:bg-[var(--destructive)]/30 border border-[var(--destructive)]/30 hover:border-[var(--destructive)]/50 flex items-center justify-center transition-all duration-200 text-[var(--destructive)] hover:text-[var(--destructive)] text-xs disabled:opacity-50"
title="Supprimer la tâche"
>
×
</button>
)}
{/* Indicateur de priorité */}
<div
className="w-2 h-2 rounded-full animate-pulse shadow-sm"
style={{
backgroundColor: priorityColor,
boxShadow: `0 0 4px ${priorityColor}50`,
}}
/>
</div>
</div>
{/* Description */}
{description && (
<p className={`${fontClasses.description} text-[var(--muted-foreground)] mb-3 line-clamp-1 font-mono`}>
{description}
</p>
)}
{/* Tags avec couleurs personnalisées */}
{tags.length > 0 && (
<div className="mb-3">
<div className="flex flex-wrap gap-1">
{tags.slice(0, 3).map((tag, index) => {
const tagConfig = availableTags.find(t => t.name === tag);
return (
<Badge
key={index}
variant="outline"
size="sm"
style={tagConfig?.color ? {
borderColor: `${tagConfig.color}40`,
color: tagConfig.color
} : undefined}
>
{tag}
</Badge>
);
})}
{tags.length > 3 && (
<Badge variant="outline" size="sm">
+{tags.length - 3}
</Badge>
)}
</div>
</div>
)}
{/* Footer avec métadonnées */}
{(dueDate || (source && source !== 'manual') || completedAt || (todosCount !== undefined && todosCount > 0)) && (
<div className="pt-2 border-t border-[var(--border)]/50">
<div className={`flex items-center justify-between ${fontClasses.meta}`}>
{/* Date d'échéance */}
{dueDate ? (
<span className="flex items-center gap-1 text-[var(--muted-foreground)] font-mono">
<span className="text-[var(--primary)]"></span>
{formatDateForDisplay(dueDate, 'DISPLAY_MEDIUM')}
</span>
) : (
<div></div>
)}
{/* Badges source */}
<div className="flex items-center gap-2">
{/* Jira */}
{source === 'jira' && jiraKey && (
jiraConfig?.baseUrl ? (
<a
href={`${jiraConfig.baseUrl}/browse/${jiraKey}`}
target="_blank"
rel="noopener noreferrer"
className="hover:scale-105 transition-transform"
>
<Badge variant="outline" size="sm" className="hover:bg-blue-500/10 hover:border-blue-400/50 cursor-pointer">
{jiraKey}
</Badge>
</a>
) : (
<Badge variant="outline" size="sm">
{jiraKey}
</Badge>
)
)}
{/* TFS */}
{source === 'tfs' && tfsPullRequestId && tfsProject && tfsRepository && (
tfsConfig?.organizationUrl ? (
<a
href={`${tfsConfig.organizationUrl}/${encodeURIComponent(tfsProject)}/_git/${tfsRepository}/pullrequest/${tfsPullRequestId}`}
target="_blank"
rel="noopener noreferrer"
className="hover:scale-105 transition-transform"
>
<Badge variant="outline" size="sm" className="hover:bg-orange-500/10 hover:border-orange-400/50 cursor-pointer">
PR-{tfsPullRequestId}
</Badge>
</a>
) : (
<Badge variant="outline" size="sm">
PR-{tfsPullRequestId}
</Badge>
)
)}
{/* Projets */}
{jiraProject && (
<Badge variant="outline" size="sm" className="text-blue-400 border-blue-400/30">
{jiraProject}
</Badge>
)}
{tfsRepository && (
<Badge variant="outline" size="sm" className="text-orange-400 border-orange-400/30">
{tfsRepository}
</Badge>
)}
{/* Type Jira */}
{jiraType && (
<Badge variant="outline" size="sm" className="text-purple-400 border-purple-400/30">
{jiraType}
</Badge>
)}
{/* Statut terminé avec date de résolution */}
{completedAt && (
<span className="text-emerald-400 font-mono font-bold flex items-center gap-1">
<span></span>
<span>{formatDateForDisplay(completedAt, 'DISPLAY_SHORT')}</span>
</span>
)}
{/* Nombre de todos reliés */}
{todosCount !== undefined && todosCount > 0 && (
<Badge variant="outline" size="sm" className="text-cyan-400 border-cyan-400/30">
📝 {todosCount}
</Badge>
)}
</div>
</div>
</div>
)}
{/* Contenu personnalisé */}
{children}
</div>
</Card>
);
}
);
TaskCard.displayName = 'TaskCard';
export { TaskCard };