'use client'; import { useCallback, useState, type DragEvent } from 'react'; import type { TodoColumn, TodoItem } from '@/lib/types'; const PRIORITY_COLORS: Record = { high: 'bg-red-100 text-red-700', normal: 'bg-yellow-100 text-yellow-700', low: 'bg-gray-100 text-gray-600', }; export function KanbanBoard({ projectId, columns, initialItems, }: { projectId: number; columns: TodoColumn[]; initialItems: TodoItem[]; }) { const [items, setItems] = useState(initialItems); const [draggingId, setDraggingId] = useState(null); const reload = useCallback(async () => { const res = await fetch(`/api/projects/${projectId}/todos/items`); if (res.ok) { const data = (await res.json()) as { items: TodoItem[] }; setItems(data.items); } }, [projectId]); function onDragStart(e: DragEvent, itemId: number) { setDraggingId(itemId); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(itemId)); } function onDrop(e: DragEvent, column: TodoColumn) { e.preventDefault(); const itemId = Number(e.dataTransfer.getData('text/plain')); if (!itemId) return; const item = items.find((i) => i.id === itemId); if (!item || item.columnId === column.id) { setDraggingId(null); return; } // 楽観的にローカル更新したあとAPIで移動 setItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, columnId: column.id } : i)) ); fetch(`/api/projects/${projectId}/todos/items/${itemId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ columnId: column.id, orderIndex: 0 }), }).then(() => reload()); setDraggingId(null); } async function addTask(columnId: number, title: string) { const res = await fetch(`/api/projects/${projectId}/todos/items`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, columnId }), }); if (res.ok) await reload(); } async function toggleComplete(itemId: number) { await fetch(`/api/projects/${projectId}/todos/items/${itemId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ toggleComplete: true }), }); await reload(); } return (
{columns.map((column) => { const colItems = items.filter((i) => i.columnId === column.id); return (
e.preventDefault()} onDrop={(e) => onDrop(e, column)} data-testid={`kanban-column-${column.id}`} data-column-id={column.id} >

{column.name} ({colItems.length})

    {colItems.map((item) => (
  • onDragStart(e, item.id)} className={`cursor-grab rounded border bg-white p-2 shadow-sm ${ draggingId === item.id ? 'opacity-50' : '' } ${item.completedAt ? 'opacity-60 line-through' : ''}`} data-testid={`todo-card-${item.id}`} >
    {item.title}
    {item.priority} {item.dueDate && ( 期限: {item.dueDate} )}
  • ))}
addTask(column.id, title)} />
); })}
); } function NewTaskInput({ onAdd }: { onAdd: (title: string) => void }) { const [title, setTitle] = useState(''); return (
{ e.preventDefault(); if (!title.trim()) return; onAdd(title); setTitle(''); }} > setTitle(e.target.value)} className="w-full rounded border px-2 py-1 text-sm" data-testid="new-task-input" />
); }