From e87245376c2fc6447020bf677768d6dfa237c8a9 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Mon, 23 Feb 2026 15:50:57 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=B9=B8=EB=B0=98=20=EB=93=9C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8=20=EC=8B=9C=20=ED=95=A0=20=EC=9D=BC=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=83=81=ED=83=9C=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EB=B0=8F=20=EC=BB=AC=EB=9F=BC=20=EC=9C=84=EC=B9=98?= =?UTF-8?q?=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - task.ts: 할 일 단건 수정 API(PATCH) 함수 추가 - useKanbanDnd.ts: 드래그로 컬럼 이동 시 onStatusChange 콜백 호출 - useKanbanTasks.ts: handleStatusChange 훅 추가 — localStorage로 컬럼 위치 유지, done/todo 이동 시 전체 항목, inProgress 이동 시 단일 항목 완료 상태 API 요청 - KanbanBoard.tsx: handleStatusChange를 useKanbanDnd에 전달 --- src/app/[teamid]/_domain/apis/task.ts | 18 +++++ .../_domain/components/Kanban/KanbanBoard.tsx | 2 + .../[teamid]/_domain/hooks/useKanbanDnd.ts | 18 +++-- .../[teamid]/_domain/hooks/useKanbanTasks.ts | 78 ++++++++++++++++++- 4 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 src/app/[teamid]/_domain/apis/task.ts diff --git a/src/app/[teamid]/_domain/apis/task.ts b/src/app/[teamid]/_domain/apis/task.ts new file mode 100644 index 0000000..e470b30 --- /dev/null +++ b/src/app/[teamid]/_domain/apis/task.ts @@ -0,0 +1,18 @@ +import { requestJson } from '@/shared/apis/groups/http'; +import type { Task } from './types'; + +export function updateTask( + groupId: number, + taskListId: number, + taskId: number, + data: { done?: boolean; name?: string; description?: string }, +): Promise { + return requestJson( + `/groups/${groupId}/task-lists/${taskListId}/tasks/${taskId}`, + '할 일 수정 실패', + { + method: 'PATCH', + body: JSON.stringify(data), + }, + ); +} diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx b/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx index f566702..4f70511 100644 --- a/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx @@ -34,6 +34,7 @@ export default function KanbanBoard({ groupId, teamId, taskLists }: KanbanBoardP handleCardClick, handleDeleteTask, handleUpdateTask, + handleStatusChange, handleAddTask, handleAddListSubmit, handleAddListClose, @@ -44,6 +45,7 @@ export default function KanbanBoard({ groupId, teamId, taskLists }: KanbanBoardP tasks, setTasks, COLUMN_IDS, + handleStatusChange, ); return ( diff --git a/src/app/[teamid]/_domain/hooks/useKanbanDnd.ts b/src/app/[teamid]/_domain/hooks/useKanbanDnd.ts index 6ce9aa8..84a9914 100644 --- a/src/app/[teamid]/_domain/hooks/useKanbanDnd.ts +++ b/src/app/[teamid]/_domain/hooks/useKanbanDnd.ts @@ -16,6 +16,7 @@ export function useKanbanDnd( tasks: KanbanTask[], setTasks: React.Dispatch>, columnIds: KanbanStatus[], + onStatusChange?: (taskId: string, fromStatus: KanbanStatus, toStatus: KanbanStatus) => void, ) { const [activeTask, setActiveTask] = useState(null); @@ -41,9 +42,13 @@ export function useKanbanDnd( // over가 컬럼인 경우 (빈 컬럼에 드롭) const isOverColumn = columnIds.includes(overId as KanbanStatus); if (isOverColumn) { - setTasks((prev) => - prev.map((t) => (t.id === activeId ? { ...t, status: overId as KanbanStatus } : t)), - ); + const activeTaskItem = tasks.find((t) => t.id === activeId); + if (activeTaskItem && activeTaskItem.status !== overId) { + const fromStatus = activeTaskItem.status; + const toStatus = overId as KanbanStatus; + setTasks((prev) => prev.map((t) => (t.id === activeId ? { ...t, status: toStatus } : t))); + onStatusChange?.(activeId, fromStatus, toStatus); + } return; } @@ -63,9 +68,10 @@ export function useKanbanDnd( }); } else { // 다른 컬럼으로 이동 - setTasks((prev) => - prev.map((t) => (t.id === activeId ? { ...t, status: overTaskItem.status } : t)), - ); + const fromStatus = activeTaskItem.status; + const toStatus = overTaskItem.status; + setTasks((prev) => prev.map((t) => (t.id === activeId ? { ...t, status: toStatus } : t))); + onStatusChange?.(activeId, fromStatus, toStatus); } }; diff --git a/src/app/[teamid]/_domain/hooks/useKanbanTasks.ts b/src/app/[teamid]/_domain/hooks/useKanbanTasks.ts index 6cb3ca5..f2e9bab 100644 --- a/src/app/[teamid]/_domain/hooks/useKanbanTasks.ts +++ b/src/app/[teamid]/_domain/hooks/useKanbanTasks.ts @@ -1,16 +1,35 @@ import { useState, useCallback, useMemo } from 'react'; import { useRouter } from 'next/navigation'; -import { useQueries } from '@tanstack/react-query'; +import { useQueries, useQueryClient } from '@tanstack/react-query'; import type { KanbanTask, KanbanStatus, TaskItem } from '../interfaces/team'; import type { TaskList } from '../apis/types'; import { taskListQueryOptions } from '../queries/useTaskListQuery'; import { useCreateTaskListMutation } from '../queries/useCreateTaskListMutation'; import { useDeleteTaskListMutation } from '../queries/useDeleteTaskListMutation'; +import { updateTask } from '../apis/task'; +import { taskListKeys } from '../queries/queryKeys'; function getTodayDateString(): string { return new Date().toISOString().split('T')[0]; } +// localStorage에 컬럼 위치를 저장하여 새로고침 후에도 유지 +function getStoredStatus(groupId: number, taskListId: number): KanbanStatus | null { + try { + const stored = localStorage.getItem(`kanban-status-${groupId}-${taskListId}`); + if (stored === 'todo' || stored === 'inProgress' || stored === 'done') return stored; + return null; + } catch { + return null; + } +} + +function setStoredStatus(groupId: number, taskListId: number, status: KanbanStatus): void { + try { + localStorage.setItem(`kanban-status-${groupId}-${taskListId}`, status); + } catch {} +} + function deriveStatus(items: TaskItem[]): KanbanStatus { if (items.length === 0) return 'todo'; const doneCount = items.filter((item) => item.checked).length; @@ -25,6 +44,7 @@ export function useKanbanTasks( taskLists: Omit[], ) { const router = useRouter(); + const queryClient = useQueryClient(); const today = getTodayDateString(); // 각 할 일 목록의 태스크를 병렬로 조회 @@ -44,11 +64,13 @@ export function useKanbanTasks( text: task.name, checked: task.doneAt !== null, })); + // localStorage 저장값 우선, 없으면 item 완료 비율로 파생 + const storedStatus = getStoredStatus(groupId, tl.id); return { id: String(tl.id), title: tl.name, items, - status: deriveStatus(items), + status: storedStatus ?? deriveStatus(items), }; }); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -118,6 +140,57 @@ export function useKanbanTasks( // 수정 기능은 할 일 목록 상세 페이지에서 처리 const handleUpdateTask = useCallback(() => {}, []); + // 드래그로 컬럼 이동 시 컬럼 위치를 저장하고, 항목이 있으면 API로 완료 상태도 변경 + const handleStatusChange = useCallback( + async (taskId: string, fromStatus: KanbanStatus, toStatus: KanbanStatus) => { + if (fromStatus === toStatus) return; + + const task = tasks.find((t) => t.id === taskId); + const taskListId = Number(taskId); + + // 항목 유무와 관계없이 컬럼 위치를 localStorage에 저장 + setStoredStatus(groupId, taskListId, toStatus); + + // 항목이 없으면 API 호출 없이 종료 (위치는 이미 저장됨) + if (!task || task.items.length === 0) return; + + try { + if (toStatus === 'done') { + // 모든 항목 완료 처리 + await Promise.all( + task.items.map((item) => + updateTask(groupId, taskListId, Number(item.id), { done: true }), + ), + ); + } else if (toStatus === 'todo') { + // 모든 항목 미완료 처리 + await Promise.all( + task.items.map((item) => + updateTask(groupId, taskListId, Number(item.id), { done: false }), + ), + ); + } else if (toStatus === 'inProgress') { + if (fromStatus === 'todo') { + // 첫 번째 항목만 완료 처리 + await updateTask(groupId, taskListId, Number(task.items[0].id), { done: true }); + } else if (fromStatus === 'done') { + // 마지막으로 완료된 항목을 미완료 처리 + const lastChecked = [...task.items].reverse().find((i) => i.checked); + if (lastChecked) { + await updateTask(groupId, taskListId, Number(lastChecked.id), { done: false }); + } + } + } + } finally { + // 성공/실패 관계없이 쿼리를 무효화하여 실제 서버 상태로 동기화 + await queryClient.invalidateQueries({ + queryKey: taskListKeys.detail(groupId, taskListId, today), + }); + } + }, + [tasks, groupId, today, queryClient], + ); + return { tasks, setTasks, @@ -127,6 +200,7 @@ export function useKanbanTasks( handleCardClick, handleDeleteTask, handleUpdateTask, + handleStatusChange, handleAddTask, handleAddListSubmit, handleAddListClose, From b30e5ce137d990e2d449b9a744ac742a4040437b Mon Sep 17 00:00:00 2001 From: jieunsse Date: Mon, 23 Feb 2026 17:04:22 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EC=B9=B8=EB=B0=98=20=EB=93=9C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8=EC=95=A4=EB=93=9C=EB=A1=AD=20UX=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20-=20=EB=93=9C=EB=A1=AD=20=EC=98=81=EC=97=AD=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=20=EB=B0=8F=20=EC=8B=9C=EA=B0=81=EC=A0=81=20?= =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 빈 컬럼 드롭 영역 min-height 40px → 200px로 확장 - 드래그 중인 아이템 원본 위치에 테두리 플레이스홀더 표시 (border-radius 12px) - 대상 컬럼 진입 시 드롭 위치 가이드 사각형 표시 (드래그 아이템과 동일 크기) - inProgress 컬럼 이동 시 불필요한 API 호출 제거로 자유로운 양방향 이동 지원 --- .../components/Kanban/KanbanColumn.module.css | 10 +++++++++- .../_domain/components/Kanban/KanbanColumn.tsx | 18 +++++++++++++++++- .../components/Kanban/KanbanItem.module.css | 12 ++++++++++++ .../_domain/components/Kanban/KanbanItem.tsx | 8 +++++--- .../[teamid]/_domain/hooks/useKanbanTasks.ts | 17 +++-------------- 5 files changed, 46 insertions(+), 19 deletions(-) diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.module.css b/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.module.css index ae1a2bc..34a14e8 100644 --- a/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.module.css +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.module.css @@ -36,7 +36,7 @@ display: flex; flex-direction: column; gap: 16px; - min-height: 40px; + min-height: 200px; border-radius: 12px; padding: 4px; transition: background 0.15s; @@ -45,3 +45,11 @@ .isOver { background: var(--color-brand-secondary); } + +/* 드롭 가이드: 드래그 중인 아이템과 동일한 border-radius(12px)로 표시 */ +.dropGuide { + border-radius: 12px; + box-shadow: inset 0 0 0 2px var(--color-brand-primary); + background-color: var(--color-brand-secondary); + flex-shrink: 0; +} diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx b/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx index 3304aa7..78ac523 100644 --- a/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx @@ -1,7 +1,7 @@ 'use client'; import { memo } from 'react'; -import { useDroppable } from '@dnd-kit/core'; +import { useDroppable, useDndContext } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import KanbanItem from './KanbanItem'; @@ -34,8 +34,21 @@ function KanbanColumn({ onUpdateTask, }: KanbanColumnProps) { const { setNodeRef, isOver } = useDroppable({ id: status }); + const { active, over } = useDndContext(); const itemIds = tasks.map((t) => t.id); + // 현재 컬럼의 아이템이 드래그 중인지 확인 + const isActiveFromThisColumn = active ? tasks.some((t) => t.id === String(active.id)) : false; + + // 다른 컬럼 아이템을 이 컬럼의 기존 아이템 위로 드래그 중인지 확인 + const isOverThisColumnItem = over ? tasks.some((t) => t.id === String(over.id)) : false; + + // 다른 컬럼에서 이 컬럼으로 드래그 진입 시 드롭 가이드 표시 + const showDropGuide = !!active && !isActiveFromThisColumn && (isOver || isOverThisColumnItem); + + // 드래그 중인 아이템의 초기 높이 (가이드 크기를 아이템과 동일하게 맞추기 위함) + const draggedItemHeight = active?.rect.current.initial?.height ?? 54; + return (
@@ -63,6 +76,9 @@ function KanbanColumn({ onUpdateTask={onUpdateTask} /> ))} + {showDropGuide && ( +
+ )}
diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanItem.module.css b/src/app/[teamid]/_domain/components/Kanban/KanbanItem.module.css index 85900e9..be5e960 100644 --- a/src/app/[teamid]/_domain/components/Kanban/KanbanItem.module.css +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanItem.module.css @@ -10,6 +10,18 @@ position: relative; } +/* 드래그 중인 아이템: TodoCard와 동일한 border-radius(12px)로 테두리 플레이스홀더 표시 */ +.itemDragging { + border-radius: 12px; + box-shadow: inset 0 0 0 2px var(--color-brand-primary); + background-color: var(--color-brand-secondary); +} + +/* 드래그 중인 아이템의 내용을 숨기되 공간은 유지 */ +.cardWrapperDragging { + visibility: hidden; +} + .todoCard { flex: 1; /* 접힘/펼침 전환 시 타이틀 수평 시프팅 방지: folded 상태와 패딩·보더 두께 통일 */ diff --git a/src/app/[teamid]/_domain/components/Kanban/KanbanItem.tsx b/src/app/[teamid]/_domain/components/Kanban/KanbanItem.tsx index 123939e..b34c9a8 100644 --- a/src/app/[teamid]/_domain/components/Kanban/KanbanItem.tsx +++ b/src/app/[teamid]/_domain/components/Kanban/KanbanItem.tsx @@ -40,7 +40,6 @@ function KanbanItem({ const style = { transform: CSS.Transform.toString(transform), transition, - opacity: isDragging ? 0.4 : 1, }; // 메뉴 외부 클릭 시 닫기 @@ -101,11 +100,14 @@ function KanbanItem({
-
+
{isEditing ? (
{}, []); - // 드래그로 컬럼 이동 시 컬럼 위치를 저장하고, 항목이 있으면 API로 완료 상태도 변경 + // 드래그로 컬럼 이동 시 컬럼 위치를 저장하고, 완료/할 일 이동 시 API로 완료 상태 동기화 const handleStatusChange = useCallback( async (taskId: string, fromStatus: KanbanStatus, toStatus: KanbanStatus) => { if (fromStatus === toStatus) return; @@ -151,8 +151,8 @@ export function useKanbanTasks( // 항목 유무와 관계없이 컬럼 위치를 localStorage에 저장 setStoredStatus(groupId, taskListId, toStatus); - // 항목이 없으면 API 호출 없이 종료 (위치는 이미 저장됨) - if (!task || task.items.length === 0) return; + // 진행중으로 이동하거나 항목이 없으면 API 호출 없이 종료 (위치는 이미 저장됨) + if (!task || task.items.length === 0 || toStatus === 'inProgress') return; try { if (toStatus === 'done') { @@ -169,17 +169,6 @@ export function useKanbanTasks( updateTask(groupId, taskListId, Number(item.id), { done: false }), ), ); - } else if (toStatus === 'inProgress') { - if (fromStatus === 'todo') { - // 첫 번째 항목만 완료 처리 - await updateTask(groupId, taskListId, Number(task.items[0].id), { done: true }); - } else if (fromStatus === 'done') { - // 마지막으로 완료된 항목을 미완료 처리 - const lastChecked = [...task.items].reverse().find((i) => i.checked); - if (lastChecked) { - await updateTask(groupId, taskListId, Number(lastChecked.id), { done: false }); - } - } } } finally { // 성공/실패 관계없이 쿼리를 무효화하여 실제 서버 상태로 동기화