diff --git a/public/images/management.png b/public/images/management.png deleted file mode 100644 index 1990728..0000000 Binary files a/public/images/management.png and /dev/null differ diff --git a/src/app/dashboard/[id]/edit/page.tsx b/src/app/dashboard/[id]/edit/page.tsx index 43864a9..0f1b81d 100644 --- a/src/app/dashboard/[id]/edit/page.tsx +++ b/src/app/dashboard/[id]/edit/page.tsx @@ -3,7 +3,6 @@ import EditInfo from '@dashboard/components/edit/EditInfo' import EditInvitation from '@dashboard/components/edit/EditInvitation' import EditMember from '@dashboard/components/edit/EditMember' -import { showError, showSuccess } from '@lib/toast' import Image from 'next/image' import { useParams } from 'next/navigation' import { useRouter } from 'next/navigation' @@ -14,23 +13,15 @@ export default function DashBoardEditPage() { const { id } = useParams() const router = useRouter() - const handleSuccess = () => { - showSuccess('대시보드가 성공적으로 수정되었습니다.') - } - - const handleError = () => { - showError('수정 중 오류가 발생했습니다.') - } - return (
diff --git a/src/app/features/dashboard/components/edit/DeleteDashboardButton.tsx b/src/app/features/dashboard/components/edit/DeleteDashboardButton.tsx index c190631..f0b5764 100644 --- a/src/app/features/dashboard/components/edit/DeleteDashboardButton.tsx +++ b/src/app/features/dashboard/components/edit/DeleteDashboardButton.tsx @@ -1,9 +1,12 @@ 'use client' import api from '@lib/axios' +import { showError, showSuccess } from '@lib/toast' +import { useMutation } from '@tanstack/react-query' import axios from 'axios' import { useRouter } from 'next/navigation' -import React, { useState } from 'react' +import React from 'react' +import { toast } from 'sonner' type DeleteDashboardButtonProps = { dashboardId: string @@ -13,52 +16,52 @@ export default function DeleteDashboardButton({ dashboardId, }: DeleteDashboardButtonProps) { const router = useRouter() - const [isDeleting, setIsDeleting] = useState(false) - - console.log('DeleteDashboardButton 렌더됨:', dashboardId) - - const handleDelete = async () => { - const confirmed = confirm( - '정말로 이 대시보드를 삭제하시겠습니까? 삭제 후 되돌릴 수 없습니다.', - ) - - if (!confirmed) return - - try { - setIsDeleting(true) + const mutation = useMutation({ + mutationFn: async () => { if (!process.env.NEXT_PUBLIC_TEAM_ID) { throw new Error('NEXT_PUBLIC_TEAM_ID 환경변수가 설정되지 않았습니다.') } - await api.delete( `/${process.env.NEXT_PUBLIC_TEAM_ID}/dashboards/${dashboardId}`, ) - - // 삭제 후 대시보드 목록 페이지로 이동 + }, + onSuccess: () => { router.push('/dashboard') - } catch (error: unknown) { + showSuccess('대시보드가 삭제되었습니다') + }, + onError: (error) => { if (axios.isAxiosError(error)) { const message = error.response?.data?.message || '대시보드 삭제 중 오류가 발생했습니다.' - console.error('대시보드 삭제 오류:', message) - alert(message) // 또는 showError(message) 등으로 사용자에게 표시 + showError(message) } else { - console.error('대시보드 삭제 오류:', error) - alert('알 수 없는 오류가 발생했습니다.') + showError('알 수 없는 오류가 발생했습니다.') } - } finally { - setIsDeleting(false) - } + }, + }) + + // sonner로 삭제 확인 토스트 구현 + function handleDelete() { + toast('대시보드를 삭제하시겠습니까?', { + description: '삭제 후 되돌릴 수 없습니다.', + action: { + label: '삭제하기', + onClick: () => mutation.mutate(), + }, + }) } return ( - ))} -
-
- - {/* 하단 버튼 */} -
- -
- + ) diff --git a/src/app/features/dashboard/components/edit/EditInvitation.tsx b/src/app/features/dashboard/components/edit/EditInvitation.tsx index 8e29ec1..3f732e1 100644 --- a/src/app/features/dashboard/components/edit/EditInvitation.tsx +++ b/src/app/features/dashboard/components/edit/EditInvitation.tsx @@ -1,117 +1,178 @@ -import { UserInfo } from '@components/common/UserInfo' +'use client' + +import Tooltip from '@components/common/header/Collaborator/Tooltip' +import api from '@lib/axios' import { cn } from '@lib/cn' +import { showError, showSuccess } from '@lib/toast' +import { useModalStore } from '@store/useModalStore' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import axios from 'axios' import Image from 'next/image' -import { usePathname } from 'next/navigation' -import React from 'react' +import { useParams } from 'next/navigation' +import React, { useState } from 'react' -import { useModalStore } from '@/app/shared/store/useModalStore' +import { getTeamId } from '@/app/shared/lib/getTeamId' -import { mockMembers } from './mockMember' +import { PaginationHeader } from './PaginationHeader' -const PAGE_SIZE = 5 // 페이지당 표시할 초대 내역 수 +const INVITATION_SIZE = 5 +const teamId = getTeamId() + +interface Invitation { + id: number + invitee: { + nickname: string + email: string + } +} export default function EditInvitation() { - const pathname = usePathname() + const params = useParams() const { openModal } = useModalStore() - const [currentPage, setCurrentPage] = React.useState(1) - const totalPages = Math.ceil(mockMembers.length / PAGE_SIZE) - const startIndex = (currentPage - 1) * PAGE_SIZE - const paginationMembers = mockMembers.slice( - startIndex, - startIndex + PAGE_SIZE, + + const dashboardId = params.id as string + const queryClient = useQueryClient() + + const [currentPage, setCurrentPage] = useState(1) + + const { + data: invitations = [], + isLoading, + isError, + error, + } = useQuery({ + queryKey: ['invitations', teamId, dashboardId], + queryFn: async () => { + if (!teamId || !dashboardId) return [] + const res = await api.get<{ invitations: Invitation[] }>( + `/${teamId}/dashboards/${dashboardId}/invitations`, + ) + return res.data.invitations + }, + enabled: !!teamId && !!dashboardId, + retry: false, + }) + + // length가 0인 경우에도 최소 페이지 1로 보장 + const totalPages = Math.max( + 1, + Math.ceil(invitations.length / INVITATION_SIZE), ) - const handlePrev = () => { - if (currentPage > 1) { - setCurrentPage((prev) => prev - 1) - } - } + const currentItems = invitations.slice( + (currentPage - 1) * INVITATION_SIZE, + currentPage * INVITATION_SIZE, + ) - const handleNext = () => { - if (currentPage < totalPages) { - setCurrentPage((prev) => prev + 1) - } - } + const cancelMutation = useMutation({ + mutationFn: async (invitationId: number) => { + if (!teamId || !dashboardId) + throw new Error('teamId 또는 dashboardId가 없습니다.') + await api.delete( + `/${teamId}/dashboards/${dashboardId}/invitations/${invitationId}`, + ) + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['invitations', teamId, dashboardId], + }) + showSuccess('초대가 취소되었습니다.') + }, + onError: (error) => { + showError(error.message) + }, + }) + + const handlePrev = () => setCurrentPage((p) => Math.max(p - 1, 1)) + const handleNext = () => setCurrentPage((p) => Math.min(p + 1, totalPages)) + + // 에러 메시지 정리 + const errorMessage = + isError && axios.isAxiosError(error) + ? error.response?.status === 403 + ? '초대 권한이 없습니다.' + : '초대 정보를 불러오는 데 실패했습니다.' + : isError + ? '초대 정보를 불러오는 데 실패했습니다.' + : null return (
- {/* 컨테이너 */} -
-
-

초대 내역

- -
-

- {totalPages} 페이지 중 {currentPage} -

- - - -
-
+
+ + +
- {paginationMembers.map((member, index) => { - const isLast = index === paginationMembers.length - 1 - return ( -
- - -
- ) - })} + {isLoading && ( +

로딩 중...

+ )} + + {errorMessage && ( +

{errorMessage}

+ )} + + {!isLoading && !errorMessage && currentItems.length === 0 && ( +

+ 초대된 사용자가 없습니다. +

+ )} + + {!isLoading && + !errorMessage && + currentItems.map((member, index) => { + const isLast = index === currentItems.length - 1 + return ( +
+
+
+ +

+ {member.invitee.email} +

+
+
+
+ + +
+ ) + })}
diff --git a/src/app/features/dashboard/components/edit/EditMember.tsx b/src/app/features/dashboard/components/edit/EditMember.tsx index b5ed11d..ba12246 100644 --- a/src/app/features/dashboard/components/edit/EditMember.tsx +++ b/src/app/features/dashboard/components/edit/EditMember.tsx @@ -1,99 +1,134 @@ +'use client' + +import authHttpClient from '@lib/axios' +import { cn } from '@lib/cn' +import { getTeamId } from '@lib/getTeamId' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import Image from 'next/image' -import React from 'react' -import { useState } from 'react' +import { useParams } from 'next/navigation' +import React, { useState } from 'react' import { UserInfo } from '@/app/shared/components/common/UserInfo' +import { fetchMembers, Member } from '@/app/shared/hooks/useMembers' +import { showError, showSuccess } from '@/app/shared/lib/toast' + +import { PaginationHeader } from './PaginationHeader' -import { mockMembers } from './mockMember' +const PAGE_SIZE = 4 +const teamId = getTeamId() -const PAGE_SIZE = 4 // 페이지당 표시할 구성원 수 +async function deleteMember(memberId: number): Promise { + await authHttpClient.delete(`/${teamId}/members/${memberId}`) +} export default function EditMember() { + const queryClient = useQueryClient() + const { id: dashboardId } = useParams() + const dashboardIdStr = String(dashboardId) + const [currentPage, setCurrentPage] = useState(1) - const totalPages = Math.ceil(mockMembers.length / PAGE_SIZE) - const startIndex = (currentPage - 1) * PAGE_SIZE - const paginationMembers = mockMembers.slice( - startIndex, - startIndex + PAGE_SIZE, - ) + const { + data: members = [], + isLoading, + isError, + } = useQuery({ + queryKey: ['members', dashboardIdStr], + queryFn: () => fetchMembers(dashboardIdStr), + enabled: !!dashboardIdStr, + }) + + // 본인이 구성원으로 들어가기 때문에 0 페이지일 경우 X + const totalPages = Math.ceil(members.length / PAGE_SIZE) + const startIdx = (currentPage - 1) * PAGE_SIZE + const paginationMembers = members.slice(startIdx, startIdx + PAGE_SIZE) - const handlePrev = () => { - if (currentPage > 1) { - setCurrentPage((prev) => prev - 1) - } + function handlePrev() { + setCurrentPage((prev) => Math.max(prev - 1, 1)) } - const handleNext = () => { - if (currentPage < totalPages) { - setCurrentPage((prev) => prev + 1) - } + function handleNext() { + setCurrentPage((prev) => Math.min(prev + 1, totalPages)) } + const { mutate: removeMember, isPending: isDeleting } = useMutation({ + mutationFn: (memberId: number) => deleteMember(memberId), + onSuccess: () => { + showSuccess('삭제에 성공하였습니다.') + queryClient.invalidateQueries({ queryKey: ['members', dashboardIdStr] }) + }, + onError: () => { + showError('삭제에 실패했습니다.') + }, + }) + return (
- {/* 컨테이너 */}
-
-

구성원

- -
-

- {totalPages} 페이지 중 {currentPage} -

- - -
-
+
-
- {paginationMembers.map((member, index) => { - // 해당 페이지 중 마지막 요소인 경우 border-bottom 미적용 + + {isLoading &&
로딩 중...
} + + {isError && ( +
+ 멤버 정보를 불러오는 데 실패했습니다. +
+ )} + + {!isLoading && + !isError && + paginationMembers.map((member, index) => { const isLast = index === paginationMembers.length - 1 + const isOwner = member.isOwner === true + return (
- + {isOwner && ( + 왕관 아이콘 + )} + {!isOwner && ( + + )}
) })} -
diff --git a/src/app/features/dashboard/components/edit/PaginationHeader.tsx b/src/app/features/dashboard/components/edit/PaginationHeader.tsx new file mode 100644 index 0000000..4a555b7 --- /dev/null +++ b/src/app/features/dashboard/components/edit/PaginationHeader.tsx @@ -0,0 +1,59 @@ +'use client' + +import Image from 'next/image' +import React from 'react' + +type PaginationHeaderProps = { + currentPage: number + totalPages: number + title: string + onPrev: () => void + onNext: () => void + children?: React.ReactNode +} + +export function PaginationHeader({ + currentPage, + totalPages, + title, + onPrev, + onNext, + children, +}: PaginationHeaderProps) { + return ( +
+

{title}

+ +
+

+ {totalPages} 페이지 중 {currentPage} +

+ + + {children &&
{children}
} +
+
+ ) +} diff --git a/src/app/features/dashboard/components/edit/mockMember.js b/src/app/features/dashboard/components/edit/mockMember.js deleted file mode 100644 index 83c5a2b..0000000 --- a/src/app/features/dashboard/components/edit/mockMember.js +++ /dev/null @@ -1,28 +0,0 @@ -// mockMember.js - -export const mockMembers = [ - { - nickname: '민준', - imageUrl: '/images/profile.gif', - }, - { - nickname: '서연', - imageUrl: null, - }, - { - nickname: 'James', - imageUrl: null, - }, - { - nickname: '나연 ', - imageUrl: '/images/profile.gif', - }, - { - nickname: 'Emily', - imageUrl: null, - }, - { - nickname: 'Jenny', - imageUrl: null, - }, -] diff --git a/src/app/shared/components/common/Avatar.tsx b/src/app/shared/components/common/Avatar.tsx index 056e2ae..8b9904b 100644 --- a/src/app/shared/components/common/Avatar.tsx +++ b/src/app/shared/components/common/Avatar.tsx @@ -1,10 +1,10 @@ 'use client' +import { getColor } from '@lib/getColor' // 경로는 실제 위치에 맞게 조정 import Image from 'next/image' import { useRef } from 'react' import { useAuthStore } from '@/app/features/auth/store/useAuthStore' -import { getColor } from '@/app/shared/lib/getColor' type AvatarProps = { size?: number @@ -46,7 +46,6 @@ export function Avatar({ size = 36, name, imageUrl }: AvatarProps) { profileImageUrl.current = user.profileImageUrl } - const initial = getInitial(nickname.current) const bgColor = getColor(nickname.current, customColors.length) return profileImageUrl.current ? ( @@ -71,7 +70,7 @@ export function Avatar({ size = 36, name, imageUrl }: AvatarProps) { backgroundColor: customColors[bgColor], }} > - {initial} + {getInitial(name)}
) } diff --git a/src/app/shared/components/common/CollaboratorItem.tsx b/src/app/shared/components/common/CollaboratorItem.tsx index cc65c49..096deee 100644 --- a/src/app/shared/components/common/CollaboratorItem.tsx +++ b/src/app/shared/components/common/CollaboratorItem.tsx @@ -6,7 +6,7 @@ import { Avatar } from './Avatar' type CollaboratorItemProps = { nickname: string - imageUrl?: string + imageUrl?: string | null size?: number className?: string onClick?: () => void @@ -21,7 +21,7 @@ export default function CollaboratorItem({ }: CollaboratorItemProps) { return (
- +
) } diff --git a/src/app/shared/components/common/UserInfo.tsx b/src/app/shared/components/common/UserInfo.tsx index 118bab8..30e8418 100644 --- a/src/app/shared/components/common/UserInfo.tsx +++ b/src/app/shared/components/common/UserInfo.tsx @@ -5,20 +5,24 @@ import { useAuthStore } from '@/app/features/auth/store/useAuthStore' import { Avatar } from './Avatar' type UserInfoProps = { - nickname: string + nickname?: string imageUrl?: string | null size?: number } -export function UserInfo({ size = 36 }: UserInfoProps) { +export function UserInfo({ nickname, imageUrl, size = 36 }: UserInfoProps) { const user = useAuthStore((state) => state.user) - if (!user) return null // 또는 로딩 중 표시나 기본 UI + const displayNickname = nickname ?? user?.nickname ?? '' + const displayImage = imageUrl ?? user?.profileImageUrl ?? null + + if (!displayNickname) return null // 사용자 정보가 없는 경우 렌더링하지 않음 return (
- - {user.nickname} + {/* Avatar에 nickname, profileImageUrl 모두 넘겨줌 */} + + {displayNickname}
) } diff --git a/src/app/shared/components/common/header/Collaborator/CollaboratorList.tsx b/src/app/shared/components/common/header/Collaborator/CollaboratorList.tsx index 1543cf6..47d6bbd 100644 --- a/src/app/shared/components/common/header/Collaborator/CollaboratorList.tsx +++ b/src/app/shared/components/common/header/Collaborator/CollaboratorList.tsx @@ -1,57 +1,56 @@ -'use client' // Tooltip, CollaboratorItem 컴포넌트는 클라이언트에서만 사용되므로 'use client' 선언 +'use client' + +import { fetchMembers, Member } from '@hooks/useMembers' +import { useQuery } from '@tanstack/react-query' +import { useParams } from 'next/navigation' +import React from 'react' import CollaboratorItem from '../../CollaboratorItem' -import Tooltip from './Tooltip' // 툴팁 기능 - -// 임시 mock 협업자 데이터 -export const mockCollaborators = [ - { nickname: '홍길동', imageUrl: '/images/collaborator.png' }, - { nickname: '김철수', imageUrl: '/images/collaborator.png' }, - { nickname: '이영희', imageUrl: '/images/collaborator.png' }, - { nickname: '뚜비', imageUrl: '/images/collaborator.png' }, - { nickname: '두룹', imageUrl: '/images/collaborator.png' }, - { nickname: 'ㅋ', imageUrl: '/images/collaborator.png' }, -] - -// 협업자 배열을 선택적으로 전달받음 -type CollaboratorListProps = { - collaborators?: { - nickname: string - imageUrl?: string - }[] -} +import Tooltip from './Tooltip' + +const MAX_COLLABS = 4 + +export default function CollaboratorList() { + const { id: dashboardId } = useParams() + const dashboardIdStr = String(dashboardId) -// 협업자 리스트 컴포넌트 -export default function CollaboratorList({ - collaborators = mockCollaborators, // 전달된 협업자가 없을 경우 mock 데이터 사용 -}: CollaboratorListProps) { - const MAX_VISIBLE = 4 // 최대 표시 협업자 수 - const visibleCollaborators = collaborators.slice(0, MAX_VISIBLE) // 앞에서부터 4명만 추출 - const extraCount = collaborators.length - MAX_VISIBLE // 초과 인원 계산 + const { + data: members = [], + isLoading, + isError, + } = useQuery({ + queryKey: ['members', dashboardIdStr], + queryFn: () => fetchMembers(dashboardIdStr), + enabled: !!dashboardIdStr, + }) + + if (isLoading && isError) return null + + // 프로필 이미지 및 닉네임만 필요한 경우 + const visibleCollaborators = members.slice(0, MAX_COLLABS) + const extraCount = members.length - MAX_COLLABS return (
- {/* 협업자들 가로 배치 & 간격 설정 */} {visibleCollaborators.map((collab) => ( - // 각 협업자에 툴팁을 감싸서 닉네임 표시 - +
))} - {/* 초과 인원이 있을 경우 +N 표시 */} + {extraCount > 0 && (
- +{extraCount} {/* 초과 인원 수 출력 */} + +{extraCount}
diff --git a/src/app/shared/components/common/header/NavItem.tsx b/src/app/shared/components/common/header/NavItem.tsx index 0dac158..705a0c2 100644 --- a/src/app/shared/components/common/header/NavItem.tsx +++ b/src/app/shared/components/common/header/NavItem.tsx @@ -20,14 +20,13 @@ export default function NavItem({ onClick, iconSrc, label, - active, className, }: NavItemProps) { const content = ( + // 정적인 클래스만 쓸 경우 cn을 쓰지 않아도 되지만 외부에서 className 받는 경우 사용 권장
diff --git a/src/app/shared/components/common/header/RightHeaderNav.tsx b/src/app/shared/components/common/header/RightHeaderNav.tsx index bff972f..4465675 100644 --- a/src/app/shared/components/common/header/RightHeaderNav.tsx +++ b/src/app/shared/components/common/header/RightHeaderNav.tsx @@ -2,30 +2,26 @@ import { useModalStore } from '@store/useModalStore' import { useSelectedDashboardStore } from '@store/useSelectedDashboardStore' -import { usePathname } from 'next/navigation' import NavItem from './NavItem' export default function RightHeaderNav() { - const pathname = usePathname() - const { modalType, openModal } = useModalStore() + const { openModal } = useModalStore() const { selectedDashboard } = useSelectedDashboardStore() return ( -
} width="w-6" diff --git a/src/app/shared/components/common/modal/CreateDashboardModal.tsx b/src/app/shared/components/common/modal/CreateDashboardModal.tsx index e80c28a..7e26ccc 100644 --- a/src/app/shared/components/common/modal/CreateDashboardModal.tsx +++ b/src/app/shared/components/common/modal/CreateDashboardModal.tsx @@ -1,82 +1,34 @@ 'use client' -import { DASHBOARD_COLORS } from '@constants/colors' -import authHttpClient from '@lib/axios' import { useModalStore } from '@store/useModalStore' -import Image from 'next/image' -import { useRouter } from 'next/navigation' -import React, { useEffect, useState } from 'react' +import React, { useEffect } from 'react' -import { CreateDashboardRequest } from '@/types/dashboard' +import { useDashboardForm } from '@/app/shared/hooks/useDashboardForm' + +import DashboardForm from '../../dashboard/DashboardForm' export default function CreateDashboardModal() { - const router = useRouter() const { modalType, closeModal } = useModalStore() const isModalOpen = modalType === 'createDashboard' - const [formData, setFormData] = useState({ - title: '', - color: DASHBOARD_COLORS[0], - }) - - const [isSubmitting, setIsSubmitting] = useState(false) + const { + formData, + isSubmitting, + handleChange, + handleColorSelect, + handleSubmit, + resetForm, + } = useDashboardForm('create') + // 모달 닫힐 때 form 초기화 useEffect(() => { if (!isModalOpen) { - setFormData({ title: '', color: DASHBOARD_COLORS[0] }) - setIsSubmitting(false) - } - }, [isModalOpen]) - - if (!isModalOpen) { - return null - } - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - - if (!formData.title || !formData.color) { - return - } - try { - setIsSubmitting(true) - - if (!process.env.NEXT_PUBLIC_TEAM_ID) { - throw new Error('NEXT_PUBLIC_TEAM_ID 환경변수가 설정되지 않았습니다.') - } - - const response = await authHttpClient.post( - `/${process.env.NEXT_PUBLIC_TEAM_ID}/dashboards`, - formData, - ) - - const data = response.data - - // 성공 시 대시보드 상세 페이지로 이동 - router.push(`/dashboard/${data.id}`) - closeModal() - } catch (error) { - console.error('대시보드 생성 오류:', error) - } finally { - setIsSubmitting(false) + resetForm() } - } - - // 입력값 변경 처리 - const handleChange = (e: React.ChangeEvent) => { - const { name, value } = e.target - setFormData((prev) => ({ - ...prev, - [name]: value, - })) - } + }, [isModalOpen, resetForm]) - // 색상 선택 처리 - const handleColorSelect = (color: string) => { - setFormData((prev) => ({ ...prev, color })) - } + if (!isModalOpen) return null - // 모달 외부 클릭 시 닫기 const handleBackdropClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) { closeModal() @@ -84,83 +36,25 @@ export default function CreateDashboardModal() { } return ( - // 모달 백드롭
- {/* 모달 컨테이너 */}

새로운 대시보드

- -
- {/* 제목 입력 */} -
- - -
- - {/* 색상 선택 */} -
-
- {DASHBOARD_COLORS.map((color) => ( - - ))} -
-
- - {/* 하단 버튼 */} -
- - -
-
+ { + handleSubmit(e) + closeModal() + }} + isSubmitting={isSubmitting} + submitText="생성" + showCancelButton + onCancel={closeModal} + />
) diff --git a/src/app/shared/components/common/modal/CreateInvitationModal.tsx b/src/app/shared/components/common/modal/CreateInvitationModal.tsx index c4dbc0c..bfebe02 100644 --- a/src/app/shared/components/common/modal/CreateInvitationModal.tsx +++ b/src/app/shared/components/common/modal/CreateInvitationModal.tsx @@ -2,6 +2,7 @@ import { inviteUser } from '@dashboard/api/invitation' import { useModalStore } from '@store/useModalStore' +import { useQueryClient } from '@tanstack/react-query' import { AxiosError } from 'axios' import { useParams } from 'next/navigation' import { useState } from 'react' @@ -12,14 +13,16 @@ export default function CreateInvitationModal() { const { modalType, closeModal } = useModalStore() const [email, setEmail] = useState('') const { id: dashboardId } = useParams() + const teamId = process.env.NEXT_PUBLIC_TEAM_ID ?? '' + const queryClient = useQueryClient() - const handleBackdropClick = (e: React.MouseEvent) => { + function handleBackdropClick(e: React.MouseEvent) { if (e.target === e.currentTarget) { closeModal() } } - const handleSubmit = async (e: React.FormEvent) => { + async function handleSubmit(e: React.FormEvent) { e.preventDefault() if (!email) return @@ -27,17 +30,23 @@ export default function CreateInvitationModal() { if (!dashboardId) { throw new Error('대시보드 ID가 없습니다.') } + await inviteUser({ email, dashboardId: Number(dashboardId) }) + + // ✅ 초대 후 캐시 무효화 (초대 내역 새로고침) + queryClient.invalidateQueries({ + queryKey: ['invitations', teamId, String(dashboardId)], + }) + showSuccess('초대가 완료되었습니다.') closeModal() } catch (err: unknown) { - // 에러 타입 안정성을 위해 axios 에러 타입으로 캐스팅 const error = err as AxiosError<{ message: string }> showError(error?.response?.data?.message || '초대에 실패하였습니다.') } } - if (!modalType) return null + if (modalType !== 'invite') return null return (
diff --git a/src/app/shared/components/dashboard/DashboardForm.tsx b/src/app/shared/components/dashboard/DashboardForm.tsx new file mode 100644 index 0000000..1788d25 --- /dev/null +++ b/src/app/shared/components/dashboard/DashboardForm.tsx @@ -0,0 +1,99 @@ +'use client' + +import Image from 'next/image' +import React from 'react' + +import { DASHBOARD_COLORS } from '@/app/shared/constants/colors' + +import Input from '../Input' + +type DashboardFormProps = { + formData: { title: string; color: string } + onChange: (e: React.ChangeEvent) => void + onColorSelect: (color: string) => void + onSubmit: (e: React.FormEvent) => void + isSubmitting: boolean + submitText: string + submitButtonWidth?: string + showCancelButton?: boolean + onCancel?: () => void +} + +export default function DashboardForm({ + formData, + onChange, + onColorSelect, + onSubmit, + isSubmitting, + submitText, + submitButtonWidth = 'w-256', + showCancelButton = false, + onCancel, +}: DashboardFormProps) { + return ( +
+ {/* 제목 */} +
+ +
+ + {/* 색상 */} +
+
+ {DASHBOARD_COLORS.map((color) => ( + + ))} +
+
+ + {/* 버튼 */} +
+ {showCancelButton && ( + + )} + +
+
+ ) +} diff --git a/src/app/shared/hooks/useDashboard.ts b/src/app/shared/hooks/useDashboard.ts index 4281181..8cd6563 100644 --- a/src/app/shared/hooks/useDashboard.ts +++ b/src/app/shared/hooks/useDashboard.ts @@ -1,10 +1,10 @@ // hooks/useDashboard.ts 'use client' +import api from '@lib/axios' import { useQuery } from '@tanstack/react-query' -import api from '@/app/shared/lib/axios' -import { DashboardListResponse } from '@/app/shared/types/dashboard' +import { DashboardListResponse } from '@/types/dashboard' export const useDashboard = () => { return useQuery({ diff --git a/src/app/shared/hooks/useDashboardForm.ts b/src/app/shared/hooks/useDashboardForm.ts new file mode 100644 index 0000000..bb528d5 --- /dev/null +++ b/src/app/shared/hooks/useDashboardForm.ts @@ -0,0 +1,109 @@ +// hooks/useDashboardForm.ts +import { DASHBOARD_COLORS } from '@constants/colors' +import api from '@lib/axios' +import { getTeamId } from '@lib/getTeamId' +import { showError } from '@lib/toast' +import { useSelectedDashboardStore } from '@store/useSelectedDashboardStore' +import { useQueryClient } from '@tanstack/react-query' +import axios from 'axios' +import { useRouter } from 'next/navigation' +import { useEffect, useState } from 'react' + +import { CreateDashboardRequest } from '@/types/dashboard' + +type Mode = 'create' | 'edit' + +const teamId = getTeamId() + +export function useDashboardForm(mode: Mode) { + const router = useRouter() + const queryClient = useQueryClient() + const { selectedDashboard, setSelectedDashboard } = + useSelectedDashboardStore() + + const [formData, setFormData] = useState({ + title: '', + color: DASHBOARD_COLORS[0], + }) + const [isSubmitting, setIsSubmitting] = useState(false) + + // 수정 모드일 때 초기값 설정 + useEffect(() => { + if (mode === 'edit' && selectedDashboard) { + setFormData({ + title: selectedDashboard.title, + color: selectedDashboard.color, + }) + } + }, [mode, selectedDashboard]) + + // 대시보드 생성 모달 폼 초기화 + const resetForm = () => { + setFormData({ + title: '', + color: DASHBOARD_COLORS[0], + }) + setIsSubmitting(false) + } + + // 입력값 변경 핸들러 + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target + setFormData((prev) => ({ + ...prev, + [name]: value, + })) + } + + // 색상 선택 핸들러 + const handleColorSelect = (color: string) => { + setFormData((prev) => ({ ...prev, color })) + } + + // 폼 제출 핸들러 + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!formData.title || !formData.color) return + + try { + setIsSubmitting(true) + + if (mode === 'create') { + const response = await api.post(`/${teamId}/dashboards`, formData) + router.push(`/dashboard/${response.data.id}`) + } else if (mode === 'edit' && selectedDashboard?.id) { + const response = await api.put( + `/${teamId}/dashboards/${selectedDashboard.id}`, + formData, + ) + const data = response.data + setSelectedDashboard(data) + + // 캐시 무효화 및 페이지 강제 갱신 + await queryClient.invalidateQueries({ queryKey: ['dashboards'] }) + router.refresh() + + router.push(`/dashboard/${data.id}/edit`) + } + } catch (error) { + const message = axios.isAxiosError(error) + ? error.response?.data?.message || + '대시보드 요청 중 오류가 발생했습니다.' + : '알 수 없는 오류가 발생했습니다.' + showError(message) + } finally { + setIsSubmitting(false) + } + } + + return { + formData, + isSubmitting, + handleChange, + handleColorSelect, + handleSubmit, + resetForm, + selectedDashboard, + } +} diff --git a/src/app/shared/hooks/useMembers.ts b/src/app/shared/hooks/useMembers.ts new file mode 100644 index 0000000..1fd6bf4 --- /dev/null +++ b/src/app/shared/hooks/useMembers.ts @@ -0,0 +1,26 @@ +'use client' + +import authHttpClient from '@lib/axios' +import { getTeamId } from '@lib/getTeamId' + +export type Member = { + id: number + email: string + nickname: string + profileImageUrl: string | null + isOwner: boolean | string + userId: number +} + +const teamId = getTeamId() + +export async function fetchMembers(dashboardId: string): Promise { + const { data } = await authHttpClient.get(`/${teamId}/members`, { + params: { + page: 1, + size: 100, + dashboardId, + }, + }) + return data.members ?? data +} diff --git a/src/app/shared/hooks/usePagination.ts b/src/app/shared/hooks/usePagination.ts new file mode 100644 index 0000000..8838112 --- /dev/null +++ b/src/app/shared/hooks/usePagination.ts @@ -0,0 +1,33 @@ +import { useMemo, useState } from 'react' + +export function usePagination(items: T[], pageSize: number) { + const [currentPage, setCurrentPage] = useState(1) + + const totalPages = Math.ceil(items.length / pageSize) + + const startIndex = (currentPage - 1) * pageSize + + const currentItems = useMemo(() => { + return items.slice(startIndex, startIndex + pageSize) + }, [items, startIndex, pageSize]) + + function handlePrev() { + if (currentPage > 1) { + setCurrentPage((prev) => prev - 1) + } + } + + function handleNext() { + if (currentPage < totalPages) { + setCurrentPage((prev) => prev + 1) + } + } + + return { + currentPage, + totalPages, + currentItems, + handlePrev, + handleNext, + } +} diff --git a/src/app/shared/lib/getTeamId.ts b/src/app/shared/lib/getTeamId.ts new file mode 100644 index 0000000..549543a --- /dev/null +++ b/src/app/shared/lib/getTeamId.ts @@ -0,0 +1,7 @@ +export function getTeamId(): string { + const teamId = process.env.NEXT_PUBLIC_TEAM_ID + if (!teamId) { + throw new Error('NEXT_PUBLIC_TEAM_ID가 환경 변수에 설정되지 않았습니다.') + } + return teamId +} diff --git a/src/app/shared/store/useModalStore.ts b/src/app/shared/store/useModalStore.ts index 51a7348..7837df3 100644 --- a/src/app/shared/store/useModalStore.ts +++ b/src/app/shared/store/useModalStore.ts @@ -4,7 +4,7 @@ import { create } from 'zustand' import { ModalState } from '@/types/modal' export const useModalStore = create((set) => ({ - modalType: null, + modalType: 'none', openModal: (type) => set({ modalType: type }), - closeModal: () => set({ modalType: null }), + closeModal: () => set({ modalType: 'none' }), }))