feat(#17): implement Kanban board view
- Drag-and-drop with @dnd-kit - Four status columns (Not Started, In Progress, Paused, Completed) - Task cards with priority badges and due dates - PDA-friendly design (calm colors, gentle language) - 70 tests (87% coverage) - Demo page at /demo/kanban
This commit is contained in:
86
apps/web/src/components/kanban/kanban-column.tsx
Normal file
86
apps/web/src/components/kanban/kanban-column.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import type { Task } from "@mosaic/shared";
|
||||
import { TaskStatus } from "@mosaic/shared";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { TaskCard } from "./task-card";
|
||||
|
||||
interface KanbanColumnProps {
|
||||
status: TaskStatus;
|
||||
title: string;
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
[TaskStatus.NOT_STARTED]: "border-gray-300 dark:border-gray-600",
|
||||
[TaskStatus.IN_PROGRESS]: "border-blue-300 dark:border-blue-600",
|
||||
[TaskStatus.PAUSED]: "border-amber-300 dark:border-amber-600",
|
||||
[TaskStatus.COMPLETED]: "border-green-300 dark:border-green-600",
|
||||
[TaskStatus.ARCHIVED]: "border-gray-400 dark:border-gray-500",
|
||||
};
|
||||
|
||||
const statusBadgeColors = {
|
||||
[TaskStatus.NOT_STARTED]: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
|
||||
[TaskStatus.IN_PROGRESS]: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||||
[TaskStatus.PAUSED]: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
|
||||
[TaskStatus.COMPLETED]: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||
[TaskStatus.ARCHIVED]: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
|
||||
};
|
||||
|
||||
export function KanbanColumn({ status, title, tasks }: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: status,
|
||||
});
|
||||
|
||||
const taskIds = tasks.map((task) => task.id);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={setNodeRef}
|
||||
role="region"
|
||||
aria-label={`${title} tasks`}
|
||||
data-testid={`column-${status}`}
|
||||
className={`
|
||||
flex flex-col
|
||||
bg-gray-50 dark:bg-gray-900
|
||||
rounded-lg border-2
|
||||
p-4 space-y-4
|
||||
min-h-[500px]
|
||||
transition-colors duration-200
|
||||
${statusColors[status]}
|
||||
${isOver ? "bg-gray-100 dark:bg-gray-800 border-opacity-100" : "border-opacity-50"}
|
||||
`}
|
||||
>
|
||||
{/* Column Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{title}
|
||||
</h3>
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center justify-center
|
||||
w-6 h-6 rounded-full text-xs font-medium
|
||||
${statusBadgeColors[status]}
|
||||
`}
|
||||
>
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tasks */}
|
||||
<div className="flex-1 space-y-3 overflow-y-auto">
|
||||
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
|
||||
{tasks.length > 0 ? (
|
||||
tasks.map((task) => <TaskCard key={task.id} task={task} />)
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-32 text-sm text-gray-500 dark:text-gray-400">
|
||||
{/* Empty state - gentle, PDA-friendly */}
|
||||
<p>No tasks here yet</p>
|
||||
</div>
|
||||
)}
|
||||
</SortableContext>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user