Add src/components/KanbanBoard.tsx
This commit is contained in:
171
src/components/KanbanBoard.tsx
Normal file
171
src/components/KanbanBoard.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useApp, type Task } from './AppProvider';
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { format, isToday, isTomorrow, isPast, parseISO } from 'date-fns';
|
||||||
|
|
||||||
|
interface KanbanTask extends Task {
|
||||||
|
projectColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KanbanBoard() {
|
||||||
|
const { tasks, projects, setSelectedTask, setShowEditTaskModal, setEditingTask, refreshTasks } = useApp();
|
||||||
|
const [draggedTask, setDraggedTask] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Add project info
|
||||||
|
const kanbanTasks: KanbanTask[] = tasks.map(task => {
|
||||||
|
const project = projects.find(p => p.id === task.projectId);
|
||||||
|
return { ...task, projectColor: project?.color };
|
||||||
|
});
|
||||||
|
|
||||||
|
const todoTasks = kanbanTasks.filter(t => t.status === 'todo');
|
||||||
|
const inProgressTasks = kanbanTasks.filter(t => t.status === 'in_progress');
|
||||||
|
const doneTasks = kanbanTasks.filter(t => t.status === 'done');
|
||||||
|
|
||||||
|
const columns: { key: string; title: string; tasks: KanbanTask[]; icon: string; color: string }[] = [
|
||||||
|
{ key: 'todo', title: 'To Do', tasks: todoTasks, icon: '📋', color: 'border-gray-300' },
|
||||||
|
{ key: 'in_progress', title: 'In Progress', tasks: inProgressTasks, icon: '🔄', color: 'border-blue-300' },
|
||||||
|
{ key: 'done', title: 'Done', tasks: doneTasks, icon: '✅', color: 'border-green-300' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleDragStart = (taskId: string) => {
|
||||||
|
setDraggedTask(taskId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = async (status: string, e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!draggedTask) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/tasks/${draggedTask}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
refreshTasks();
|
||||||
|
// Refresh kanban data
|
||||||
|
const res = await fetch(`/api/kanban`);
|
||||||
|
const data = await res.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update task status:', error);
|
||||||
|
}
|
||||||
|
setDraggedTask(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTaskClick = (task: KanbanTask) => {
|
||||||
|
setSelectedTask(task);
|
||||||
|
setEditingTask(task);
|
||||||
|
setShowEditTaskModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDueDateInfo = (dueDate: string | null) => {
|
||||||
|
if (!dueDate) return null;
|
||||||
|
const date = parseISO(dueDate);
|
||||||
|
if (isToday(date)) return { label: 'Today', className: 'text-[var(--priority-urgent)] font-medium' };
|
||||||
|
if (isTomorrow(date)) return { label: 'Tomorrow', className: 'text-[var(--priority-high)]' };
|
||||||
|
if (isPast(date)) return { label: format(date, 'MMM d'), className: 'text-[var(--priority-urgent)] font-medium' };
|
||||||
|
return { label: format(date, 'MMM d'), className: 'text-text-secondary' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityConfig = {
|
||||||
|
urgent: { bg: 'bg-[var(--priority-urgent)]/10', text: 'text-[var(--priority-urgent)]' },
|
||||||
|
high: { bg: 'bg-[var(--priority-high)]/10', text: 'text-[var(--priority-high)]' },
|
||||||
|
medium: { bg: 'bg-[var(--priority-medium)]/10', text: 'text-[var(--priority-medium)]' },
|
||||||
|
low: { bg: 'bg-[var(--priority-low)]/10', text: 'text-[var(--priority-low)]' },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-x-auto overflow-y-hidden p-4">
|
||||||
|
<div className="flex gap-4 h-full min-w-[900px]">
|
||||||
|
{columns.map((column) => (
|
||||||
|
<div
|
||||||
|
key={column.key}
|
||||||
|
className="flex-1 flex flex-col bg-content rounded-xl border border-border"
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={(e) => handleDrop(column.key, e)}
|
||||||
|
>
|
||||||
|
{/* Column header */}
|
||||||
|
<div className={`p-3 border-b-2 ${column.color} rounded-t-xl`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{column.icon}</span>
|
||||||
|
<span className="font-semibold text-sm">{column.title}</span>
|
||||||
|
<span className="text-xs text-text-secondary bg-content px-2 py-0.5 rounded-full">
|
||||||
|
{column.tasks.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tasks */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
||||||
|
{column.tasks.map((task) => {
|
||||||
|
const dueInfo = getDueDateInfo(task.dueDate);
|
||||||
|
const prio = priorityConfig[task.priority];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => handleDragStart(task.id)}
|
||||||
|
onClick={() => handleTaskClick(task)}
|
||||||
|
className={`p-3 bg-card border border-border rounded-lg cursor-grab hover:shadow-md transition-all ${
|
||||||
|
draggedTask === task.id ? 'opacity-50 scale-95' : ''
|
||||||
|
} ${task.completed ? 'opacity-60' : ''}`}
|
||||||
|
>
|
||||||
|
{/* Project indicator */}
|
||||||
|
{task.projectColor && (
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: task.projectColor }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<p className={`text-sm font-medium mb-1 ${task.completed ? 'line-through text-text-secondary' : ''}`}>
|
||||||
|
{task.title}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Description preview */}
|
||||||
|
{task.description && (
|
||||||
|
<p className="text-xs text-text-secondary truncate mb-2">{task.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${prio.bg} ${prio.text}`}>
|
||||||
|
{task.priority}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{dueInfo && (
|
||||||
|
<span className={`text-xs ${dueInfo.className}`}>
|
||||||
|
{dueInfo.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.recurrenceRule !== 'none' && (
|
||||||
|
<span title="Recurring">🔄</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{column.tasks.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-text-secondary text-sm">
|
||||||
|
Drop tasks here
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user