'use client'; import { useCallback, useRef, useState, type DragEvent } from 'react'; import type { TodoColumn, TodoItem } from '@/lib/types'; import type { ProjectMemberWithUser } from '@/repositories/ProjectMemberRepository'; import { TodoDialog } from '@/components/todo/TodoDialog'; const PRIORITY_COLORS: Record = { high: 'bg-red-100 text-red-700', normal: 'bg-yellow-100 text-yellow-700', low: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300', }; /** 同一 orderIndex は id で安定ソート(createdAt 同着対策) */ function byOrder(a: TodoItem, b: TodoItem): number { return a.orderIndex - b.orderIndex || a.id - b.id; } export function KanbanBoard({ projectId, columns, initialItems, members, }: { projectId: number; columns: TodoColumn[]; initialItems: TodoItem[]; members: ProjectMemberWithUser[]; }) { const [items, setItems] = useState(initialItems); const [draggingId, setDraggingId] = useState(null); const [selectedItem, setSelectedItem] = useState(null); const [error, setError] = useState(null); // ドラッグ直後の誤クリック(ダイアログを開く)を抑制するためのタイムスタンプ const lastDragEndAtRef = useRef(0); 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 applyReorder( prev: TodoItem[], destColumnId: number, newOrderIds: number[] ): TodoItem[] { const order = new Map(newOrderIds.map((id, idx) => [id, idx])); return prev.map((i) => order.has(i.id) ? { ...i, columnId: destColumnId, orderIndex: order.get(i.id)! } : i ); } function columnItemIds(columnId: number, excludeId?: number): number[] { return items .filter((i) => i.columnId === columnId && i.id !== excludeId) .sort((a, b) => a.orderIndex - b.orderIndex || a.id - b.id) .map((i) => i.id); } async function persistReorder( destColumnId: number, newOrderIds: number[] ): Promise { setItems((prev) => applyReorder(prev, destColumnId, newOrderIds)); setError(null); try { const res = await fetch( `/api/projects/${projectId}/todos/items/reorder`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ columnId: destColumnId, itemIds: newOrderIds, }), } ); if (!res.ok) { setError('並べ替えに失敗しました'); } } catch { setError('並べ替えに失敗しました'); } finally { await reload(); } setDraggingId(null); } // カラムの空き領域へのドロップ = 末尾に追加 function onDrop(e: DragEvent, column: TodoColumn) { e.preventDefault(); const itemId = Number(e.dataTransfer.getData('text/plain')); if (!itemId) { setDraggingId(null); return; } const newOrderIds = [...columnItemIds(column.id, itemId), itemId]; void persistReorder(column.id, newOrderIds); } // カード上へのドロップ = ポインタ位置で前後に挿入 function dropOnItem(e: DragEvent, target: TodoItem) { e.preventDefault(); e.stopPropagation(); const itemId = Number(e.dataTransfer.getData('text/plain')); if (!itemId || itemId === target.id) { setDraggingId(null); return; } const rect = e.currentTarget.getBoundingClientRect(); const before = e.clientY - rect.top < rect.height / 2; const ordered = columnItemIds(target.columnId, itemId); const insertAt = before ? ordered.indexOf(target.id) : ordered.indexOf(target.id) + 1; ordered.splice(insertAt, 0, itemId); void persistReorder(target.columnId, ordered); } 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 ( <> {error && (

{error}

)}
{columns.map((column) => { const colItems = items .filter((i) => i.columnId === column.id) .sort(byOrder); 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) => { const tags = item.tags ?.split(',') .map((t) => t.trim()) .filter(Boolean); const assignee = members.find( (m) => m.user.id === item.assigneeId ); return (
  • onDragStart(e, item.id)} onDragEnd={() => { lastDragEndAtRef.current = Date.now(); setDraggingId(null); }} onDragOver={(e) => e.preventDefault()} onDrop={(e) => dropOnItem(e, item)} onClick={() => { // ドラッグ直後の誤クリックでダイアログが開かないようにする if (Date.now() - lastDragEndAtRef.current < 300) return; setSelectedItem(item); }} className={`cursor-grab rounded border bg-white dark:bg-gray-800 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} )} {item.startDate && ( 開始: {item.startDate} )} {assignee && ( @{assignee.user.name} )}
    {tags && tags.length > 0 && (
    {tags.map((t) => ( {t} ))}
    )}
  • ); })}
addTask(column.id, title)} />
); })} {selectedItem && ( setSelectedItem(null)} onSaved={reload} /> )}
); } 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" />
); }