feat: enhance Kanban functionality and update TODO.md

- Completed the creation and validation forms for tasks in the Kanban board, improving task management capabilities.
- Integrated new task creation and deletion functionalities in the `KanbanBoard` and `KanbanColumn` components.
- Added quick task addition feature in `Column` component for better user experience.
- Updated `TaskCard` to support task deletion with a new button.
- Marked several tasks as completed in `TODO.md` to reflect the progress on Kanban features.
- Updated TypeScript types to include 'manual' as a new task source.
This commit is contained in:
Julien Froidefond
2025-09-14 08:48:39 +02:00
parent 79f8035d18
commit 0b7e0edb2f
14 changed files with 1056 additions and 37 deletions

View File

@@ -2,13 +2,20 @@
import { Task, TaskStatus } from '@/lib/types';
import { KanbanColumn } from './Column';
import { useMemo } from 'react';
import { Button } from '@/components/ui/Button';
import { CreateTaskForm } from '@/components/forms/CreateTaskForm';
import { CreateTaskData } from '@/clients/tasks-client';
import { useMemo, useState } from 'react';
interface KanbanBoardProps {
tasks: Task[];
onCreateTask?: (data: CreateTaskData) => Promise<Task | null>;
onDeleteTask?: (taskId: string) => Promise<void>;
loading?: boolean;
}
export function KanbanBoard({ tasks }: KanbanBoardProps) {
export function KanbanBoard({ tasks, onCreateTask, onDeleteTask, loading = false }: KanbanBoardProps) {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
// Organiser les tâches par statut
const tasksByStatus = useMemo(() => {
const grouped = tasks.reduce((acc, task) => {
@@ -55,8 +62,34 @@ export function KanbanBoard({ tasks }: KanbanBoardProps) {
}
];
const handleCreateTask = async (data: CreateTaskData) => {
if (onCreateTask) {
await onCreateTask(data);
}
};
return (
<div className="h-full flex flex-col bg-slate-950">
{/* Header avec bouton nouvelle tâche */}
<div className="flex justify-between items-center p-6 pb-0">
<div className="flex items-center gap-3">
<div className="w-2 h-2 bg-cyan-400 rounded-full animate-pulse"></div>
<h2 className="text-lg font-mono font-bold text-slate-100 uppercase tracking-wider">
Kanban Board
</h2>
</div>
{onCreateTask && (
<Button
variant="primary"
onClick={() => setIsCreateModalOpen(true)}
disabled={loading}
>
+ Nouvelle tâche
</Button>
)}
</div>
{/* Board tech dark */}
<div className="flex-1 flex gap-6 overflow-x-auto p-6">
{columns.map((column) => (
@@ -66,9 +99,21 @@ export function KanbanBoard({ tasks }: KanbanBoardProps) {
title={column.title}
color={column.color}
tasks={column.tasks}
onCreateTask={onCreateTask ? handleCreateTask : undefined}
onDeleteTask={onDeleteTask}
/>
))}
</div>
{/* Modal de création */}
{onCreateTask && (
<CreateTaskForm
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSubmit={handleCreateTask}
loading={loading}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,32 @@
'use client';
import { KanbanBoard } from './Board';
import { useTasks } from '@/hooks/useTasks';
import { Task } from '@/lib/types';
interface BoardContainerProps {
initialTasks: Task[];
initialStats: {
total: number;
completed: number;
inProgress: number;
todo: number;
completionRate: number;
};
}
export function KanbanBoardContainer({ initialTasks, initialStats }: BoardContainerProps) {
const { tasks, loading, createTask, deleteTask } = useTasks(
{ limit: 20 },
{ tasks: initialTasks, stats: initialStats }
);
return (
<KanbanBoard
tasks={tasks}
onCreateTask={createTask}
onDeleteTask={deleteTask}
loading={loading}
/>
);
}

View File

@@ -1,16 +1,22 @@
import { Task, TaskStatus } from '@/lib/types';
import { TaskCard } from './TaskCard';
import { QuickAddTask } from './QuickAddTask';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { CreateTaskData } from '@/clients/tasks-client';
import { useState } from 'react';
interface KanbanColumnProps {
id: TaskStatus;
title: string;
color: string;
tasks: Task[];
onCreateTask?: (data: CreateTaskData) => Promise<void>;
onDeleteTask?: (taskId: string) => Promise<void>;
}
export function KanbanColumn({ id, title, color, tasks }: KanbanColumnProps) {
export function KanbanColumn({ id, title, color, tasks, onCreateTask, onDeleteTask }: KanbanColumnProps) {
const [showQuickAdd, setShowQuickAdd] = useState(false);
// Couleurs tech/cyberpunk
const techStyles = {
gray: {
@@ -62,15 +68,38 @@ export function KanbanColumn({ id, title, color, tasks }: KanbanColumnProps) {
{title}
</h3>
</div>
<Badge variant={badgeVariant} size="sm">
{String(tasks.length).padStart(2, '0')}
</Badge>
<div className="flex items-center gap-2">
<Badge variant={badgeVariant} size="sm">
{String(tasks.length).padStart(2, '0')}
</Badge>
{onCreateTask && (
<button
onClick={() => setShowQuickAdd(true)}
className={`w-5 h-5 rounded-full border border-dashed ${style.border} ${style.accent} hover:bg-slate-800/50 transition-colors flex items-center justify-center text-xs font-mono`}
title="Ajouter une tâche rapide"
>
+
</button>
)}
</div>
</div>
</CardHeader>
<CardContent className="flex-1 p-4 h-[calc(100vh-220px)] overflow-y-auto">
<div className="space-y-3">
{tasks.length === 0 ? (
{/* Quick Add Task */}
{showQuickAdd && onCreateTask && (
<QuickAddTask
status={id}
onSubmit={async (data) => {
await onCreateTask(data);
// Ne pas fermer automatiquement pour permettre la création en série
}}
onCancel={() => setShowQuickAdd(false)}
/>
)}
{tasks.length === 0 && !showQuickAdd ? (
<div className="text-center py-20">
<div className={`w-16 h-16 mx-auto mb-4 rounded-full bg-slate-800 border-2 border-dashed ${style.border} flex items-center justify-center`}>
<span className={`text-2xl ${style.accent} opacity-50`}>{techIcons[id]}</span>
@@ -82,7 +111,7 @@ export function KanbanColumn({ id, title, color, tasks }: KanbanColumnProps) {
</div>
) : (
tasks.map((task) => (
<TaskCard key={task.id} task={task} />
<TaskCard key={task.id} task={task} onDelete={onDeleteTask} />
))
)}
</div>

View File

@@ -0,0 +1,225 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { TaskStatus, TaskPriority } from '@/lib/types';
import { CreateTaskData } from '@/clients/tasks-client';
interface QuickAddTaskProps {
status: TaskStatus;
onSubmit: (data: CreateTaskData) => Promise<void>;
onCancel: () => void;
}
export function QuickAddTask({ status, onSubmit, onCancel }: QuickAddTaskProps) {
const [formData, setFormData] = useState<CreateTaskData>({
title: '',
description: '',
status,
priority: 'medium' as TaskPriority,
tags: [],
dueDate: undefined
});
const [tagInput, setTagInput] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeField, setActiveField] = useState<'title' | 'description' | 'tags' | 'date' | null>('title');
const titleRef = useRef<HTMLInputElement>(null);
// Focus automatique sur le titre
useEffect(() => {
titleRef.current?.focus();
}, []);
const handleSubmit = async () => {
const trimmedTitle = formData.title.trim();
console.log('handleSubmit called:', { trimmedTitle, isSubmitting });
if (!trimmedTitle || isSubmitting) return;
setIsSubmitting(true);
try {
console.log('Submitting task:', { ...formData, title: trimmedTitle });
await onSubmit({
...formData,
title: trimmedTitle
});
// Réinitialiser pour la prochaine tâche
setFormData({
title: '',
description: '',
status,
priority: 'medium',
tags: [],
dueDate: undefined
});
setTagInput('');
setActiveField('title');
setIsSubmitting(false);
titleRef.current?.focus();
} catch (error) {
console.error('Erreur lors de la création:', error);
setIsSubmitting(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent, field: string) => {
console.log('Key pressed:', e.key, 'field:', field, 'title:', formData.title);
// Seulement intercepter les touches spécifiques qu'on veut gérer
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
if (field === 'title' && formData.title.trim()) {
console.log('Calling handleSubmit from title');
handleSubmit();
} else if (field === 'tags' && tagInput.trim()) {
console.log('Adding tag');
addTag();
} else if (formData.title.trim()) {
// Permettre création depuis n'importe quel champ si titre rempli
console.log('Calling handleSubmit from other field');
handleSubmit();
}
} else if (e.key === 'Escape') {
e.preventDefault();
onCancel();
} else if (e.key === 'Tab' && !e.metaKey && !e.ctrlKey) {
// Navigation entre les champs seulement si pas de modificateur
e.preventDefault();
const fields = ['title', 'description', 'tags', 'date'];
const currentIndex = fields.indexOf(activeField || 'title');
const nextField = fields[(currentIndex + 1) % fields.length] as typeof activeField;
setActiveField(nextField);
}
// Laisser passer tous les autres événements (y compris les raccourcis système)
};
const addTag = () => {
const tag = tagInput.trim();
if (tag && !formData.tags?.includes(tag)) {
setFormData(prev => ({
...prev,
tags: [...(prev.tags || []), tag]
}));
setTagInput('');
}
};
const removeTag = (tagToRemove: string) => {
setFormData(prev => ({
...prev,
tags: prev.tags?.filter(tag => tag !== tagToRemove) || []
}));
};
const handleBlur = (e: React.FocusEvent) => {
// Vérifier si le focus reste dans le composant
setTimeout(() => {
const currentTarget = e.currentTarget;
const relatedTarget = e.relatedTarget as Node | null;
// Si le focus sort complètement du composant ET qu'il n'y a pas de titre
if (currentTarget && (!relatedTarget || !currentTarget.contains(relatedTarget)) && !formData.title.trim()) {
onCancel();
}
}, 100);
};
return (
<div onBlur={handleBlur}>
<Card className="p-3 border-dashed border-cyan-500/30 bg-slate-800/50 hover:border-cyan-500/50 transition-all duration-300">
{/* Header avec titre et priorité */}
<div className="flex items-start gap-2 mb-2">
<input
ref={titleRef}
type="text"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
onKeyDown={(e) => handleKeyDown(e, 'title')}
onFocus={() => setActiveField('title')}
placeholder="Titre de la tâche..."
disabled={isSubmitting}
className="flex-1 bg-transparent border-none outline-none text-slate-100 font-mono text-sm font-medium placeholder-slate-500 leading-tight"
/>
{/* Indicateur de priorité */}
<select
value={formData.priority}
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value as TaskPriority }))}
disabled={isSubmitting}
className="bg-transparent border-none outline-none text-xs font-mono text-slate-400"
>
<option value="low">L</option>
<option value="medium">M</option>
<option value="high">H</option>
<option value="urgent">U</option>
</select>
</div>
{/* Description */}
<textarea
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
onKeyDown={(e) => handleKeyDown(e, 'description')}
onFocus={() => setActiveField('description')}
placeholder="Description..."
rows={2}
disabled={isSubmitting}
className="w-full bg-transparent border-none outline-none text-xs text-slate-400 font-mono placeholder-slate-500 resize-none mb-2"
/>
{/* Tags */}
<div className="mb-2">
<div className="flex flex-wrap gap-1 mb-1">
{formData.tags && formData.tags.map((tag: string, index: number) => (
<Badge
key={index}
variant="primary"
size="sm"
className="cursor-pointer hover:bg-red-950/50 hover:text-red-300 hover:border-red-500/30 transition-colors"
onClick={() => removeTag(tag)}
>
{tag}
</Badge>
))}
</div>
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => handleKeyDown(e, 'tags')}
onFocus={() => setActiveField('tags')}
placeholder="Tags..."
disabled={isSubmitting}
className="w-full bg-transparent border-none outline-none text-xs text-slate-400 font-mono placeholder-slate-500"
/>
</div>
{/* Footer avec date et actions */}
<div className="pt-2 border-t border-slate-700/50">
<div className="flex items-center justify-between text-xs min-w-0">
<input
type="datetime-local"
value={formData.dueDate ? new Date(formData.dueDate.getTime() - formData.dueDate.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''}
onChange={(e) => setFormData(prev => ({
...prev,
dueDate: e.target.value ? new Date(e.target.value) : undefined
}))}
onFocus={() => setActiveField('date')}
disabled={isSubmitting}
className="bg-transparent border-none outline-none text-slate-400 font-mono text-xs flex-shrink min-w-0"
/>
{isSubmitting && (
<div className="flex items-center gap-1 text-cyan-400 font-mono text-xs flex-shrink-0">
<div className="w-3 h-3 border border-cyan-400 border-t-transparent rounded-full animate-spin"></div>
<span>...</span>
</div>
)}
</div>
</div>
</Card>
</div>
);
}

View File

@@ -6,9 +6,17 @@ import { Badge } from '@/components/ui/Badge';
interface TaskCardProps {
task: Task;
onDelete?: (taskId: string) => Promise<void>;
}
export function TaskCard({ task }: TaskCardProps) {
export function TaskCard({ task, onDelete }: TaskCardProps) {
const handleDelete = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (onDelete) {
await onDelete(task.id);
}
};
// Extraire les emojis du titre pour les afficher comme tags visuels
const emojiRegex = /[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu;
const emojis = task.title.match(emojiRegex) || [];
@@ -33,12 +41,25 @@ export function TaskCard({ task }: TaskCardProps) {
{titleWithoutEmojis}
</h4>
{/* Indicateur de priorité tech */}
<div className={`w-2 h-2 rounded-full flex-shrink-0 mt-1 animate-pulse ${
task.priority === 'high' ? 'bg-red-400 shadow-red-400/50 shadow-sm' :
task.priority === 'medium' ? 'bg-yellow-400 shadow-yellow-400/50 shadow-sm' :
'bg-slate-500'
}`} />
<div className="flex items-center gap-2 flex-shrink-0">
{/* Bouton de suppression discret */}
{onDelete && (
<button
onClick={handleDelete}
className="opacity-0 group-hover:opacity-100 w-4 h-4 rounded-full bg-red-900/50 hover:bg-red-800/80 border border-red-500/30 hover:border-red-400/50 flex items-center justify-center transition-all duration-200 text-red-400 hover:text-red-300 text-xs"
title="Supprimer la tâche"
>
×
</button>
)}
{/* Indicateur de priorité tech */}
<div className={`w-2 h-2 rounded-full animate-pulse ${
task.priority === 'high' ? 'bg-red-400 shadow-red-400/50 shadow-sm' :
task.priority === 'medium' ? 'bg-yellow-400 shadow-yellow-400/50 shadow-sm' :
'bg-slate-500'
}`} />
</div>
</div>
{/* Description tech */}
@@ -84,6 +105,12 @@ export function TaskCard({ task }: TaskCardProps) {
<span className="text-slate-600 font-mono">--:--</span>
)}
{task.source !== 'manual' && task.source && (
<Badge variant="outline" size="sm">
{task.source}
</Badge>
)}
{task.completedAt && (
<span className="text-emerald-400 font-mono font-bold"> DONE</span>
)}