diff --git a/src/app/dashboard/[id]/page.tsx b/src/app/dashboard/[id]/page.tsx index 0e79228..e4086f6 100644 --- a/src/app/dashboard/[id]/page.tsx +++ b/src/app/dashboard/[id]/page.tsx @@ -7,6 +7,8 @@ import { useEffect, useRef } from 'react' import { useCardMutation } from '@/app/features/dashboard_Id/api/useCardMutation' import useColumns from '@/app/features/dashboard_Id/api/useColumns' import Column from '@/app/features/dashboard_Id/Column/Column' +import ColumnModalRenderer from '@/app/features/dashboard_Id/components/ColumnModalRenderer' +import { useColumnModalStore } from '@/app/features/dashboard_Id/store/useColumnModalStore' import { useColumnsStore } from '@/app/features/dashboard_Id/store/useColumnsStore' import { useDragStore } from '@/app/features/dashboard_Id/store/useDragStore' import { Card } from '@/app/features/dashboard_Id/type/Card.type' @@ -15,7 +17,7 @@ export default function DashboardID() { const params = useParams() const dashboardId = Number(params.id) const { data: columns, isLoading, error } = useColumns(dashboardId) - // const { data: columns, isLoading, error } = useColumns(id) + const { openModal } = useColumnModalStore() const { draggingCard, setDraggingCard } = useDragStore() const { setColumns } = useColumnsStore() @@ -25,6 +27,10 @@ export default function DashboardID() { const longPressTimer = useRef(null) const isLongPressActive = useRef(false) + const handleCreateColumn = () => { + openModal('create', { dashboardId }) + } + useEffect(() => { if (columns) { const transformed = columns.map((column) => ({ @@ -141,14 +147,19 @@ export default function DashboardID() { <>
- {columns?.map((column) => )} + {columns?.map((column) => ( + + ))}
-
+ ) } diff --git a/src/app/features/dashboard_Id/Column/Column.tsx b/src/app/features/dashboard_Id/Column/Column.tsx index 5e45ef7..f55c4dc 100644 --- a/src/app/features/dashboard_Id/Column/Column.tsx +++ b/src/app/features/dashboard_Id/Column/Column.tsx @@ -8,11 +8,20 @@ import useCards from '../api/useCards' import Card from '../Card/Card' import CreateCardForm from '../Card/cardFormModals/CreateCardForm' import CreateCardModal from '../Card/cardFormModals/CreateCardModal' +import { useColumnModalStore } from '../store/useColumnModalStore' import { useDragStore } from '../store/useDragStore' import type { Column as ColumnType } from '../type/Column.type' -export default function Column({ column }: { column: ColumnType }) { + +export default function Column({ + column, + dashboardId, +}: { + column: ColumnType + dashboardId: number +}) { const { id, title }: { id: number; title: string } = column const { data, isLoading, error } = useCards(id) + const { openModal } = useColumnModalStore() const [isDraggingover, setDraggingover] = useState(false) const { draggingCard, clearDraggingCard } = useDragStore() const cardMutation = useCardMutation() @@ -21,6 +30,14 @@ export default function Column({ column }: { column: ColumnType }) { const [openCreateColumn, setOpenCreateColumn] = useState(false) //page.tsx const [oepnConfigColumn, setConfigColumn] = useState(false) + const handleConfigColumn = () => { + openModal('edit', { + dashboardId, + columnId: id, + columnTitle: title, + }) + } + if (isLoading) return

loading...

if (error) return

error...{error.message}

@@ -52,7 +69,7 @@ export default function Column({ column }: { column: ColumnType }) { }} data-column-id={id} className={cn( - 'BG-gray Border-column flex w-354 shrink-0 flex-col gap-16 p-20 tablet:w-584', + 'BG-gray Border-column tablet:w-584 flex w-354 shrink-0 flex-col gap-16 p-20', { 'BG-drag-hovered': isDraggingover, }, @@ -71,7 +88,8 @@ export default function Column({ column }: { column: ColumnType }) { alt="컬럼 설정" width={20} height={20} - onClick={() => setConfigColumn(true)} + onClick={handleConfigColumn} + className="cursor-pointer" />
+ +
+ + + + ) +} diff --git a/src/app/features/dashboard_Id/components/DeleteColumnConfirmModal.tsx b/src/app/features/dashboard_Id/components/DeleteColumnConfirmModal.tsx new file mode 100644 index 0000000..bd13072 --- /dev/null +++ b/src/app/features/dashboard_Id/components/DeleteColumnConfirmModal.tsx @@ -0,0 +1,90 @@ +'use client' + +import { useState } from 'react' + +import { useDeleteColumn } from '../hooks/useDeleteColumn' +import { useColumnModalStore } from '../store/useColumnModalStore' + +export default function DeleteColumnConfirmModal() { + const { modalType, modalData, closeModal } = useColumnModalStore() + const isModalOpen = modalType === 'deleteConfirm' + + const [isSubmitting, setIsSubmitting] = useState(false) + const deleteColumnMutation = useDeleteColumn() + + if (!isModalOpen) { + return null + } + + const handleConfirmDelete = async () => { + if (!modalData?.columnId || !modalData?.dashboardId) { + return + } + + try { + setIsSubmitting(true) + + await deleteColumnMutation.mutateAsync({ + columnId: modalData.columnId, + dashboardId: modalData.dashboardId, + }) + + closeModal() + } catch (error) { + // 에러는 useDeleteColumn에서 toast로 처리됨 + } finally { + setIsSubmitting(false) + } + } + + // 모달 외부 클릭 시 닫기 + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + closeModal() + } + } + + return ( + // 모달 백드롭 +
+ {/* 모달 컨테이너 */} +
+ {/* 중앙 메시지 */} +
+

+ 컬럼의 모든 카드가 삭제됩니다. +

+
+ + {/* 하단 버튼들 */} +
+ {/* 왼쪽 취소 버튼 */} + + + {/* 오른쪽 삭제 버튼 */} + +
+
+
+ ) +} diff --git a/src/app/features/dashboard_Id/components/EditColumnModal.tsx b/src/app/features/dashboard_Id/components/EditColumnModal.tsx new file mode 100644 index 0000000..02bca26 --- /dev/null +++ b/src/app/features/dashboard_Id/components/EditColumnModal.tsx @@ -0,0 +1,183 @@ +'use client' + +import { useEffect, useState } from 'react' + +import useColumns from '../api/useColumns' +import { useUpdateColumn } from '../hooks/useUpdateColumn' +import { useColumnModalStore } from '../store/useColumnModalStore' + +export default function EditColumnModal() { + const { modalType, modalData, closeModal, openModal } = useColumnModalStore() + const isModalOpen = modalType === 'edit' + + const [title, setTitle] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + // 기존 컬럼 목록 가져오기 (중복 체크용) + const { data: columns } = useColumns(modalData?.dashboardId || 0) + const updateColumnMutation = useUpdateColumn() + + useEffect(() => { + if (!isModalOpen) { + setTitle('') + setIsSubmitting(false) + } else if (modalData?.columnTitle) { + setTitle(modalData.columnTitle) + } + }, [isModalOpen, modalData?.columnTitle]) + + if (!isModalOpen) { + return null + } + + // 중복 컬럼명 체크 (현재 컬럼 제외) + const isDuplicate = columns?.some( + (column) => + column.title.toLowerCase() === title.toLowerCase() && + column.id !== modalData?.columnId, + ) + + // 변경 버튼 활성화 조건 + const isUpdateDisabled = + !title.trim() || + isDuplicate || + title.trim() === modalData?.columnTitle || // 기존과 동일하면 비활성화 + isSubmitting + + // 에러 메시지 표시 여부에 따른 높이 계산 + const modalHeight = isDuplicate ? 'h-290 mobile:h-280' : 'h-270 mobile:h-258' + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (isUpdateDisabled) { + return + } + + if (!modalData?.dashboardId || !modalData?.columnId) { + return + } + + try { + setIsSubmitting(true) + + await updateColumnMutation.mutateAsync({ + columnId: modalData.columnId, + title: title.trim(), + dashboardId: modalData.dashboardId, + }) + + closeModal() + } catch (error) { + // 에러는 useUpdateColumn에서 toast로 처리됨 + } finally { + setIsSubmitting(false) + } + } + + // 입력값 변경 처리 + const handleChange = (e: React.ChangeEvent) => { + setTitle(e.target.value) + } + + // 삭제 버튼 클릭 시 삭제 확인 모달로 전환 + const handleDeleteClick = () => { + if (!modalData) { + return + } + + openModal('deleteConfirm', modalData) + } + + // 모달 외부 클릭 시 닫기 + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + closeModal() + } + } + + return ( + // 모달 백드롭 +
+ {/* 모달 컨테이너 */} +
+ {/* 헤더에 닫기 버튼 */} +
+

+ 컬럼 관리 +

+ +
+ +
+ {/* 제목 입력 */} +
+ + + + {/* 에러 메시지 표시 */} + {isDuplicate && ( +

+ 중복된 컬럼 이름입니다 +

+ )} +
+ + {/* 하단 버튼 */} +
+ {/* 왼쪽 삭제 버튼 */} + + + {/* 오른쪽 변경 버튼 */} + +
+
+
+
+ ) +} diff --git a/src/app/features/dashboard_Id/hooks/useCreateColumn.ts b/src/app/features/dashboard_Id/hooks/useCreateColumn.ts new file mode 100644 index 0000000..4c98938 --- /dev/null +++ b/src/app/features/dashboard_Id/hooks/useCreateColumn.ts @@ -0,0 +1,43 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { AxiosError } from 'axios' +import { toast } from 'sonner' + +import { postColumn } from '../api/postColumn' +import { CreateColumnRequest, CreateColumnResponse } from '../type/Column.type' + +export function useCreateColumn() { + const queryClient = useQueryClient() + + return useMutation< + CreateColumnResponse, + AxiosError<{ message?: string }>, + CreateColumnRequest + >({ + mutationFn: postColumn, + onSuccess: (data, variables) => { + // 컬럼 목록 캐시 무효화 - 새로운 컬럼이 즉시 보이도록 + queryClient.invalidateQueries({ + queryKey: ['columns', variables.dashboardId], + }) + toast.success('컬럼이 생성되었습니다!') + }, + onError: (error) => { + const serverMessage = error.response?.data?.message + + // 서버에서 오는 에러 메시지에 따라 분기 + if ( + serverMessage?.includes('중복') || + serverMessage?.includes('duplicate') + ) { + toast.error('중복된 컬럼 이름입니다') + } else if ( + serverMessage?.includes('최대') || + serverMessage?.includes('maximum') + ) { + toast.error('컬럼은 최대 10개까지 생성 가능합니다') + } else { + toast.error(serverMessage || '컬럼 생성 중 오류가 발생했습니다') + } + }, + }) +} diff --git a/src/app/features/dashboard_Id/hooks/useDeleteColumn.ts b/src/app/features/dashboard_Id/hooks/useDeleteColumn.ts new file mode 100644 index 0000000..93fc9d2 --- /dev/null +++ b/src/app/features/dashboard_Id/hooks/useDeleteColumn.ts @@ -0,0 +1,35 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { AxiosError } from 'axios' +import { toast } from 'sonner' + +import { deleteColumn } from '../api/deleteColumn' +import { DeleteColumnVariables } from '../type/Column.type' + +export function useDeleteColumn() { + const queryClient = useQueryClient() + + return useMutation< + void, + AxiosError<{ message?: string }>, + DeleteColumnVariables + >({ + mutationFn: ({ columnId }) => deleteColumn(columnId), + onSuccess: (data, variables) => { + // 컬럼 목록 캐시 무효화 + queryClient.invalidateQueries({ + queryKey: ['columns', variables.dashboardId], + }) + + // 해당 컬럼의 카드 캐시도 무효화 (컬럼이 삭제되었으므로) + queryClient.invalidateQueries({ + queryKey: ['columnId', variables.columnId], + }) + + toast.success('컬럼이 삭제되었습니다!') + }, + onError: (error) => { + const serverMessage = error.response?.data?.message + toast.error(serverMessage || '컬럼 삭제 중 오류가 발생했습니다') + }, + }) +} diff --git a/src/app/features/dashboard_Id/hooks/useUpdateColumn.ts b/src/app/features/dashboard_Id/hooks/useUpdateColumn.ts new file mode 100644 index 0000000..e46f79b --- /dev/null +++ b/src/app/features/dashboard_Id/hooks/useUpdateColumn.ts @@ -0,0 +1,40 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { AxiosError } from 'axios' +import { toast } from 'sonner' + +import { updateColumn } from '../api/updateColumn' +import { + UpdateColumnResponse, + UpdateColumnVariables, +} from '../type/Column.type' + +export function useUpdateColumn() { + const queryClient = useQueryClient() + + return useMutation< + UpdateColumnResponse, + AxiosError<{ message?: string }>, + UpdateColumnVariables + >({ + mutationFn: ({ columnId, title }) => updateColumn(columnId, { title }), + onSuccess: (data, variables) => { + // 컬럼 목록 캐시 무효화 + queryClient.invalidateQueries({ + queryKey: ['columns', variables.dashboardId], + }) + toast.success('컬럼이 수정되었습니다!') + }, + onError: (error) => { + const serverMessage = error.response?.data?.message + + if ( + serverMessage?.includes('중복') || + serverMessage?.includes('duplicate') + ) { + toast.error('중복된 컬럼 이름입니다') + } else { + toast.error(serverMessage || '컬럼 수정 중 오류가 발생했습니다') + } + }, + }) +} diff --git a/src/app/features/dashboard_Id/store/useColumnModalStore.ts b/src/app/features/dashboard_Id/store/useColumnModalStore.ts new file mode 100644 index 0000000..33b959d --- /dev/null +++ b/src/app/features/dashboard_Id/store/useColumnModalStore.ts @@ -0,0 +1,23 @@ +import { create } from 'zustand' + +type ColumnModalType = 'create' | 'edit' | 'deleteConfirm' | null + +interface ColumnModalData { + columnId?: number + columnTitle?: string + dashboardId: number +} + +interface ColumnModalState { + modalType: ColumnModalType + modalData: ColumnModalData | null + openModal: (type: ColumnModalType, data: ColumnModalData) => void + closeModal: () => void +} + +export const useColumnModalStore = create((set) => ({ + modalType: null, + modalData: null, + openModal: (type, data) => set({ modalType: type, modalData: data }), + closeModal: () => set({ modalType: null, modalData: null }), +})) diff --git a/src/app/features/dashboard_Id/type/Column.type.ts b/src/app/features/dashboard_Id/type/Column.type.ts index 0c736f9..84bcf78 100644 --- a/src/app/features/dashboard_Id/type/Column.type.ts +++ b/src/app/features/dashboard_Id/type/Column.type.ts @@ -7,5 +7,54 @@ export interface Column { updatedAt: string } export interface ColumnsResponse { + result: string // "SUCCESS" data: Column[] } + +// 컬럼 생성 요청 (POST body) +export interface CreateColumnRequest { + title: string + dashboardId: number +} + +// 컬럼 생성 응답 (POST response) +export interface CreateColumnResponse { + id: number + title: string + teamId: string + createdAt: string + updatedAt: string +} + +// 컬럼 수정 요청 (PUT body) +export interface UpdateColumnRequest { + title: string +} + +// 컬럼 수정 응답 (PUT response) +export interface UpdateColumnResponse { + id: number + title: string + teamId: string + createdAt: string + updatedAt: string +} + +// 훅에서 사용할 변수 타입들 +export interface UpdateColumnVariables { + columnId: number + title: string + dashboardId: number // 캐시 무효화용 +} + +export interface DeleteColumnVariables { + columnId: number + dashboardId: number +} + +// 컬럼 모달용 데이터 타입 +export interface ColumnModalData { + columnId?: number + columnTitle?: string + dashboardId: number +}