diff --git a/public/images/Rectangle.svg b/public/images/Rectangle.svg new file mode 100644 index 0000000..4c0f3fc --- /dev/null +++ b/public/images/Rectangle.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/arrow.svg b/public/images/arrow.svg new file mode 100644 index 0000000..d9aeb59 --- /dev/null +++ b/public/images/arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/chip.svg b/public/images/chip.svg new file mode 100644 index 0000000..d63c2e3 --- /dev/null +++ b/public/images/chip.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/search.svg b/public/images/search.svg new file mode 100644 index 0000000..ba6aff8 --- /dev/null +++ b/public/images/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/unsubscribe.svg b/public/images/unsubscribe.svg new file mode 100644 index 0000000..4dc1d74 --- /dev/null +++ b/public/images/unsubscribe.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/globals.css b/src/app/globals.css index 7cddd98..b7915ea 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -39,6 +39,9 @@ body { .Text-gray { @apply text-[#787486] dark:text-[#BCBCBC]; } +.Text-gray-light { + @apply text-[#9FA6B2]; +} .Text-white { @apply text-[#FFFFFF] dark:text-[#333236]; } @@ -51,6 +54,9 @@ body { .Text-btn { @apply text-[#5FBBFF] dark:text-[#228DFF]; } +.Text-blue { + @apply text-[#83C8FA] dark:text-[#228DFF]; +} .Border-error { @apply border border-[#D6173A]; } @@ -66,6 +72,9 @@ body { .Border-bottom { @apply border-b border-[#D9D9D9] dark:border-[#707070]; } +.Border-blue { + @apply border border-[#83C8FA] dark:border-[#228DFF]; +} .BG-addPhoto { @apply bg-[#F5F5F5] dark:bg-[#2E2E2E]; } @@ -94,6 +103,3 @@ body { .Input-readOnly { @apply w-520 cursor-pointer rounded-6 border border-[#D9D9D9] px-16 py-11 pt-14 text-14 text-[#333236] placeholder-gray-400 caret-transparent focus:border-[#44aeff] focus:outline-none dark:border-[#747474] dark:text-[#FFFFFF] dark:focus:border-[#3474a5]; } -.Text-blue { - @apply text-[#83C8FA] dark:text-[#228DFF]; -} diff --git a/src/app/mydashboard/api/dashboardApi.ts b/src/app/mydashboard/api/dashboardApi.ts new file mode 100644 index 0000000..d03b7ac --- /dev/null +++ b/src/app/mydashboard/api/dashboardApi.ts @@ -0,0 +1,66 @@ +import authHttpClient from '@/app/shared/lib/axios' +import { + DashboardListResponse, + InvitationListResponse, +} from '@/app/shared/types/dashboard' + +const TEAM_ID = process.env.NEXT_PUBLIC_TEAM_ID + +if (!TEAM_ID) { + throw new Error('NEXT_PUBLIC_TEAM_ID 환경변수가 설정되지 않았습니다.') +} + +/** + * 내 대시보드 목록 조회 (페이지네이션) + * @param page - 페이지 번호 (1부터 시작) + * @param size - 페이지 크기 + */ +export const getMyDashboards = async ( + page: number = 1, + size: number = 5, +): Promise => { + const params = new URLSearchParams({ + navigationMethod: 'pagination', + page: page.toString(), + size: size.toString(), + }) + + const response = await authHttpClient.get(`/${TEAM_ID}/dashboards?${params}`) + return response.data +} + +/** + * 초대받은 대시보드 목록 조회 + * @param size - 페이지 크기 + * @param cursorId - 커서 ID + */ +export const getInvitedDashboards = async ( + size: number = 10, + cursorId?: number, +): Promise => { + const params = new URLSearchParams({ + navigationMethod: 'infiniteScroll', + size: size.toString(), + }) + + if (cursorId) { + params.append('cursorId', cursorId.toString()) + } + + const response = await authHttpClient.get(`/${TEAM_ID}/invitations?${params}`) + return response.data +} + +/** + * 초대 수락/거절 + * @param invitationId - 초대 ID + * @param accept - 수락 여부 (true: 수락, false: 거절) + */ +export const respondToInvitation = async ( + invitationId: number, + accept: boolean, +): Promise => { + await authHttpClient.put(`/${TEAM_ID}/invitations/${invitationId}`, { + inviteAccepted: accept, + }) +} diff --git a/src/app/mydashboard/components/InvitedDashboardTable/InvitedDashboardRow.tsx b/src/app/mydashboard/components/InvitedDashboardTable/InvitedDashboardRow.tsx new file mode 100644 index 0000000..9a75959 --- /dev/null +++ b/src/app/mydashboard/components/InvitedDashboardTable/InvitedDashboardRow.tsx @@ -0,0 +1,85 @@ +'use client' + +import { useState } from 'react' + +import { showError, showSuccess } from '@/app/shared/lib/toast' +import { Invitation } from '@/app/shared/types/dashboard' + +import { useRespondToInvitation } from '../../hooks/useMyDashboards' + +interface InvitedDashboardRowProps { + invitation: Invitation +} + +export default function InvitedDashboardRow({ + invitation, +}: InvitedDashboardRowProps) { + const [isProcessing, setIsProcessing] = useState(false) + const respondToInvitationMutation = useRespondToInvitation() + + // 공통 초대 응답 처리 + const handleInvitationResponse = async (accept: boolean) => { + if (isProcessing) return + + const action = accept ? '수락' : '거절' + setIsProcessing(true) + + try { + await respondToInvitationMutation.mutateAsync({ + invitationId: invitation.id, + accept, + }) + + const successMessage = accept + ? '초대를 수락했습니다!' + : '초대를 거절했습니다.' + showSuccess(successMessage) + } catch (error) { + console.error(`초대 ${action} 실패:`, error) + + const errorMessage = + error instanceof Error + ? `초대 ${action} 실패: ${error.message}` + : `초대 ${action} 중 오류가 발생했습니다.` + + showError(errorMessage) + } finally { + setIsProcessing(false) + } + } + + const handleAccept = () => handleInvitationResponse(true) + const handleReject = () => handleInvitationResponse(false) + + return ( +
+ {/* 대시보드 이름 */} + + {invitation.dashboard.title || '제목 없음'} + + + {/* 초대자 */} + + {invitation.inviter.nickname || invitation.inviter.email} + + + {/* 수락/거절 버튼들 */} +
+ + +
+
+ ) +} diff --git a/src/app/mydashboard/components/InvitedDashboardTable/InvitedDashboardTable.tsx b/src/app/mydashboard/components/InvitedDashboardTable/InvitedDashboardTable.tsx new file mode 100644 index 0000000..36539ca --- /dev/null +++ b/src/app/mydashboard/components/InvitedDashboardTable/InvitedDashboardTable.tsx @@ -0,0 +1,165 @@ +'use client' + +import Image from 'next/image' +import { useMemo, useState } from 'react' + +import { useInfiniteScroll } from '../../hooks/useInfiniteScroll' +import { useInvitedDashboards } from '../../hooks/useMyDashboards' +import InvitedDashboardRow from './InvitedDashboardRow' +import SearchInput from './SearchInput' + +export default function InvitedDashboardTable() { + const [searchQuery, setSearchQuery] = useState('') + + const { + data, + isLoading, + isError, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInvitedDashboards(6) + + useInfiniteScroll(fetchNextPage, hasNextPage, isFetchingNextPage) + + const allInvitations = useMemo(() => { + return ( + data?.pages + .flatMap((page) => page.invitations) + .filter((invitation) => invitation != null) || [] + ) + }, [data]) + + // 검색 필터링 + const filteredInvitations = useMemo(() => { + if (!searchQuery.trim()) { + return allInvitations + } + + const query = searchQuery.toLowerCase().trim() + return allInvitations.filter((invitation) => { + const dashboardTitle = invitation.dashboard.title.toLowerCase() + const inviterName = invitation.inviter.nickname.toLowerCase() + return dashboardTitle.includes(query) || inviterName.includes(query) + }) + }, [allInvitations, searchQuery]) + + // 로딩 상태 + if (isLoading) { + return ( +
+ {/* 검색창 스켈레톤 */} +
+ + {/* 테이블 헤더 */} +
+ 이름 + + 초대자 + + + 수락 여부 + +
+ + {/* 스켈레톤 행들 */} + {Array.from({ length: 3 }).map((_, index) => ( +
+
+
+
+
+ ))} +
+ ) + } + + // 에러 상태 + if (isError) { + return ( +
+

+ 초대받은 대시보드를 불러오는 중 오류가 발생했습니다. +

+

+ {error?.message || '다시 시도해주세요.'} +

+
+ ) + } + + // 빈 상태 + if (allInvitations.length === 0) { + return ( +
+
+ 초대받은 대시보드 없음 +
+

