Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/app/[teamid]/_domain/apis/task.ts
Original file line number Diff line number Diff line change
@@ -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<Task> {
return requestJson<Task>(
`/groups/${groupId}/task-lists/${taskListId}/tasks/${taskId}`,
'할 일 수정 실패',
{
method: 'PATCH',
body: JSON.stringify(data),
},
);
}
2 changes: 2 additions & 0 deletions src/app/[teamid]/_domain/components/Kanban/KanbanBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export default function KanbanBoard({ groupId, teamId, taskLists }: KanbanBoardP
handleCardClick,
handleDeleteTask,
handleUpdateTask,
handleStatusChange,
handleAddTask,
handleAddListSubmit,
handleAddListClose,
Expand All @@ -44,6 +45,7 @@ export default function KanbanBoard({ groupId, teamId, taskLists }: KanbanBoardP
tasks,
setTasks,
COLUMN_IDS,
handleStatusChange,
);

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
18 changes: 17 additions & 1 deletion src/app/[teamid]/_domain/components/Kanban/KanbanColumn.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

54라는 하드코딩된 값(매직 넘버)을 사용하고 있습니다. 주석으로 설명이 되어 있지만, 이 값은 KanbanItem.module.css의 접힌 카드 높이와 암묵적으로 연결되어 있어 유지보수 시 문제를 일으킬 수 있습니다. 예를 들어, CSS에서 카드 높이가 변경되면 이 코드도 함께 수정해야 합니다. 이 값을 공유 상수로 정의하거나 CSS 변수를 통해 관리하여 코드의 일관성과 유지보수성을 높이는 것이 좋습니다.


return (
<div className={styles.column}>
<div className={styles.columnHeader}>
Expand Down Expand Up @@ -63,6 +76,9 @@ function KanbanColumn({
onUpdateTask={onUpdateTask}
/>
))}
{showDropGuide && (
<div className={styles.dropGuide} style={{ height: draggedItemHeight }} />
)}
</div>
</SortableContext>
</div>
Expand Down
12 changes: 12 additions & 0 deletions src/app/[teamid]/_domain/components/Kanban/KanbanItem.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 상태와 패딩·보더 두께 통일 */
Expand Down
8 changes: 5 additions & 3 deletions src/app/[teamid]/_domain/components/Kanban/KanbanItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
function KanbanItem({
task,
onItemCheckedChange,
onCardClick,

Check warning on line 22 in src/app/[teamid]/_domain/components/Kanban/KanbanItem.tsx

View workflow job for this annotation

GitHub Actions / build

'onCardClick' is defined but never used. Allowed unused args must match /^_/u
onDeleteTask,
onEditTask,
onUpdateTask,
Expand All @@ -40,7 +40,6 @@
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};

// 메뉴 외부 클릭 시 닫기
Expand Down Expand Up @@ -101,11 +100,14 @@
<div
ref={setNodeRef}
style={style}
className={styles.item}
className={`${styles.item} ${isDragging ? styles.itemDragging : ''}`}
onClick={handleContainerClick}
{...(isEditing ? {} : listeners)}
>
<div ref={containerRef} className={styles.cardWrapper}>
<div
ref={containerRef}
className={`${styles.cardWrapper} ${isDragging ? styles.cardWrapperDragging : ''}`}
>
{isEditing ? (
<div className={styles.editCard}>
<input
Expand Down
18 changes: 12 additions & 6 deletions src/app/[teamid]/_domain/hooks/useKanbanDnd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function useKanbanDnd(
tasks: KanbanTask[],
setTasks: React.Dispatch<React.SetStateAction<KanbanTask[]>>,
columnIds: KanbanStatus[],
onStatusChange?: (taskId: string, fromStatus: KanbanStatus, toStatus: KanbanStatus) => void,
) {
const [activeTask, setActiveTask] = useState<KanbanTask | null>(null);

Expand All @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

activeTaskItem을 찾는 이 로직은 함수 뒷부분(57행)에서 반복됩니다. handleDragEnd 함수 시작 부분에서 activeTaskItem을 한 번만 찾도록 리팩터링하여 코드 중복을 제거하고 효율성을 높이는 것을 고려해 보세요.

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;
}

Expand All @@ -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);
}
};

Expand Down
67 changes: 65 additions & 2 deletions src/app/[teamid]/_domain/hooks/useKanbanTasks.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -25,6 +44,7 @@ export function useKanbanTasks(
taskLists: Omit<TaskList, 'tasks'>[],
) {
const router = useRouter();
const queryClient = useQueryClient();
const today = getTodayDateString();

// 각 할 일 목록의 태스크를 병렬로 조회
Expand All @@ -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
Expand Down Expand Up @@ -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],
);
Comment on lines +144 to +181

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

handleStatusChange 함수가 tasks 상태에 의존하고 있어, 칸반 아이템을 드래그하여 tasks 상태가 변경될 때마다 이 콜백 함수가 재생성됩니다. 이는 성능에 영향을 줄 수 있습니다. useRef를 사용하여 tasks의 최신 값을 참조하도록 하고 useCallback의 의존성 배열에서 tasks를 제거하면 이러한 재생성을 방지하여 코드를 최적화할 수 있습니다.


return {
tasks,
setTasks,
Expand All @@ -127,6 +189,7 @@ export function useKanbanTasks(
handleCardClick,
handleDeleteTask,
handleUpdateTask,
handleStatusChange,
handleAddTask,
handleAddListSubmit,
handleAddListClose,
Expand Down
Loading