From 91dd05ba7bf0ab6542592b2756af69a35212af7e Mon Sep 17 00:00:00 2001 From: Ken Yasue Date: Thu, 25 Jun 2026 10:51:50 +0200 Subject: [PATCH] feat(todo): richer metadata (tags, startDate, files) + edit/detail dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ToDoアイテムのメタデータを拡充し、編集/詳細ダイアログを追加。 - lib/db/migrations/003_todo_tags.sql: todo_items に tags 列を追加 - TodoRepository/TodoService: startDate(従来ハードコードNULL)とtagsをcreate/updateで永続化、fileIdsで添付紐付け(トランザクション)、deleteItemで添付クリーンアップ、getItem/getItemAttachments を追加 - API: GET /todos/items/[itemId] ({item, attachments}) を追加、POST/PATCH で startDate/tags/fileIds を受理、PATCH は null(クリア)と undefined(更新しない)を区別 - UI: TodoDialog(タイトル/説明/担当/優先度/開始日/期限/タグ/添付を編集、完了日時・タイムスタンプは読み取り専用)、KanbanBoard のカードクリックでダイアログを開きメタデータを表示 - MilestoneRepository の todo マッピングに tags を追加し as never キャストを修正 - docs/functional-design.md に tags / file_assets.source / attachments テーブルを追記 --- .../[projectId]/todos/items/[itemId]/route.ts | 45 ++- .../projects/[projectId]/todos/items/route.ts | 9 + app/projects/[projectId]/todos/page.tsx | 2 + components/todo/KanbanBoard.tsx | 132 +++++--- components/todo/TodoDialog.tsx | 311 ++++++++++++++++++ docs/functional-design.md | 24 ++ docs/repository-structure.md | 2 +- lib/api/services.ts | 11 +- lib/db/migrations/003_todo_tags.sql | 4 + lib/types/index.ts | 4 +- repositories/MilestoneRepository.ts | 11 +- repositories/TodoRepository.ts | 20 +- services/TodoService.ts | 98 ++++-- tests/e2e/todo-kanban.spec.ts | 95 ++++++ tests/unit/lib/db/migrator.test.ts | 1 + .../unit/repositories/TodoRepository.test.ts | 43 +++ tests/unit/services/TodoService.test.ts | 149 ++++++++- 17 files changed, 885 insertions(+), 76 deletions(-) create mode 100644 components/todo/TodoDialog.tsx create mode 100644 lib/db/migrations/003_todo_tags.sql diff --git a/app/api/projects/[projectId]/todos/items/[itemId]/route.ts b/app/api/projects/[projectId]/todos/items/[itemId]/route.ts index 18e31fd..bb9c6a6 100644 --- a/app/api/projects/[projectId]/todos/items/[itemId]/route.ts +++ b/app/api/projects/[projectId]/todos/items/[itemId]/route.ts @@ -6,6 +6,23 @@ import { handleApiError, jsonError } from '@/lib/api/handleError'; export const runtime = 'nodejs'; +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ projectId: string; itemId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { itemId } = await params; + const service = createTodoService(); + try { + const item = service.getItem(user.id, Number(itemId)); + const attachments = service.getItemAttachments(user.id, Number(itemId)); + return NextResponse.json({ item, attachments }); + } catch (error) { + return handleApiError(error); + } +} + export async function PATCH( request: NextRequest, { params }: { params: Promise<{ projectId: string; itemId: string }> } @@ -35,23 +52,29 @@ export async function PATCH( ); return NextResponse.json({ item }); } + // nullable フィールドは absent(undefined=更新しない) と null(クリア) を区別する + const nullableString = (v: unknown): string | null | undefined => + v === undefined ? undefined : v === null ? null : String(v); + const nullableNumber = (v: unknown): number | null | undefined => + v === undefined ? undefined : v === null ? null : Number(v); + const item = service.updateItem(user.id, Number(itemId), { title: typeof body.title === 'string' ? body.title : undefined, - description: - typeof body.description === 'string' ? body.description : undefined, - assigneeId: - body.assigneeId === null || body.assigneeId === undefined - ? undefined - : Number(body.assigneeId), + description: nullableString(body.description), + assigneeId: nullableNumber(body.assigneeId), priority: typeof body.priority === 'string' ? (body.priority as 'low' | 'normal' | 'high') : undefined, - dueDate: typeof body.dueDate === 'string' ? body.dueDate : undefined, - milestoneId: - body.milestoneId === null || body.milestoneId === undefined - ? undefined - : Number(body.milestoneId), + startDate: nullableString(body.startDate), + dueDate: nullableString(body.dueDate), + tags: nullableString(body.tags), + milestoneId: nullableNumber(body.milestoneId), + fileIds: Array.isArray(body.fileIds) + ? body.fileIds + .map((n) => Number(n)) + .filter((n) => Number.isFinite(n) && n > 0) + : undefined, }); return NextResponse.json({ item }); } catch (error) { diff --git a/app/api/projects/[projectId]/todos/items/route.ts b/app/api/projects/[projectId]/todos/items/route.ts index bc8d040..8149a44 100644 --- a/app/api/projects/[projectId]/todos/items/route.ts +++ b/app/api/projects/[projectId]/todos/items/route.ts @@ -38,6 +38,11 @@ export async function POST( } const service = createTodoService(); try { + const fileIds = Array.isArray(body.fileIds) + ? body.fileIds + .map((n) => Number(n)) + .filter((n) => Number.isFinite(n) && n > 0) + : []; const item = service.createItem(user.id, Number(projectId), { title: String(body.title ?? ''), columnId: Number(body.columnId ?? 0), @@ -51,7 +56,11 @@ export async function POST( typeof body.priority === 'string' ? (body.priority as 'low' | 'normal' | 'high') : undefined, + startDate: + typeof body.startDate === 'string' ? body.startDate : undefined, dueDate: typeof body.dueDate === 'string' ? body.dueDate : null, + tags: typeof body.tags === 'string' ? body.tags : undefined, + fileIds, }); return NextResponse.json({ item }, { status: 201 }); } catch (error) { diff --git a/app/projects/[projectId]/todos/page.tsx b/app/projects/[projectId]/todos/page.tsx index bcb17d6..52f096f 100644 --- a/app/projects/[projectId]/todos/page.tsx +++ b/app/projects/[projectId]/todos/page.tsx @@ -31,6 +31,7 @@ export default async function TodosPage({ const todoService = createTodoService(); const columns = todoService.getColumns(user.id, project.id); const items = todoService.getItems(user.id, project.id); + const members = projectService.getMembers(user.id, project.id); return (
@@ -42,6 +43,7 @@ export default async function TodosPage({ projectId={project.id} columns={columns} initialItems={items} + members={members} />
diff --git a/components/todo/KanbanBoard.tsx b/components/todo/KanbanBoard.tsx index 546b2f8..e08c36f 100644 --- a/components/todo/KanbanBoard.tsx +++ b/components/todo/KanbanBoard.tsx @@ -2,6 +2,8 @@ import { useCallback, 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', @@ -13,13 +15,16 @@ 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 reload = useCallback(async () => { const res = await fetch(`/api/projects/${projectId}/todos/items`); @@ -44,7 +49,7 @@ export function KanbanBoard({ setDraggingId(null); return; } - // 楽観的にローカル更新したあとAPIで移動 + // 楽観的にローカル更新したあとAPIで移動(失敗時は再取得で巻き戻す) setItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, columnId: column.id } : i)) ); @@ -52,7 +57,9 @@ export function KanbanBoard({ method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ columnId: column.id, orderIndex: 0 }), - }).then(() => reload()); + }) + .catch(() => undefined) + .finally(() => reload()); setDraggingId(null); } @@ -91,48 +98,99 @@ export function KanbanBoard({ {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} + {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)} + onClick={() => setSelectedItem(item)} + 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} + + )} + {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} + /> + )} ); } diff --git a/components/todo/TodoDialog.tsx b/components/todo/TodoDialog.tsx new file mode 100644 index 0000000..e4e466c --- /dev/null +++ b/components/todo/TodoDialog.tsx @@ -0,0 +1,311 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import type { AttachmentView, TodoItem, TodoPriority } from '@/lib/types'; +import type { ProjectMemberWithUser } from '@/repositories/ProjectMemberRepository'; +import { AttachmentList } from '@/components/files/AttachmentList'; +import { + AttachmentPicker, + type AttachmentPickerHandle, +} from '@/components/files/AttachmentPicker'; + +const PRIORITIES: TodoPriority[] = ['low', 'normal', 'high']; + +function parseTags(tags: string | null): string[] { + if (!tags) return []; + return tags + .split(',') + .map((t) => t.trim()) + .filter((t) => t.length > 0); +} + +/** + * ToDo編集/詳細ダイアログ。 + * カードクリックで開き、タイトル/説明/担当/優先度/開始日/期限/タグ/添付を + * 編集・閲覧できる。完了日(完了時刻)・作成/更新日時は読み取り専用で表示。 + */ +export function TodoDialog({ + projectId, + item, + members, + onClose, + onSaved, +}: { + projectId: number; + item: TodoItem; + members: ProjectMemberWithUser[]; + onClose: () => void; + onSaved: () => void | Promise; +}) { + const [title, setTitle] = useState(item.title); + const [description, setDescription] = useState(item.description ?? ''); + const [assigneeId, setAssigneeId] = useState( + item.assigneeId ? String(item.assigneeId) : '' + ); + const [priority, setPriority] = useState(item.priority); + const [startDate, setStartDate] = useState(item.startDate ?? ''); + const [dueDate, setDueDate] = useState(item.dueDate ?? ''); + const [tags, setTags] = useState(item.tags ?? ''); + const [attachments, setAttachments] = useState([]); + const [current, setCurrent] = useState(item); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [pickerLoading, setPickerLoading] = useState(false); + const [error, setError] = useState(null); + const pickerRef = useRef(null); + + // 開封時に最新のアイテム+添付を取得 + useEffect(() => { + let alive = true; + setLoading(true); + fetch(`/api/projects/${projectId}/todos/items/${item.id}`) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!alive || !data) return; + const fetched = data.item as TodoItem; + setTitle(fetched.title); + setDescription(fetched.description ?? ''); + setAssigneeId(fetched.assigneeId ? String(fetched.assigneeId) : ''); + setPriority(fetched.priority); + setStartDate(fetched.startDate ?? ''); + setDueDate(fetched.dueDate ?? ''); + setTags(fetched.tags ?? ''); + setAttachments((data.attachments as AttachmentView[]) ?? []); + setCurrent(fetched); + }) + .catch(() => undefined) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, [projectId, item.id]); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + async function onSave() { + setSaving(true); + setError(null); + const fileIds = pickerRef.current?.getFileIds() ?? []; + const res = await fetch( + `/api/projects/${projectId}/todos/items/${item.id}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title, + description, + assigneeId: assigneeId ? Number(assigneeId) : null, + priority, + startDate: startDate || null, + dueDate: dueDate || null, + tags: tags || null, + fileIds, + }), + } + ); + setSaving(false); + if (res.ok) { + pickerRef.current?.clear(); + await onSaved(); + onClose(); + } else { + const b = (await res.json().catch(() => null)) as { + error?: { message?: string }; + } | null; + setError(b?.error?.message ?? '保存に失敗しました'); + } + } + + const tagList = parseTags(tags); + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label={`ToDo ${item.title} の編集`} + data-testid="todo-dialog" + > +
+

ToDoの編集

+ +
+ + {loading ? ( +

読み込み中...

+ ) : ( +
+
+ + setTitle(e.target.value)} + className="mt-1 w-full rounded border px-3 py-2" + data-testid="todo-title" + /> +
+
+ +