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/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 ? (
>, 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..e8460f1 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,46 @@ 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 || toStatus === 'inProgress') 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 }), + ), + ); + } + } finally { + // 성공/실패 관계없이 쿼리를 무효화하여 실제 서버 상태로 동기화 + await queryClient.invalidateQueries({ + queryKey: taskListKeys.detail(groupId, taskListId, today), + }); + } + }, + [tasks, groupId, today, queryClient], + ); + return { tasks, setTasks, @@ -127,6 +189,7 @@ export function useKanbanTasks( handleCardClick, handleDeleteTask, handleUpdateTask, + handleStatusChange, handleAddTask, handleAddListSubmit, handleAddListClose,