+ 아직 초대받은 대시보드가 없어요. +

+
+ ) + } + + // 성공 상태 - 테이블 표시 + return ( +
+ {/* 검색창 */} + + + {/* 테이블 헤더 */} +
+ 이름 + + 초대자 + + + 수락 여부 + +
+ + {/* 테이블 바디 */} +
+ {searchQuery.trim() && filteredInvitations.length === 0 ? ( + // 검색 결과 없음 +
+

+ `{searchQuery}`에 대한 검색 결과가 없습니다. +

+
+ ) : ( + // 검색 결과 표시 + filteredInvitations.map((invitation) => ( + + )) + )} +
+ + {/* 무한 스크롤 로딩 인디케이터 - 검색 중에는 표시 안함 */} + {!searchQuery.trim() && isFetchingNextPage && ( +
+
+
+ )} + + {/* 더 이상 데이터가 없을 때 */} + {!hasNextPage && allInvitations.length > 0 && ( +
+

+ 모든 초대를 확인했습니다. +

+
+ )} +
+ ) +} diff --git a/src/app/mydashboard/components/InvitedDashboardTable/SearchInput.tsx b/src/app/mydashboard/components/InvitedDashboardTable/SearchInput.tsx new file mode 100644 index 0000000..fd88d38 --- /dev/null +++ b/src/app/mydashboard/components/InvitedDashboardTable/SearchInput.tsx @@ -0,0 +1,36 @@ +'use client' + +import Image from 'next/image' + +interface SearchInputProps { + value: string + onChange: (value: string) => void + placeholder?: string +} + +export default function SearchInput({ + value, + onChange, + placeholder = '검색', +}: SearchInputProps) { + return ( +
+ onChange(e.target.value)} + className="Border-btn h-40 w-full rounded-8 border pl-40 pr-12 text-14 placeholder-gray-400 focus:border-blue-500 focus:outline-none" + /> +
+ 검색 +
+
+ ) +} diff --git a/src/app/mydashboard/components/MyDashboardGrid/AddDashboardCard.tsx b/src/app/mydashboard/components/MyDashboardGrid/AddDashboardCard.tsx new file mode 100644 index 0000000..805d188 --- /dev/null +++ b/src/app/mydashboard/components/MyDashboardGrid/AddDashboardCard.tsx @@ -0,0 +1,31 @@ +'use client' + +import Image from 'next/image' + +import { useModalStore } from '@/app/shared/store/useModalStore' + +export default function AddDashboardCard() { + const { openModal } = useModalStore() + + const handleClick = () => { + openModal('createDashboard') + } + + return ( + + ) +} diff --git a/src/app/mydashboard/components/MyDashboardGrid/MyDashboardCard.tsx b/src/app/mydashboard/components/MyDashboardGrid/MyDashboardCard.tsx new file mode 100644 index 0000000..0d3b984 --- /dev/null +++ b/src/app/mydashboard/components/MyDashboardGrid/MyDashboardCard.tsx @@ -0,0 +1,64 @@ +'use client' + +import Image from 'next/image' +import { useRouter } from 'next/navigation' + +import { Dashboard } from '@/app/shared/types/dashboard' + +interface MyDashboardCardProps { + dashboard: Dashboard +} + +export default function MyDashboardCard({ dashboard }: MyDashboardCardProps) { + const router = useRouter() + + const handleClick = () => { + router.push(`/dashboard/${dashboard.id}`) + } + + return ( +
+
+
+ {/* 컬러 도트 */} +
+ + {/* 대시보드 제목 */} +

+ {dashboard.title} +

+ + {/* 왕관 아이콘 */} + {dashboard.createdByMe && ( +
+ 소유자 +
+ )} +
+ + {/* 화살표 아이콘 */} +
+
+ 화살표 +
+
+
+
+ ) +} diff --git a/src/app/mydashboard/components/MyDashboardGrid/MyDashboardGrid.tsx b/src/app/mydashboard/components/MyDashboardGrid/MyDashboardGrid.tsx new file mode 100644 index 0000000..9f87c89 --- /dev/null +++ b/src/app/mydashboard/components/MyDashboardGrid/MyDashboardGrid.tsx @@ -0,0 +1,117 @@ +'use client' + +import Image from 'next/image' +import { useState } from 'react' + +import { useMyDashboards } from '../../hooks/useMyDashboards' +import AddDashboardCard from './AddDashboardCard' +import MyDashboardCard from './MyDashboardCard' + +export default function MyDashboardGrid() { + // 현재 페이지 상태 관리 + const [currentPage, setCurrentPage] = useState(1) + const pageSize = 5 + + // 대시보드 조회 + const { data, isLoading, isError, error } = useMyDashboards( + currentPage, + pageSize, + ) + + // 로딩 + if (isLoading) { + return ( +
+
+ {/* 새 대시보드 추가 버튼은 항상 표시 */} + + + {/* 로딩 스켈레톤*/} + {Array.from({ length: 5 }).map((_, index) => ( +
+ ))} +
+
+ ) + } + + // 에러 + if (isError) { + return ( +
+
+

