- 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.
549 lines
20 KiB
TypeScript
549 lines
20 KiB
TypeScript
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 }; |