実装フェーズ2:高度な機能
ドラッグ&ドロップ、リアルタイム同期など、高度な機能をClaude Codeで実装します。
Phase 2 で実装する機能
1
dnd-kitによるドラッグ&ドロップ
2
WebSocketリアルタイム同期
3
楽観的更新(Optimistic Update)
4
チームメンバー招待機能
ステップ1:dnd-kitによるドラッグ&ドロップ
Claude Codeへの指示
@dnd-kit/core と @dnd-kit/sortable を使って、カンバンボードの
ドラッグ&ドロップを実装してください。
【要件】
- 3つのカラム(todo / in_progress / done)間でタスクを移動
- 同一カラム内でタスクの順序を変更可能
- ドロップ時にバックエンドAPIを呼んでステータスと順序を更新
- ドラッグ中のビジュアルフィードバック(半透明+シャドウ)
- モバイルでもタッチ操作でドラッグ可能
【技術スタック】
- @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities
- 既存の TanStack Query によるタスク取得と連携
- PATCH /tasks/{id} でステータス・順序を更新
段階的に実装してください:
1. まずパッケージインストールと基本構造
2. カラム間のドラッグ&ドロップ
3. カラム内の並び替え
4. API連携とエラーハンドリング
Claude Codeが行うこと
☑
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities を実行☑
frontend/src/components/kanban/KanbanBoard.tsx を作成☑
frontend/src/components/kanban/SortableColumn.tsx を作成☑
frontend/src/components/kanban/SortableTaskCard.tsx を作成☑
frontend/src/hooks/useTaskDragDrop.ts を作成☑ 既存の
ProjectPage.tsx にカンバンボードを統合完成コード:KanbanBoard コンポーネント
// frontend/src/components/kanban/KanbanBoard.tsx import { DndContext, DragOverlay, closestCorners, PointerSensor, TouchSensor, useSensors, useSensor } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { useState } from 'react'; import { SortableColumn } from './SortableColumn'; import { TaskCard } from './TaskCard'; import { useTaskDragDrop } from '@/hooks/useTaskDragDrop'; const COLUMNS = [ { id: 'todo', title: 'Todo', color: 'bg-gray-100' }, { id: 'in_progress', title: 'In Progress', color: 'bg-blue-50' }, { id: 'done', title: 'Done', color: 'bg-green-50' }, ]; export function KanbanBoard({ projectId }: { projectId: string }) { const { tasks, handleDragStart, handleDragOver, handleDragEnd, activeTask } = useTaskDragDrop(projectId); // マウスとタッチの両方に対応 const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 }, }), useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 }, }), ); return ( <DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd} > <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> {COLUMNS.map((column) => ( <SortableColumn key={column.id} column={column} tasks={tasks.filter(t => t.status === column.id)} /> ))} </div> {/* ドラッグ中のオーバーレイ表示 */} <DragOverlay> {activeTask ? <TaskCard task={activeTask} isDragging /> : null} </DragOverlay> </DndContext> ); }
完成コード:SortableTaskCard コンポーネント
// frontend/src/components/kanban/SortableTaskCard.tsx
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { TaskCard } from './TaskCard';
import type { Task } from '@/types';
export function SortableTaskCard({ task }: { task: Task }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: task.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<TaskCard task={task} isDragging={isDragging} />
</div>
);
}
✓ 動作確認
- ブラウザで
http://localhost:3000を開く - プロジェクトを選択してカンバンボードを表示
- タスクカードをドラッグして別のカラムにドロップ
- ステータスが変更されることを確認
- 同じカラム内でタスクの順序を入れ替え
- ページをリロードして変更が永続化されていることを確認
注意点
- ・@dnd-kit/sortable は SortableContext の中でのみ動作します
- ・activationConstraint を設定しないと、クリックイベントとドラッグが競合します
- ・DragOverlay を使うと、ドラッグ中の要素が元の位置から分離して表示されます
ステップ2:WebSocketリアルタイム同期
Claude Codeへの指示
WebSocketでリアルタイム同期を実装してください。
【バックエンド要件】
- FastAPIのWebSocketエンドポイント(/ws/{project_id})
- Redis Pub/Sub でメッセージをブロードキャスト
- ConnectionManager クラスでルーム管理(プロジェクト単位)
- 接続/切断時のユーザー通知
- JWTトークンによるWebSocket認証
【フロントエンド要件】
- useWebSocket カスタムフックを作成
- 自動再接続ロジック(指数バックオフ)
- WebSocketメッセージ受信時に TanStack Query のキャッシュを無効化
- 接続状態のインジケーター表示
【メッセージ形式】
{
"type": "task_created" | "task_updated" | "task_deleted",
"payload": { ... },
"user_id": "送信者ID"
}
Claude Codeが行うこと
☑
backend/app/websocket/manager.py を作成(ConnectionManager)☑
backend/app/websocket/routes.py を作成(WebSocketエンドポイント)☑
backend/app/services/notification.py を作成(Redis Pub/Sub)☑
frontend/src/hooks/useWebSocket.ts を作成☑
frontend/src/components/ConnectionStatus.tsx を作成☑ 既存のタスクCRUDにWebSocket通知を追加
完成コード:ConnectionManager(バックエンド)
# backend/app/websocket/manager.py from fastapi import WebSocket from typing import Dict, Set import json class ConnectionManager: """プロジェクト単位のWebSocket接続を管理""" def __init__(self): # { project_id: { user_id: WebSocket } } self.rooms: Dict[str, Dict[str, WebSocket]] = {} async def connect( self, websocket: WebSocket, project_id: str, user_id: str ): await websocket.accept() if project_id not in self.rooms: self.rooms[project_id] = {} self.rooms[project_id][user_id] = websocket # 他のメンバーに参加を通知 await self.broadcast(project_id, { "type": "user_joined", "payload": {"user_id": user_id}, }, exclude=user_id) async def disconnect(self, project_id: str, user_id: str): if project_id in self.rooms: self.rooms[project_id].pop(user_id, None) if not self.rooms[project_id]: del self.rooms[project_id] # 他のメンバーに退出を通知 await self.broadcast(project_id, { "type": "user_left", "payload": {"user_id": user_id}, }) async def broadcast( self, project_id: str, message: dict, exclude: str = None ): """プロジェクト内の全接続にメッセージを送信""" if project_id not in self.rooms: return for uid, ws in self.rooms[project_id].items(): if uid != exclude: try: await ws.send_json(message) except Exception: # 送信失敗時は切断扱い await self.disconnect(project_id, uid) manager = ConnectionManager()
完成コード:useWebSocket フック(フロントエンド)
// frontend/src/hooks/useWebSocket.ts import { useEffect, useRef, useCallback, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; type ConnectionState = 'connecting' | 'connected' | 'disconnected'; export function useWebSocket(projectId: string, token: string) { const wsRef = useRef<WebSocket | null>(null); const queryClient = useQueryClient(); const [state, setState] = useState<ConnectionState>('disconnected'); const retryCount = useRef(0); const connect = useCallback(() => { const ws = new WebSocket( `ws://localhost:8000/ws/${projectId}?token=${token}` ); ws.onopen = () => { setState('connected'); retryCount.current = 0; }; ws.onmessage = (event) => { const data = JSON.parse(event.data); // メッセージタイプに応じてキャッシュを無効化 switch (data.type) { case 'task_created': case 'task_updated': case 'task_deleted': queryClient.invalidateQueries({ queryKey: ['tasks', projectId], }); break; case 'user_joined': case 'user_left': queryClient.invalidateQueries({ queryKey: ['members', projectId], }); break; } }; ws.onclose = () => { setState('disconnected'); // 指数バックオフで再接続 const delay = Math.min(1000 * 2 ** retryCount.current, 30000); retryCount.current += 1; setTimeout(connect, delay); }; wsRef.current = ws; }, [projectId, token, queryClient]); useEffect(() => { connect(); return () => wsRef.current?.close(); }, [connect]); return { state, ws: wsRef.current }; }
✓ 動作確認
- ブラウザで2つのタブを開き、同じプロジェクトページを表示
- タブAでタスクを新規作成
- タブBにリアルタイムでタスクが表示されることを確認
- タブBでタスクのステータスを変更し、タブAに反映されることを確認
- 接続状態インジケーターが「接続中」と表示されていることを確認
- バックエンドを一時停止し、再起動後に自動再接続されることを確認
よくあるエラー
- ・WebSocket connection failed - CORSの設定を確認。FastAPIの
allow_originsにフロントエンドのURLを追加 - ・Token validation error - WebSocketのクエリパラメータでトークンを渡す場合、HTTPヘッダーは使えないため注意
- ・メモリリーク - コンポーネントのアンマウント時に
ws.close()を忘れずに呼ぶ
ステップ3:楽観的更新(Optimistic Update)
楽観的更新とは?
APIレスポンスを待たずにUIを即座に更新し、エラーが発生した場合のみ元の状態にロールバックする手法です。ドラッグ&ドロップなど、即座のフィードバックが重要な操作で効果を発揮します。
通常の更新
操作 → API通信 → レスポンス → UI更新
体感:遅い(200-500ms待ち)
楽観的更新
操作 → UI即更新 → API通信 → 確認/ロールバック
体感:瞬時(0ms待ち)
Claude Codeへの指示
TanStack Query を使った楽観的更新を実装してください。 【対象操作】 - タスクのステータス変更(ドラッグ&ドロップ連動) - タスクのタイトル・説明の編集 - タスクの削除 【要件】 - useMutation の onMutate でキャッシュを即時更新 - onError で前の状態にロールバック - onSettled で最新データを再取得 - エラー時はトースト通知でユーザーに知らせる - 自分の操作はWebSocket通知を無視(二重更新防止)
Claude Codeが行うこと
☑
frontend/src/hooks/useUpdateTask.ts を作成☑
frontend/src/hooks/useDeleteTask.ts を作成☑ 既存の
useTaskDragDrop.ts に楽観的更新を統合☑ トースト通知コンポーネントを追加
完成コード:楽観的更新付き useMutation
// frontend/src/hooks/useUpdateTask.ts import { useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from '@/lib/api'; import { toast } from '@/components/Toast'; import type { Task } from '@/types'; export function useUpdateTask(projectId: string) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (data: { taskId: string; updates: Partial<Task> }) => api.patch(`/tasks/${data.taskId}`, data.updates), // 楽観的更新:APIレスポンス前にキャッシュを更新 onMutate: async (data) => { // 進行中のクエリをキャンセル(競合防止) await queryClient.cancelQueries({ queryKey: ['tasks', projectId], }); // 現在のキャッシュを保存(ロールバック用) const previousTasks = queryClient.getQueryData<Task[]>( ['tasks', projectId] ); // キャッシュを即座に更新 queryClient.setQueryData<Task[]>( ['tasks', projectId], (old) => old?.map((task) => task.id === data.taskId ? { ...task, ...data.updates } : task ) ?? [] ); return { previousTasks }; }, // エラー時:保存した状態にロールバック onError: (err, data, context) => { if (context?.previousTasks) { queryClient.setQueryData( ['tasks', projectId], context.previousTasks ); } toast.error('タスクの更新に失敗しました'); }, // 完了時(成功/失敗問わず):最新データを再取得 onSettled: () => { queryClient.invalidateQueries({ queryKey: ['tasks', projectId], }); }, }); }
✓ 動作確認
- タスクをドラッグ&ドロップし、即座にUIが更新されることを確認
- DevToolsの Network タブを開き、API通信中もUIが先に反応していることを確認
- バックエンドを停止した状態でタスクをドラッグし、エラー後に元の位置に戻ることを確認
- エラー時にトースト通知が表示されることを確認
注意点
- ・cancelQueries を忘れると、古いデータで上書きされる競合が発生します
- ・WebSocketとの二重更新 - 自分が操作したタスクのWebSocket通知は無視するロジックが必要です
- ・複数のmutation が同時に走る場合、onMutate で保存するキャッシュが古くなる可能性があります
ステップ4:チームメンバー招待機能
Claude Codeへの指示
プロジェクトにチームメンバーを招待する機能を実装してください。
【バックエンドAPI】
- POST /projects/{id}/invite
- メールアドレスで招待(既存ユーザーは即追加、未登録は招待メール送信)
- ロール指定(admin / member / viewer)
- GET /projects/{id}/members
- メンバー一覧を取得(ロール、参加日を含む)
- DELETE /projects/{id}/members/{user_id}
- メンバーを削除(adminのみ可)
- PATCH /projects/{id}/members/{user_id}
- ロールを変更(adminのみ可)
【フロントエンド】
- メンバー管理モーダル(プロジェクト設定から開く)
- メールアドレス入力+ロール選択の招待フォーム
- メンバー一覧(アバター、名前、ロール、参加日)
- ロール変更ドロップダウン
- メンバー削除(確認ダイアログ付き)
【権限制御】
- adminのみ招待・削除・ロール変更が可能
- memberはタスクのCRUDが可能
- viewerは閲覧のみ
Claude Codeが行うこと
☑
backend/app/models/project_member.py を作成☑
backend/app/api/members.py を作成(APIエンドポイント)☑
backend/app/services/invite.py を作成(招待ロジック)☑
frontend/src/components/members/MemberModal.tsx を作成☑
frontend/src/components/members/InviteForm.tsx を作成☑
frontend/src/components/members/MemberList.tsx を作成☑ DBマイグレーションファイルを生成
完成コード:招待APIエンドポイント
# backend/app/api/members.py
from fastapi import APIRouter, Depends, HTTPException
from app.models.project_member import ProjectMember, Role
from app.services.invite import InviteService
from app.auth.deps import get_current_user, require_admin
router = APIRouter()
@router.post("/projects/{project_id}/invite")
async def invite_member(
project_id: str,
invite: InviteRequest,
current_user = Depends(require_admin),
invite_service: InviteService = Depends(),
):
"""メンバーを招待"""
result = await invite_service.invite(
project_id=project_id,
email=invite.email,
role=invite.role,
invited_by=current_user.id,
)
return result
@router.get("/projects/{project_id}/members")
async def list_members(
project_id: str,
current_user = Depends(get_current_user),
):
"""メンバー一覧を取得"""
members = await ProjectMember.filter(
project_id=project_id
).prefetch_related("user").all()
return [
{
"id": m.user.id,
"name": m.user.name,
"email": m.user.email,
"role": m.role,
"joined_at": m.created_at,
}
for m in members
]
@router.delete("/projects/{project_id}/members/{user_id}")
async def remove_member(
project_id: str,
user_id: str,
current_user = Depends(require_admin),
):
"""メンバーを削除"""
if user_id == current_user.id:
raise HTTPException(400, "自分自身は削除できません")
deleted = await ProjectMember.filter(
project_id=project_id, user_id=user_id
).delete()
if not deleted:
raise HTTPException(404, "メンバーが見つかりません")
return {"message": "メンバーを削除しました"}
完成コード:MemberList コンポーネント
// frontend/src/components/members/MemberList.tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import type { Member, Role } from '@/types';
const ROLE_LABELS: Record<Role, string> = {
admin: '管理者',
member: 'メンバー',
viewer: '閲覧者',
};
export function MemberList({
projectId, isAdmin,
}: {
projectId: string;
isAdmin: boolean;
}) {
const queryClient = useQueryClient();
const { data: members = [] } = useQuery({
queryKey: ['members', projectId],
queryFn: () =>
api.get<Member[]>(`/projects/${projectId}/members`),
});
const removeMutation = useMutation({
mutationFn: (userId: string) =>
api.delete(`/projects/${projectId}/members/${userId}`),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['members', projectId],
});
},
});
return (
<div className="divide-y">
{members.map((member) => (
<div key={member.id}
className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-purple-100
rounded-full flex items-center
justify-center text-sm font-bold
text-purple-600">
{member.name[0]}
</div>
<div>
<p className="font-medium">{member.name}</p>
<p className="text-sm text-gray-500">
{member.email}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm px-2 py-1 bg-gray-100
rounded">
{ROLE_LABELS[member.role]}
</span>
{isAdmin && member.role !== 'admin' && (
<button
onClick={() => removeMutation.mutate(member.id)}
className="text-red-500 text-sm hover:underline"
>
削除
</button>
)}
</div>
</div>
))}
</div>
);
}
✓ 動作確認
- プロジェクト設定からメンバー管理モーダルを開く
- メールアドレスを入力してメンバーを招待
- 招待されたユーザーがメンバー一覧に表示されることを確認
- ロールの変更が正しく動作することを確認
- メンバーの削除が確認ダイアログ付きで動作することを確認
- viewer ロールのユーザーでログインし、タスクの編集ができないことを確認
- member ロールのユーザーでログインし、招待・削除ができないことを確認
注意点
- ・自分自身の削除防止 - adminが自分を削除するとプロジェクトが管理不能になります
- ・最後のadmin保護 - プロジェクトに最低1人のadminが必要です
- ・フロントエンドの権限表示 - APIの権限チェックだけでなく、UIでもボタンの表示/非表示を切り替えましょう
Phase 2 まとめ
- ✓ドラッグ&ドロップ - dnd-kitでカンバンボードの操作性を向上
- ✓リアルタイム同期 - WebSocket + Redis Pub/Sub でチーム全体に即時反映
- ✓楽観的更新 - TanStack Query の onMutate/onError パターンで瞬時のフィードバック
- ✓チーム管理 - ロールベースのアクセス制御でセキュアなコラボレーション
Claude Codeの活用ポイント
- ・複数ファイルにまたがる機能は、要件を明確にして一度に依頼する
- ・エラーが出たらそのまま貼り付けて修正を依頼する
- ・各機能の実装後にコミットし、問題があればロールバックできるようにする