Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
3 changes: 3 additions & 0 deletions public/images/Rectangle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions public/images/arrow.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions public/images/chip.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions public/images/search.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions public/images/unsubscribe.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Expand All @@ -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];
}
Expand All @@ -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];
}
Expand Down
62 changes: 62 additions & 0 deletions src/app/mydashboard/api/dashboardApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import authHttpClient from '@/app/shared/lib/axios'
import {
DashboardListResponse,
InvitationListResponse,
} from '@/app/shared/types/dashboard'

const TEAM_ID = process.env.NEXT_PUBLIC_TEAM_ID!

/**
* 내 대시보드 목록 조회 (페이지네이션)
* @param page - 페이지 번호 (1부터 시작)
* @param size - 페이지 크기
*/
export const getMyDashboards = async (
page: number = 1,
size: number = 5,
): Promise<DashboardListResponse> => {
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<InvitationListResponse> => {
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<void> => {
await authHttpClient.put(`/${TEAM_ID}/invitations/${invitationId}`, {
inviteAccepted: accept,
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
'use client'

import { useState } from 'react'

import { Invitation } from '@/app/shared/types/dashboard'

import { useRespondToInvitation } from '../../hooks/useMyDashboards'
import { showError, showSuccess } from '@/app/shared/lib/toast'

interface InvitedDashboardRowProps {
invitation: Invitation
}

export default function InvitedDashboardRow({
invitation,
}: InvitedDashboardRowProps) {
const [isProcessing, setIsProcessing] = useState(false)
const respondToInvitationMutation = useRespondToInvitation()

const handleAccept = async () => {
if (isProcessing) return

setIsProcessing(true)
try {
await respondToInvitationMutation.mutateAsync({
invitationId: invitation.id,
accept: true,
})
showSuccess('초대를 수락했습니다!')
} catch (error) {
console.error('초대 수락 실패:', error)
showError('초대 수락 중 오류가 발생했습니다.')
} finally {
setIsProcessing(false)
}
}

const handleReject = async () => {
if (isProcessing) return

setIsProcessing(true)
try {
await respondToInvitationMutation.mutateAsync({
invitationId: invitation.id,
accept: false,
})
showSuccess('초대를 거절했습니다.')
} catch (error) {
console.error('초대 거절 실패:', error)
showError('초대 거절 중 오류가 발생했습니다.')
} finally {
setIsProcessing(false)
}
}

return (
<div className="grid grid-cols-3 items-center gap-20 border-b border-gray-100 py-20 pl-36 pr-32">
{/* 대시보드 이름 */}
<span className="Text-black text-16">
{invitation.dashboard.title || '제목 없음'}
</span>

{/* 초대자 */}
<span className="Text-black text-center text-16">
{invitation.inviter.nickname || invitation.inviter.email}
</span>

{/* 수락/거절 버튼들 */}
<div className="flex items-center justify-center gap-10">
<button
onClick={handleAccept}
disabled={isProcessing}
className="BG-blue flex h-32 w-70 items-center justify-center rounded-8 text-14 font-medium text-white transition-colors hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{isProcessing ? '처리 중' : '수락'}
</button>
<button
onClick={handleReject}
disabled={isProcessing}
className="BG-white Border-blue Text-blue flex h-32 w-70 items-center justify-center rounded-8 border text-14 font-medium transition-colors hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
>
{isProcessing ? '처리 중' : '거절'}
</button>
</div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-24">
{/* 검색창 스켈레톤 */}
<div className="h-40 animate-pulse rounded-8 bg-gray-200" />

{/* 테이블 헤더 */}
<div className="grid grid-cols-3 items-center gap-20 pl-36 pr-32">
<span className="text-16 font-medium text-gray-400">이름</span>
<span className="text-center text-16 font-medium text-gray-400">
초대자
</span>
<span className="text-center text-16 font-medium text-gray-400">
수락 여부
</span>
</div>

{/* 스켈레톤 행들 */}
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="grid grid-cols-3 items-center gap-20 border-b border-gray-100 py-20 pl-36 pr-32"
>
<div className="h-20 animate-pulse rounded-4 bg-gray-200" />
<div className="h-20 animate-pulse rounded-4 bg-gray-200" />
<div className="h-20 animate-pulse rounded-4 bg-gray-200" />
</div>
))}
</div>
)
}
Comment on lines +66 to +79
Copy link
Contributor

Choose a reason for hiding this comment

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

스켈레톤 UI 구현해주셨군용!! 👍👍


// 에러 상태
if (isError) {
return (
<div className="flex flex-col items-center justify-center py-60">
<p className="text-16 font-medium text-red-500">
초대받은 대시보드를 불러오는 중 오류가 발생했습니다.
</p>
<p className="mt-8 text-14 text-gray-500">
{error?.message || '다시 시도해주세요.'}
</p>
</div>
)
}

// 빈 상태
if (allInvitations.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-60">
<div className="relative mb-24 h-100 w-100">
<Image
src="/images/unsubscribe.svg"
alt="초대받은 대시보드 없음"
fill
className="object-contain"
/>
</div>
<p className="Text-gray-light text-16 font-medium">
아직 초대받은 대시보드가 없어요.
</p>
</div>
)
}

// 성공 상태 - 테이블 표시
return (
<div className="space-y-24">
{/* 검색창 */}
<SearchInput value={searchQuery} onChange={setSearchQuery} />

{/* 테이블 헤더 */}
<div className="grid grid-cols-3 items-center gap-20 pl-36 pr-32">
<span className="Text-gray-light text-16 font-normal">이름</span>
<span className="Text-gray-light text-center text-16 font-normal">
초대자
</span>
<span className="Text-gray-light text-center text-16 font-normal">
수락 여부
</span>
</div>

{/* 테이블 바디 */}
<div className="space-y-0">
{searchQuery.trim() && filteredInvitations.length === 0 ? (
// 검색 결과 없음
<div className="flex flex-col items-center justify-center py-60">
<p className="Text-gray-light text-16 font-medium">
`{searchQuery}`에 대한 검색 결과가 없습니다.
</p>
</div>
) : (
// 검색 결과 표시
filteredInvitations.map((invitation) => (
<InvitedDashboardRow key={invitation.id} invitation={invitation} />
))
)}
</div>

{/* 무한 스크롤 로딩 인디케이터 - 검색 중에는 표시 안함 */}
{!searchQuery.trim() && isFetchingNextPage && (
<div className="flex justify-center py-20">
<div className="size-32 animate-spin rounded-full border-4 border-gray-200 border-t-blue-500" />
</div>
)}
Comment on lines +148 to +153
Copy link
Contributor

Choose a reason for hiding this comment

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

무한스크롤 구현 방법 얻어갑니당 😮


{/* 더 이상 데이터가 없을 때 */}
{!hasNextPage && allInvitations.length > 0 && (
<div className="py-20 text-center">
<p className="Text-gray-light text-14 font-normal">
모든 초대를 확인했습니다.
</p>
</div>
)}
</div>
)
}
Loading