Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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.
12 changes: 9 additions & 3 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 Expand Up @@ -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];
}
66 changes: 66 additions & 0 deletions src/app/mydashboard/api/dashboardApi.ts
Original file line number Diff line number Diff line change
@@ -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<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,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 (
<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
Loading