+ 대시보드를 불러오는 중 오류가 발생했습니다. +

+

+ {error?.message || '다시 시도해주세요.'} +

+
+
+ ) + } + + // 성공 + const dashboards = data?.dashboards || [] + const totalCount = data?.totalCount || 0 + const totalPages = Math.ceil(totalCount / pageSize) + + return ( +
+
+ {/* 새 대시보드 추가 카드는 항상 첫 번째 고정 */} + + + {/* 대시보드 카드 */} + {dashboards.map((dashboard) => ( + + ))} +
+ + {/* 페이지네이션 */} + {totalPages > 1 && ( +
+ + {currentPage} 페이지 중 {totalPages} + + +
+ {/* 이전 페이지 버튼 */} + + + {/* 다음 페이지 버튼 */} + +
+
+ )} +
+ ) +} diff --git a/src/app/mydashboard/hooks/useInfiniteScroll.ts b/src/app/mydashboard/hooks/useInfiniteScroll.ts new file mode 100644 index 0000000..35da1fc --- /dev/null +++ b/src/app/mydashboard/hooks/useInfiniteScroll.ts @@ -0,0 +1,41 @@ +import { useCallback, useEffect } from 'react' + +/** + * 무한스크롤 훅 + * + * @param fetchNextPage - 다음 페이지를 가져오는 함수 + * @param hasNextPage - 다음 페이지가 있는지 여부 + * @param isFetchingNextPage - 다음 페이지를 가져오는 중인지 여부 + */ +export const useInfiniteScroll = ( + fetchNextPage: () => void, + hasNextPage: boolean, + isFetchingNextPage: boolean, +) => { + const handleScroll = useCallback(() => { + const scrollTop = window.scrollY // 현재 스크롤 위치 + const windowHeight = window.innerHeight // 브라우저 창 높이 + const documentHeight = document.documentElement.scrollHeight // 문서 전체 높이 + + const scrollPercentage = (scrollTop + windowHeight) / documentHeight + const isNearBottom = scrollPercentage >= 0.8 // 80% 스크롤하면 트리거 + + // 다음 페이지 요청 + if (isNearBottom && hasNextPage && !isFetchingNextPage) { + fetchNextPage() + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage]) // threshold 제거 + + useEffect(() => { + // 사용자가 스크롤바로 페이지를 스크롤할 떄 발생 + window.addEventListener('scroll', handleScroll, { passive: true }) + // 사용자가 마우스 휠을 굴릴 때 발생 + window.addEventListener('wheel', handleScroll, { passive: true }) + + // 컴포넌트 언마운트 시 이벤트 리스너 제거 + return () => { + window.removeEventListener('scroll', handleScroll) + window.removeEventListener('wheel', handleScroll) + } + }, [handleScroll]) // 핸들스크롤 함수 변경 시 리스너 재등록 +} diff --git a/src/app/mydashboard/hooks/useMyDashboards.ts b/src/app/mydashboard/hooks/useMyDashboards.ts new file mode 100644 index 0000000..54ab1a5 --- /dev/null +++ b/src/app/mydashboard/hooks/useMyDashboards.ts @@ -0,0 +1,76 @@ +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' + +import { + getInvitedDashboards, + getMyDashboards, + respondToInvitation, +} from '../api/dashboardApi' + +// 내 대시보드 목록 조회 훅 +export const useMyDashboards = (page: number = 1, size = 5) => { + return useQuery({ + queryKey: ['myDashboards', page, size], + queryFn: () => getMyDashboards(page, size), + staleTime: 1000 * 60 * 5, // 5분간 fresh 상태 유지 + gcTime: 1000 * 60 * 10, // 10분간 캐시 유지 + retry: 2, + refetchOnWindowFocus: false, // 창 포커스 시 재요청 방지 -> 불필요한 API 호출 방지 + }) +} + +// 초대받은 대시보드 목록 조회 훅 +export const useInvitedDashboards = (size: number = 10) => { + return useInfiniteQuery({ + queryKey: ['invitedDashboards', size], + // 페이지별 데이터 조회 함수 (pageParam = cursorId) + queryFn: ({ pageParam }: { pageParam: number | null }) => + getInvitedDashboards(size, pageParam || undefined), + // 첫 페이지 시작점 (cursorId 없음) + initialPageParam: null, + // 다음 페이지 파라미터 결정 함수 + getNextPageParam: (lastPage) => { + // cursorId가 있으면 다음 페이지 존재, 없으면 마지막 페이지 + return lastPage.cursorId || null + }, + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 10, + }) +} + +// 초대 응답(수락/거절) 훅 +export const useRespondToInvitation = () => { + // 캐시 관리 + const queryClient = useQueryClient() + + return useMutation({ + // 변경 작업을 수행하는 함수 + mutationFn: ({ + invitationId, + accept, + }: { + invitationId: number + accept: boolean + }) => respondToInvitation(invitationId, accept), + + // 성공 시 실행 + onSuccess: (_, variables) => { + // 초대 목록에서 해당 항목 제거 + queryClient.invalidateQueries({ queryKey: ['invitedDashboards'] }) + + // 수락 시에만 대시보드 목록 갱신 + if (variables.accept) { + queryClient.invalidateQueries({ queryKey: ['myDashboards'] }) + queryClient.invalidateQueries({ queryKey: ['dashboards'] }) + } + }, + // 실패 시 + onError: (error) => { + console.error('초대 응답 실패:', error) + }, + }) +} diff --git a/src/app/mydashboard/page.tsx b/src/app/mydashboard/page.tsx new file mode 100644 index 0000000..b572f56 --- /dev/null +++ b/src/app/mydashboard/page.tsx @@ -0,0 +1,36 @@ +'use client' + +import Header from '@/app/shared/components/common/header/Header' +import Sidebar from '@/app/shared/components/common/sidebar/Sidebar' + +import InvitedDashboardTable from './components/InvitedDashboardTable/InvitedDashboardTable' +import MyDashboardGrid from './components/MyDashboardGrid/MyDashboardGrid' + +export default function MyDashboardPage() { + return ( +
+ {/* 사이드바 */} + + + {/* 메인 */} +
+ {/* 헤더 */} +
+ + {/* 페이지 콘텐츠 */} +
+ + + {/* 초대받은 대시보드 섹션 */} +
+

+ 초대받은 대시보드 +

+ + +
+
+
+
+ ) +} diff --git a/src/app/shared/types/dashboard.ts b/src/app/shared/types/dashboard.ts index e851234..dfde17f 100644 --- a/src/app/shared/types/dashboard.ts +++ b/src/app/shared/types/dashboard.ts @@ -32,3 +32,36 @@ export interface CreateDashboardRequest { title: string color: string } + +// 대시보드 생성 모달 +export interface ModalState { + createDashboardModalOpen: boolean + openCreateDashboardModal: () => void + closeCreateDashboardModal: () => void +} + +// 초대받은 대시보드 +export interface InvitationListResponse { + cursorId: number | null + invitations: Invitation[] +} + +// 초대 정보 타입 +export interface Invitation { + id: number + inviter: { + id: number + email: string + nickname: string + } + teamId: string + dashboard: Dashboard + invitee: { + id: number + email: string + nickname: string + } + inviteAccepted: boolean | null + createdAt: string + updatedAt: string +}