diff --git a/src/app/dashboard/[id]/Card/Card.tsx b/src/app/dashboard/[id]/Card/Card.tsx index a1e4d61..e804193 100644 --- a/src/app/dashboard/[id]/Card/Card.tsx +++ b/src/app/dashboard/[id]/Card/Card.tsx @@ -1,13 +1,25 @@ import Image from 'next/image' -import type { Card } from '@/app/api/useCards' +import type { Card as CardType } from '@/app/api/useCards' +import { useDragStore } from '../store/useDragStore' import Tags from './Tags' -export default function Card({ card }: { card: Card }) { - const { imageUrl, title, tags, dueDate, assignee } = card +export default function Card({ + card, + columnId, +}: { + card: CardType + columnId: number +}) { + const { id, imageUrl, title, tags, dueDate, assignee } = card + const { setDraggingCard } = useDragStore() return ( -
+
setDraggingCard({ cardId: id, columnId: columnId })} + className="BG-white Border-section relative w-314 rounded-6 border-solid px-20 py-16" + > Todo Card {imageUrl && ( )}

{title}

diff --git a/src/app/dashboard/[id]/Column/Column.tsx b/src/app/dashboard/[id]/Column/Column.tsx index 1084aa2..eaf64e5 100644 --- a/src/app/dashboard/[id]/Column/Column.tsx +++ b/src/app/dashboard/[id]/Column/Column.tsx @@ -1,18 +1,57 @@ +import { useQueryClient } from '@tanstack/react-query' import Image from 'next/image' +import { useState } from 'react' import useCards from '@/app/api/useCards' -import type { Column } from '@/app/api/useColumns' +import type { Column as ColumnType } from '@/app/api/useColumns' +import { cn } from '@/app/shared/lib/cn' +import { useCardMutation } from '../api/useCardMutation' import Card from '../Card/Card' -export default function Column({ column }: { column: Column }) { +import { useDragStore } from '../store/useDragStore' +export default function Column({ column }: { column: ColumnType }) { const { id, title }: { id: number; title: string } = column const { data, isLoading, error } = useCards(id) + const [isDraggingover, setDraggingover] = useState(false) + const { clearDraggingCard } = useDragStore() + const cardMutation = useCardMutation() if (isLoading) return

loading...

if (error) return

error...{error.message}

return ( -
+
{ + e.preventDefault() //브라우저 기본은 드롭 비허용. 이걸 막아줘야 drop 가능 + if (!isDraggingover) setDraggingover(true) //dragOver 이벤트 발생하는 내내 setState 실행 방지(처음 false일때만 setDraggingOver실행) + }} + onDragLeave={(e) => { + e.preventDefault() + if (isDraggingover) setDraggingover(false) + }} + onDrop={(e) => { + e.preventDefault() + if (isDraggingover) setDraggingover(false) + const draggingCard = useDragStore.getState().draggingCard + + if (!draggingCard) { + console.log('no dragging card') //TODO - toast 처리 🍞 + return + } + // 동일 컬럼이면 무시 + if (draggingCard.columnId === id) { + clearDraggingCard() + return + } + cardMutation.mutate({ cardId: draggingCard.cardId, columnId: id }) + }} + className={cn( + 'BG-gray Border-column flex w-354 shrink-0 flex-col gap-16 p-20', + { + '!border-blue-500': isDraggingover, + }, + )} + >
@@ -38,7 +77,9 @@ export default function Column({ column }: { column: Column }) { />
- {data?.cards.map((card) => )} + {data?.cards.map((card) => ( + + ))}
) } diff --git a/src/app/dashboard/[id]/api/updateCardColumn.ts b/src/app/dashboard/[id]/api/updateCardColumn.ts new file mode 100644 index 0000000..b2cc903 --- /dev/null +++ b/src/app/dashboard/[id]/api/updateCardColumn.ts @@ -0,0 +1,12 @@ +import axiosClient from '@/app/api/axiosClient' + +// 카드 이동 - 해당 카드의 컬럼ID를 변경하는 방식(PUT) +export async function updateCardColumn( + cardId: number, + columnId: number, +): Promise<{ success: boolean }> { + const res = await axiosClient.put<{ success: boolean }>(`/cards/${cardId}`, { + columnId: columnId, + }) + return res.data +} diff --git a/src/app/dashboard/[id]/api/useCardMutation.ts b/src/app/dashboard/[id]/api/useCardMutation.ts new file mode 100644 index 0000000..8c7a875 --- /dev/null +++ b/src/app/dashboard/[id]/api/useCardMutation.ts @@ -0,0 +1,102 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import type { Card, CardResponse } from '@/app/api/useCards' + +import { useDragStore } from '../store/useDragStore' +import { updateCardColumn } from './updateCardColumn' + +export const useCardMutation = () => { + const queryClient = useQueryClient() + const { clearDraggingCard } = useDragStore() + + return useMutation({ + // 1. 서버 API 호출 + // cardId: 드래그한 카드 아이디, columnId: dragOver된 타겟 컬럼 아이디 + mutationFn: ({ cardId, columnId }: { cardId: number; columnId: number }) => + updateCardColumn(cardId, columnId), + + // 2. 낙관적 UI 처리 (서버 요청 전에 실행됨) + onMutate: async ({ cardId, columnId }) => { + const currentCard = useDragStore.getState().draggingCard + + await Promise.all([ + queryClient.cancelQueries({ queryKey: ['columnId', columnId] }), + queryClient.cancelQueries({ + queryKey: ['columnId', currentCard?.columnId], + }), + ]) + + // 업데이트 이전 데이터 챙겨뒀다가 롤백할때 사용 + const previousData = queryClient.getQueryData([ + 'columnId', + columnId, + ]) + + // Guard return + if ( + !currentCard || + currentCard.cardId !== cardId || + currentCard.columnId === columnId + ) { + console.log('no dragging card || is not a dragging card || same column') + clearDraggingCard() + return + } + + let extractedCard: Card | undefined // B. 에서 사용할 예정(추가할 카드 데이터는 Card여야 해서) + // A. 이전 컬럼에서 카드 제거 & 카드 추출 + // setQueryData의 콜백함수의 리턴값이 쿼리키 캐시에 저장됨(캐시 업데이트) + queryClient.setQueryData( + ['columnId', currentCard.columnId], + (oldData) => { + if (!oldData) return + + const filtered = oldData.cards.filter((card) => { + if (card.id === cardId) extractedCard = card + return card.id !== cardId + }) + + return { ...oldData, cards: filtered } + }, + ) + // B. 새 컬럼에 카드 추가 + if (extractedCard) { + queryClient.setQueryData( + ['columnId', columnId], + (oldData) => { + if (!oldData) return + + const movedCard = { ...extractedCard!, columnId } + return { + ...oldData, + cards: [...oldData.cards, movedCard], + } + }, + ) + } else { + console.log('카드가 제거 중에 undefined가 됨') + } + + clearDraggingCard() + return { previousData } + }, + + // 3. 에러 발생 시 롤백 + onError: (error, variables, context) => { + if (context?.previousData) { + queryClient.setQueryData( + ['columnId', variables.columnId], + context.previousData, + ) + } + console.error('카드 이동 실패:', error) + }, + + // 4. 성공 시 서버 기준으로 다시 데이터 불러오도록 유도함. + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ['columnId', variables.columnId], + }) + }, + }) +} diff --git a/src/app/dashboard/[id]/page.tsx b/src/app/dashboard/[id]/page.tsx index c775055..2f5e7d0 100644 --- a/src/app/dashboard/[id]/page.tsx +++ b/src/app/dashboard/[id]/page.tsx @@ -14,7 +14,7 @@ export default function DashboardID() { return ( <>
사이드바
-
+
{columns?.map((column) => )}
diff --git a/src/app/dashboard/[id]/store/useDragStore.ts b/src/app/dashboard/[id]/store/useDragStore.ts new file mode 100644 index 0000000..93e41dc --- /dev/null +++ b/src/app/dashboard/[id]/store/useDragStore.ts @@ -0,0 +1,15 @@ +import { create } from 'zustand' +interface draggingCard { + cardId: number + columnId: number +} +interface DragStore { + draggingCard: draggingCard | null + setDraggingCard: (data: { cardId: number; columnId: number }) => void + clearDraggingCard: () => void +} +export const useDragStore = create((set) => ({ + draggingCard: null, + setDraggingCard: (data) => set({ draggingCard: data }), + clearDraggingCard: () => set({ draggingCard: null }), +}))