diff --git a/package-lock.json b/package-lock.json index a735708..9f2ce64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "react-datepicker": "^7.6.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", + "react-intersection-observer": "^9.15.1", "tailwind-merge": "^3.0.1", "zod": "^3.24.1", "zustand": "^5.0.3" @@ -4739,6 +4740,21 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-intersection-observer": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.15.1.tgz", + "integrity": "sha512-vGrqYEVWXfH+AGu241uzfUpNK4HAdhCkSAyFdkMb9VWWXs6mxzBLpWCxEy9YcnDNY2g9eO6z7qUtTBdA9hc8pA==", + "license": "MIT", + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index 2ca3bd8..d4b3b35 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "react-datepicker": "^7.6.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", + "react-intersection-observer": "^9.15.1", "tailwind-merge": "^3.0.1", "zod": "^3.24.1", "zustand": "^5.0.3" diff --git a/src/apis/dashboards/index.ts b/src/apis/dashboards/index.ts index 84d1dfb..33d7817 100644 --- a/src/apis/dashboards/index.ts +++ b/src/apis/dashboards/index.ts @@ -4,7 +4,7 @@ import { Dashboard, DashboardFormType, DashboardInvitation, - DashboardInvitationResponse, + DashboardInvitationsResponse, dashboardSchema, DashboardsResponse, dashboardsResponseSchema, @@ -69,10 +69,9 @@ export const deleteDashboard = async (id: number) => { return response.data; }; -// TODO : UserSchema 추가이후, Invitation schema가 작성되면 응답 검증 로직 추가 필요 // dashboard 초대 불러오기 export const getDashboardInvitations = async (id: number, { page, size }: BasePaginationParams) => { - const response = await axiosClientHelper.get(`/dashboards/${id}/invitations`, { + const response = await axiosClientHelper.get(`/dashboards/${id}/invitations`, { params: { page: page || 1, size: size || 10, @@ -81,10 +80,9 @@ export const getDashboardInvitations = async (id: number, { page, size }: BasePa return response.data; }; -// TODO : UserSchema 추가이후, Invitation schema가 작성되면 응답 검증 로직 추가 필요 // dashboard 초대 export const inviteDashboard = async (id: number, data: InviteDashboardType) => { - const response = await axiosClientHelper.post(`/dashboards/${id}/invitations`, data); + const response = await axiosClientHelper.post(`/dashboards/${id}/invitations`, data); return response.data; }; diff --git a/src/apis/dashboards/mockData.ts b/src/apis/dashboards/mockData.ts deleted file mode 100644 index d2ddc3f..0000000 --- a/src/apis/dashboards/mockData.ts +++ /dev/null @@ -1,148 +0,0 @@ -export const mockDashboardData = { - dashboards: [ - { - id: 1, - title: '비브리지', - color: 'green-30', - createdAt: '2025-02-05T09:23:00.603Z', - updatedAt: '2025-02-05T09:23:00.603Z', - createdByMe: true, // hasCrown이 true였던 경우 - userId: 0, - }, - { - id: 2, - title: '코드잇', - color: 'blue-20', - createdAt: '2025-02-05T09:23:00.603Z', - updatedAt: '2025-02-05T09:23:00.603Z', - createdByMe: true, - userId: 0, - }, - { - id: 3, - title: '3분기 계획', - color: 'green-30', - createdAt: '2025-02-05T09:23:00.603Z', - updatedAt: '2025-02-05T09:23:00.603Z', - createdByMe: false, - userId: 0, - }, - { - id: 4, - title: '회의록', - color: 'blue-20', - createdAt: '2025-02-05T09:23:00.603Z', - updatedAt: '2025-02-05T09:23:00.603Z', - createdByMe: false, - userId: 0, - }, - { - id: 5, - title: '중요 문서함', - color: 'pink-20', - createdAt: '2025-02-05T09:23:00.603Z', - updatedAt: '2025-02-05T09:23:00.603Z', - createdByMe: false, - userId: 0, - }, - { - id: 6, - title: '비브리지', - color: 'blue-20', - createdAt: '2025-02-05T09:23:00.603Z', - updatedAt: '2025-02-05T09:23:00.603Z', - createdByMe: true, - userId: 0, - }, - { - id: 7, - title: '코드잇', - color: 'purple', - createdAt: '2025-02-05T09:23:00.603Z', - updatedAt: '2025-02-05T09:23:00.603Z', - createdByMe: true, - userId: 0, - }, - { - id: 8, - title: '3분기 계획', - color: 'blue-20', - createdAt: '2025-02-05T09:23:00.603Z', - updatedAt: '2025-02-05T09:23:00.603Z', - createdByMe: false, - userId: 0, - }, - { - id: 9, - title: '회의록', - color: 'purple', - createdAt: '2025-02-05T09:23:00.603Z', - updatedAt: '2025-02-05T09:23:00.603Z', - createdByMe: false, - userId: 0, - }, - { - id: 10, - title: '중요 문서함', - color: 'blue-20', - createdAt: '2025-02-05T09:23:00.603Z', - updatedAt: '2025-02-05T09:23:00.603Z', - createdByMe: false, - userId: 0, - }, - { - id: 11, - title: '비브리지', - color: 'blue-20', - createdAt: '2025-02-05T09:23:00.603Z', - updatedAt: '2025-02-05T09:23:00.603Z', - createdByMe: true, - userId: 0, - }, - { - id: 12, - title: '코드잇', - color: 'pink-20', - createdAt: '2025-02-05T09:23:00.603Z', - updatedAt: '2025-02-05T09:23:00.603Z', - createdByMe: true, - userId: 0, - }, - { - id: 13, - title: '3분기 계획', - color: 'orange-20', - createdAt: '2025-02-05T09:23:00.603Z', - updatedAt: '2025-02-05T09:23:00.603Z', - createdByMe: false, - userId: 0, - }, - { - id: 14, - title: '회의록', - color: 'blue-20', - createdAt: '2025-02-05T09:23:00.603Z', - updatedAt: '2025-02-05T09:23:00.603Z', - createdByMe: false, - userId: 0, - }, - { - id: 15, - title: '중요 문서함', - color: 'blue-20', - createdAt: '2025-02-05T09:23:00.603Z', - updatedAt: '2025-02-05T09:23:00.603Z', - createdByMe: false, - userId: 0, - }, - { - id: 16, - title: '중요 테스트', - color: 'blue-20', - createdAt: '2025-02-05T09:23:00.603Z', - updatedAt: '2025-02-05T09:23:00.603Z', - createdByMe: false, - userId: 0, - }, - ], -}; diff --git a/src/apis/dashboards/queries.ts b/src/apis/dashboards/queries.ts index b8d0bf2..86fb778 100644 --- a/src/apis/dashboards/queries.ts +++ b/src/apis/dashboards/queries.ts @@ -1,8 +1,9 @@ 'use client'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { createDashboard, getDashboards } from '.'; +import { cancelDashboardInvitation, createDashboard, deleteDashboard, getDashboardDetails, getDashboardInvitations, getDashboards, inviteDashboard, updateDashboard } from '.'; import { DashboardFormType } from './types'; +import { DEFAULT_COLOR } from '@/constants/colors'; export const useDashboardsQuery = (page: number, size: number) => { return useQuery({ @@ -16,6 +17,20 @@ export const useDashboardsQuery = (page: number, size: number) => { }); }; +export const useDashboardQuery = (id: number) => { + return useQuery({ + queryKey: ['dashboard', id], + queryFn: () => getDashboardDetails(id), + }); +}; + +export const useDashboardInvitationsQuery = (id: number, page: number, size: number) => { + return useQuery({ + queryKey: ['dashboard', id, 'invitations', page, size], + queryFn: () => getDashboardInvitations(id, { page, size }), + }); +}; + export const useDashboardMutation = () => { const queryClient = useQueryClient(); @@ -28,15 +43,49 @@ export const useDashboardMutation = () => { }, }); - // TODO : update query 작성 - const update = () => {}; + const update = useMutation({ + mutationFn: ({ id, title, color }: { id: number; title: string; color: DEFAULT_COLOR }) => { + return updateDashboard(id, { title, color }); + }, + onSuccess: ({ id }) => { + queryClient.invalidateQueries({ queryKey: ['dashboards'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard', id] }); + }, + }); - // TODO : remove query 작성 - const remove = () => {}; + const remove = useMutation({ + mutationFn: (id: number) => { + return deleteDashboard(id); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['dashboards'] }); + }, + }); + + const invite = useMutation({ + mutationFn: ({ id, email }: { id: number; email: string }) => { + return inviteDashboard(id, { email }); + }, + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: ['myInvitations'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard', id, 'invitations'] }); + }, + }); + + const cancel = useMutation({ + mutationFn: ({ dashboardId, invitationId }: { dashboardId: number; invitationId: number }) => { + return cancelDashboardInvitation(dashboardId, invitationId); + }, + onSuccess: (_, { dashboardId }) => { + queryClient.invalidateQueries({ queryKey: ['dashboard', dashboardId, 'invitations'] }); + }, + }); return { create: create.mutateAsync, - update, - remove, + update: update.mutateAsync, + remove: remove.mutateAsync, + invite: invite.mutateAsync, + cancel: cancel.mutateAsync, }; }; diff --git a/src/apis/dashboards/types.ts b/src/apis/dashboards/types.ts index 2677d3f..b012cc8 100644 --- a/src/apis/dashboards/types.ts +++ b/src/apis/dashboards/types.ts @@ -1,14 +1,12 @@ import { z } from 'zod'; -import { User } from '../users/types'; import { DASHBOARD_FORM_ERROR_MESSAGE, DASHBOARD_FORM_VALID_LENGTH } from '@/constants/dashboard'; import { DEFAULT_COLORS } from '@/constants/colors'; // base pagination params 타입 (필요시 공용으로 추출) -export const basePaginationParamsSchema = z.object({ - page: z.number().optional(), - size: z.number().optional(), -}); -export type BasePaginationParams = z.infer; +export type BasePaginationParams = { + page?: number; + size?: number; +}; // dashboard 항목 타입 export const dashboardSchema = z.object({ @@ -31,11 +29,11 @@ export const dashboardsResponseSchema = z.object({ export type DashboardsResponse = z.infer; // dashboard get params 타입 -export const getDashboardsParamsSchema = basePaginationParamsSchema.extend({ - cursorId: z.number().optional(), - navigationMethod: z.enum(['infiniteScroll', 'pagination']), -}); -export type GetDashboardsParams = z.infer; +export type NavigationMethod = 'infiniteScroll' | 'pagination'; +export type GetDashboardsParams = BasePaginationParams & { + cursorId?: number; + navigationMethod: NavigationMethod; +}; // dashboard 작성 스키마 export const dashboardFormSchema = z.object({ @@ -43,30 +41,43 @@ export const dashboardFormSchema = z.object({ .string() .min(DASHBOARD_FORM_VALID_LENGTH.TITLE.MIN, { message: DASHBOARD_FORM_ERROR_MESSAGE.TITLE.MIN }) .max(DASHBOARD_FORM_VALID_LENGTH.TITLE.MAX, { message: DASHBOARD_FORM_ERROR_MESSAGE.TITLE.MAX }), - color: z.string(), + color: z.enum(DEFAULT_COLORS), }); export type DashboardFormType = z.infer; -// TODO : UserSchema가 추가로 작성되면 zod schema로 변경 -// invitation 타입 -export type InvitationUser = Pick; -export type InvitationDashboard = Pick; -export type DashboardInvitation = { - id: number; - inviter: InvitationUser; - invitee: InvitationUser; - teamId: string; - dashboard: InvitationDashboard; - inviteAccepted: boolean; - createdAt: string; - updatedAt: string; -}; +// TODO : 임시 유저 스키마(추후 /apis/auth 쪽에서 작성된 schema 임포트 필요) +export const userSchema = z.object({ + id: z.number(), + email: z.string().email(), + nickname: z.string(), +}); -// invitation 리스트 응답 타입 -export type DashboardInvitationResponse = { - totalCount: number; - invitations: DashboardInvitation[]; -}; +export const invitationUserSchema = userSchema.pick({ + id: true, + email: true, + nickname: true, +}); +export const invitationDashboardSchema = dashboardSchema.pick({ id: true, title: true }); + +// dashbaord invitation 타입 +export const dashboardInvitationSchema = z.object({ + id: z.number(), + inviter: invitationUserSchema, + invitee: invitationUserSchema, + teamId: z.string(), + dashboard: invitationDashboardSchema, + inviteAccepted: z.boolean().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}); +export type DashboardInvitation = z.infer; + +// dashboard invitations 리스트 응답 타입 +export const dashboardInvitationsResponseSchema = z.object({ + totalCount: z.number(), + invitations: z.array(dashboardInvitationSchema), +}); +export type DashboardInvitationsResponse = z.infer; // invitation 스키마 export const inviteDashboardFormSchema = z.object({ diff --git a/src/apis/invitations/index.ts b/src/apis/invitations/index.ts new file mode 100644 index 0000000..42fb533 --- /dev/null +++ b/src/apis/invitations/index.ts @@ -0,0 +1,29 @@ +import axiosClientHelper from '@/utils/network/axiosClientHelper'; +import { MyInvitationsParams, MyInvitationsResponse, myInvitationsResponseSchema, RespondToInvitation } from './types'; +import { DashboardInvitation, dashboardInvitationSchema } from '../dashboards/types'; + +export const getMyInvitations = async ({ cursorId, size, title }: MyInvitationsParams) => { + const response = await axiosClientHelper.get('/invitations', { + params: { + size: size || 10, + ...(title && { title }), // 검색어가 있을경우에만(빈값 보내면 오류) + ...(cursorId && { cursorId }), //cursorId가 있을경우에만(빈값 보내면 오류) + }, + }); + + const result = myInvitationsResponseSchema.safeParse(response.data); + if (!result.success) { + throw new Error('서버에서 받은 데이터가 예상과 다릅니다.'); + } + return result.data; +}; + +export const respondToInvitation = async (id: number, data: RespondToInvitation) => { + const response = await axiosClientHelper.put(`/invitations/${id}`, data); + + const result = dashboardInvitationSchema.safeParse(response.data); + if (!result.success) { + throw new Error('서버에서 받은 데이터가 예상과 다릅니다.'); + } + return result.data; +}; diff --git a/src/apis/invitations/mockData.ts b/src/apis/invitations/mockData.ts deleted file mode 100644 index f62231f..0000000 --- a/src/apis/invitations/mockData.ts +++ /dev/null @@ -1,531 +0,0 @@ -const mockData = { - invitations: [ - { - id: 1, - inviter: { - nickname: '손동희', - email: 'donghee@example.com', - id: 101, - }, - teamId: 'team-001', - dashboard: { - title: '프로덕트 디자인', - id: 501, - }, - invitee: { - nickname: '홍길동', - email: 'hong@example.com', - id: 102, - }, - inviteAccepted: false, - createdAt: '2025-02-07T05:53:20.799Z', - updatedAt: '2025-02-07T05:53:20.799Z', - }, - { - id: 2, - inviter: { - nickname: '이민재', - email: 'minjae@example.com', - id: 103, - }, - teamId: 'team-002', - dashboard: { - title: '영업 대시보드', - id: 502, - }, - invitee: { - nickname: '박지성', - email: 'jiseong@example.com', - id: 104, - }, - inviteAccepted: true, - createdAt: '2025-02-05T08:15:00.000Z', - updatedAt: '2025-02-06T09:30:00.000Z', - }, - { - id: 3, - inviter: { - nickname: '김수현', - email: 'soohyun@example.com', - id: 105, - }, - teamId: 'team-003', - dashboard: { - title: '마케팅 대시보드', - id: 503, - }, - invitee: { - nickname: '최영', - email: 'young@example.com', - id: 106, - }, - inviteAccepted: false, - createdAt: '2025-02-01T11:00:00.000Z', - updatedAt: '2025-02-01T11:00:00.000Z', - }, - { - id: 4, - inviter: { - nickname: '박상훈', - email: 'sanghun@example.com', - id: 107, - }, - teamId: 'team-004', - dashboard: { - title: '디자인 시스템', - id: 504, - }, - invitee: { - nickname: '이영희', - email: 'younghee@example.com', - id: 108, - }, - inviteAccepted: true, - createdAt: '2025-02-10T10:00:00.000Z', - updatedAt: '2025-02-10T10:00:00.000Z', - }, - { - id: 5, - inviter: { - nickname: '정우성', - email: 'woosung@example.com', - id: 109, - }, - teamId: 'team-005', - dashboard: { - title: '영업 전략', - id: 505, - }, - invitee: { - nickname: '김하늘', - email: 'haneul@example.com', - id: 110, - }, - inviteAccepted: false, - createdAt: '2025-02-11T12:00:00.000Z', - updatedAt: '2025-02-11T12:00:00.000Z', - }, - { - id: 6, - inviter: { - nickname: '서강준', - email: 'kangjun@example.com', - id: 111, - }, - teamId: 'team-006', - dashboard: { - title: '고객 분석', - id: 506, - }, - invitee: { - nickname: '이민정', - email: 'minjung@example.com', - id: 112, - }, - inviteAccepted: true, - createdAt: '2025-02-12T14:30:00.000Z', - updatedAt: '2025-02-12T14:30:00.000Z', - }, - { - id: 7, - inviter: { - nickname: '한효주', - email: 'hyohyu@example.com', - id: 113, - }, - teamId: 'team-007', - dashboard: { - title: '제품 개발', - id: 507, - }, - invitee: { - nickname: '오민식', - email: 'ominsik@example.com', - id: 114, - }, - inviteAccepted: false, - createdAt: '2025-02-13T09:45:00.000Z', - updatedAt: '2025-02-13T09:45:00.000Z', - }, - { - id: 8, - inviter: { - nickname: '송혜교', - email: 'hyegyo@example.com', - id: 115, - }, - teamId: 'team-008', - dashboard: { - title: '시장 조사', - id: 508, - }, - invitee: { - nickname: '장동건', - email: 'donggun@example.com', - id: 116, - }, - inviteAccepted: true, - createdAt: '2025-02-14T08:20:00.000Z', - updatedAt: '2025-02-14T08:20:00.000Z', - }, - { - id: 9, - inviter: { - nickname: '김태희', - email: 'taehee@example.com', - id: 117, - }, - teamId: 'team-009', - dashboard: { - title: '브랜드 관리', - id: 509, - }, - invitee: { - nickname: '유재석', - email: 'jaesuk@example.com', - id: 118, - }, - inviteAccepted: false, - createdAt: '2025-02-15T11:10:00.000Z', - updatedAt: '2025-02-15T11:10:00.000Z', - }, - { - id: 10, - inviter: { - nickname: '이병헌', - email: 'byeongheon@example.com', - id: 119, - }, - teamId: 'team-010', - dashboard: { - title: '재무 분석', - id: 510, - }, - invitee: { - nickname: '강동원', - email: 'dongwon@example.com', - id: 120, - }, - inviteAccepted: true, - createdAt: '2025-02-16T15:00:00.000Z', - updatedAt: '2025-02-16T15:00:00.000Z', - }, - { - id: 11, - inviter: { - nickname: '한지민', - email: 'jimin@example.com', - id: 121, - }, - teamId: 'team-011', - dashboard: { - title: '제품 로드맵', - id: 511, - }, - invitee: { - nickname: '정우성', - email: 'woo@example.com', - id: 122, - }, - inviteAccepted: false, - createdAt: '2025-02-17T07:30:00.000Z', - updatedAt: '2025-02-17T07:30:00.000Z', - }, - { - id: 12, - inviter: { - nickname: '김수현', - email: 'soohyun2@example.com', - id: 123, - }, - teamId: 'team-012', - dashboard: { - title: '데이터 분석', - id: 512, - }, - invitee: { - nickname: '송중기', - email: 'joongki@example.com', - id: 124, - }, - inviteAccepted: true, - createdAt: '2025-02-18T10:20:00.000Z', - updatedAt: '2025-02-18T10:20:00.000Z', - }, - { - id: 13, - inviter: { - nickname: '이민호', - email: 'minho@example.com', - id: 125, - }, - teamId: 'team-013', - dashboard: { - title: '서비스 개선', - id: 513, - }, - invitee: { - nickname: '공유', - email: 'gongyu@example.com', - id: 126, - }, - inviteAccepted: false, - createdAt: '2025-02-19T13:40:00.000Z', - updatedAt: '2025-02-19T13:40:00.000Z', - }, - { - id: 14, - inviter: { - nickname: '정해인', - email: 'haein@example.com', - id: 127, - }, - teamId: 'team-014', - dashboard: { - title: '전략 기획', - id: 514, - }, - invitee: { - nickname: '수지', - email: 'suzy@example.com', - id: 128, - }, - inviteAccepted: true, - createdAt: '2025-02-20T16:00:00.000Z', - updatedAt: '2025-02-20T16:00:00.000Z', - }, - { - id: 15, - inviter: { - nickname: '김영희', - email: 'younghee2@example.com', - id: 129, - }, - teamId: 'team-015', - dashboard: { - title: '인사 관리', - id: 515, - }, - invitee: { - nickname: '최민수', - email: 'mins@example.com', - id: 130, - }, - inviteAccepted: false, - createdAt: '2025-02-21T09:00:00.000Z', - updatedAt: '2025-02-21T09:00:00.000Z', - }, - { - id: 16, - inviter: { - nickname: '박보검', - email: 'bogum@example.com', - id: 131, - }, - teamId: 'team-016', - dashboard: { - title: '기술 검토', - id: 516, - }, - invitee: { - nickname: '이서진', - email: 'seojin@example.com', - id: 132, - }, - inviteAccepted: true, - createdAt: '2025-02-22T11:15:00.000Z', - updatedAt: '2025-02-22T11:15:00.000Z', - }, - { - id: 17, - inviter: { - nickname: '한지민', - email: 'jimin2@example.com', - id: 133, - }, - teamId: 'team-017', - dashboard: { - title: '프로젝트 관리', - id: 517, - }, - invitee: { - nickname: '정해인', - email: 'haein2@example.com', - id: 134, - }, - inviteAccepted: false, - createdAt: '2025-02-23T14:50:00.000Z', - updatedAt: '2025-02-23T14:50:00.000Z', - }, - { - id: 18, - inviter: { - nickname: '이민재', - email: 'minjae2@example.com', - id: 135, - }, - teamId: 'team-018', - dashboard: { - title: '시장 분석', - id: 518, - }, - invitee: { - nickname: '손예진', - email: 'yejin@example.com', - id: 136, - }, - inviteAccepted: true, - createdAt: '2025-02-24T08:30:00.000Z', - updatedAt: '2025-02-24T08:30:00.000Z', - }, - { - id: 19, - inviter: { - nickname: '서현진', - email: 'hyunjin@example.com', - id: 137, - }, - teamId: 'team-019', - dashboard: { - title: '운영 대시보드', - id: 519, - }, - invitee: { - nickname: '김민재', - email: 'minjae@example.com', - id: 138, - }, - inviteAccepted: false, - createdAt: '2025-02-25T12:45:00.000Z', - updatedAt: '2025-02-25T12:45:00.000Z', - }, - { - id: 20, - inviter: { - nickname: '윤아', - email: 'yuna@example.com', - id: 139, - }, - teamId: 'team-020', - dashboard: { - title: '재고 관리', - id: 520, - }, - invitee: { - nickname: '박서준', - email: 'seo@example.com', - id: 140, - }, - inviteAccepted: true, - createdAt: '2025-02-26T10:00:00.000Z', - updatedAt: '2025-02-26T10:00:00.000Z', - }, - { - id: 21, - inviter: { - nickname: '김민지', - email: 'minji@example.com', - id: 141, - }, - teamId: 'team-021', - dashboard: { - title: '고객 피드백', - id: 521, - }, - invitee: { - nickname: '이승기', - email: 'seunggi@example.com', - id: 142, - }, - inviteAccepted: false, - createdAt: '2025-02-27T14:00:00.000Z', - updatedAt: '2025-02-27T14:00:00.000Z', - }, - { - id: 22, - inviter: { - nickname: '최지우', - email: 'jiwoo@example.com', - id: 143, - }, - teamId: 'team-022', - dashboard: { - title: '리소스 관리', - id: 522, - }, - invitee: { - nickname: '김지훈', - email: 'jihoon@example.com', - id: 144, - }, - inviteAccepted: true, - createdAt: '2025-02-28T09:30:00.000Z', - updatedAt: '2025-02-28T09:30:00.000Z', - }, - { - id: 23, - inviter: { - nickname: '이서준', - email: 'seojun@example.com', - id: 145, - }, - teamId: 'team-023', - dashboard: { - title: '프로세스 개선', - id: 523, - }, - invitee: { - nickname: '박보영', - email: 'boyoung@example.com', - id: 146, - }, - inviteAccepted: false, - createdAt: '2025-03-01T11:15:00.000Z', - updatedAt: '2025-03-01T11:15:00.000Z', - }, - { - id: 24, - inviter: { - nickname: '송중기', - email: 'joongki2@example.com', - id: 147, - }, - teamId: 'team-024', - dashboard: { - title: '비즈니스 인텔리전스', - id: 524, - }, - invitee: { - nickname: '김태리', - email: 'taeri@example.com', - id: 148, - }, - inviteAccepted: true, - createdAt: '2025-03-02T13:40:00.000Z', - updatedAt: '2025-03-02T13:40:00.000Z', - }, - { - id: 25, - inviter: { - nickname: '공유', - email: 'gongyu2@example.com', - id: 149, - }, - teamId: 'team-025', - dashboard: { - title: '서비스 디자인', - id: 525, - }, - invitee: { - nickname: '박민영', - email: 'minyoung@example.com', - id: 150, - }, - inviteAccepted: false, - createdAt: '2025-03-03T16:00:00.000Z', - updatedAt: '2025-03-03T16:00:00.000Z', - }, - ], -}; - -export default mockData; diff --git a/src/apis/invitations/queries.ts b/src/apis/invitations/queries.ts new file mode 100644 index 0000000..b8bf66a --- /dev/null +++ b/src/apis/invitations/queries.ts @@ -0,0 +1,36 @@ +'use client'; + +import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { getMyInvitations, respondToInvitation } from '.'; + +export const useMyInvitationsQuery = (size: number, title: string) => { + return useInfiniteQuery({ + queryKey: ['myInvitations', size, title], + queryFn: ({ pageParam }) => + getMyInvitations({ + cursorId: pageParam, + size, + title, + }), + getNextPageParam: (lastPage) => lastPage.cursorId || undefined, + initialPageParam: 0, + }); +}; + +export const useInvitationMutation = () => { + const queryClient = useQueryClient(); + + const accept = useMutation({ + mutationFn: ({ id, flag }: { id: number; flag: boolean }) => { + return respondToInvitation(id, { inviteAccepted: flag }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['myInvitations'] }); + queryClient.invalidateQueries({ queryKey: ['dashboards'] }); + }, + }); + + return { + accept: accept.mutateAsync, + }; +}; diff --git a/src/apis/invitations/types.ts b/src/apis/invitations/types.ts index 5c8b335..d517560 100644 --- a/src/apis/invitations/types.ts +++ b/src/apis/invitations/types.ts @@ -1,26 +1,22 @@ -export interface User { - nickname: string; - email: string; - id: number; -} +import { z } from 'zod'; +import { dashboardInvitationSchema } from '../dashboards/types'; -export interface Dashboard { +export type BaseCursorParams = { + cursorId?: number; + size?: number; +}; + +export type MyInvitationsParams = BaseCursorParams & { title: string; - id: number; -} +}; -export interface Invitation { - id: number; - inviter: User; - teamId: string; - dashboard: Dashboard; - invitee: User; - inviteAccepted: boolean; - createdAt: string; - updatedAt: string; -} +export const myInvitationsResponseSchema = z.object({ + cursorId: z.number().nullable(), + invitations: z.array(dashboardInvitationSchema), +}); +export type MyInvitationsResponse = z.infer; -export interface InvitationsResponse { - invitations: Invitation[]; - nextPage?: number; -} +export const respondToInvitationSchema = z.object({ + inviteAccepted: z.boolean(), +}); +export type RespondToInvitation = z.infer; diff --git a/src/app/(dashboard)/dashboard/[id]/edit/page.tsx b/src/app/(dashboard)/dashboard/[id]/edit/page.tsx new file mode 100644 index 0000000..aa54c16 --- /dev/null +++ b/src/app/(dashboard)/dashboard/[id]/edit/page.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useParams, useRouter } from 'next/navigation'; +import { useDashboardMutation } from '@/apis/dashboards/queries'; +import Button from '@/components/ui/Button/Button'; +import useAlert from '@/hooks/useAlert'; +import { getErrorMessage } from '@/utils/errorMessage'; +import DetailModify from '@/components/dashboard/DetailModify'; +import DetailMembers from '@/components/dashboard/DetailMembers'; +import DetailInvited from '@/components/dashboard/DetailInvited'; +import Link from 'next/link'; + +export default function DashboardEditPage() { + const router = useRouter(); + const alert = useAlert(); + const { id } = useParams<{ id: string }>(); + const { remove } = useDashboardMutation(); + + const handleDelete = async () => { + try { + await remove(Number(id)); + alert('삭제했습니다.'); + router.push(`/mydashboard`); + } catch (error) { + const message = getErrorMessage(error); + alert(message); + } + }; + + return ( +
+ {/* TODO : 돌아가기 공용 컴포넌트로 교체 필요 */} +
+ 돌아가기 +
+
+ {/* 대시보드 정보 */} + + + {/* 구성원 리스트 */} + + + {/* 초대내역 */} + + + {/* 대시보드 삭제 */} + {/* TODO : 대시보드 공용 버튼 교체 필요 */} + +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/[id]/page.tsx b/src/app/(dashboard)/dashboard/[id]/page.tsx index ac7992d..b499c08 100644 --- a/src/app/(dashboard)/dashboard/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/[id]/page.tsx @@ -1,4 +1,17 @@ +import Link from 'next/link'; +import Button from '@/components/ui/Button/Button'; + export default async function DashboardDetailPage({ params }: { params: Promise<{ id: string }> }) { const id = (await params).id; - return
아이디 {id} : 대시보드 상세페이지
; + + return ( +
+
아이디 {id} : 대시보드 상세페이지
+
+ + + +
+
+ ); } diff --git a/src/app/(dashboard)/mydashboard/page.tsx b/src/app/(dashboard)/mydashboard/page.tsx index 639d9b5..cf4d0cb 100644 --- a/src/app/(dashboard)/mydashboard/page.tsx +++ b/src/app/(dashboard)/mydashboard/page.tsx @@ -1,5 +1,5 @@ import MyDashboard from '@/components/dashboard/MyDashboard'; -import InvitedDashboardCard from '@/components/invited-dashboard/InvitedDashboardCard'; +import MyInvitedDashboardList from '@/components/dashboard/MyInvitedDashboardList'; export default function MydashboardPage() { return ( @@ -7,7 +7,7 @@ export default function MydashboardPage() {
- +
diff --git a/src/assets/icons/empty.svg b/src/assets/icons/empty.svg new file mode 100644 index 0000000..3a70551 --- /dev/null +++ b/src/assets/icons/empty.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/dashboard/CreateDashboard.tsx b/src/components/dashboard/CreateDashboard.tsx index 22e57f7..cb8842a 100644 --- a/src/components/dashboard/CreateDashboard.tsx +++ b/src/components/dashboard/CreateDashboard.tsx @@ -1,6 +1,5 @@ import { forwardRef } from 'react'; import { useRouter } from 'next/navigation'; -import { isAxiosError } from 'axios'; import { Controller, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import useAlert from '@/hooks/useAlert'; @@ -11,6 +10,7 @@ import ColorPicker from '@/components/ui/Chip/ColorPicker'; import { useDashboardMutation } from '@/apis/dashboards/queries'; import { dashboardFormSchema, DashboardFormType } from '@/apis/dashboards/types'; import { DEFAULT_COLORS } from '@/constants/colors'; +import { getErrorMessage } from '@/utils/errorMessage'; const CreateDashboard = forwardRef((props, ref) => { const { @@ -44,7 +44,8 @@ const CreateDashboard = forwardRef((props, ref) => { handleReset(); router.push(`/dashboard/${id}`); } catch (error) { - alert(isAxiosError(error) ? error.message : '문제가 발생했습니다.'); + const message = getErrorMessage(error); + alert(message); } }; diff --git a/src/components/dashboard/DetailInvited.tsx b/src/components/dashboard/DetailInvited.tsx new file mode 100644 index 0000000..eebf839 --- /dev/null +++ b/src/components/dashboard/DetailInvited.tsx @@ -0,0 +1,26 @@ +import { useRef } from 'react'; +import Button from '@/components/ui/Button/Button'; +import { Card, CardTitle } from '@/components/ui/Card/Card'; +import { ModalHandle } from '@/components/ui/Modal/Modal'; +import InviteDashboard from './InviteDashboard'; + +export default function DetailInvited() { + const inviteModalRef = useRef(null); + + return ( + + + 초대내역 +
+ +
+
+
대시보드 초대내역(작업필요)
+ + {/* 초대모달 */} + +
+ ); +} diff --git a/src/components/dashboard/DetailMembers.tsx b/src/components/dashboard/DetailMembers.tsx new file mode 100644 index 0000000..22bc52f --- /dev/null +++ b/src/components/dashboard/DetailMembers.tsx @@ -0,0 +1,10 @@ +import { Card, CardTitle } from '@/components/ui/Card/Card'; + +export default function DetailMembers() { + return ( + + 구성원 +
구성원 리스트(작업필요)
+
+ ); +} diff --git a/src/components/dashboard/DetailModify.tsx b/src/components/dashboard/DetailModify.tsx new file mode 100644 index 0000000..292547a --- /dev/null +++ b/src/components/dashboard/DetailModify.tsx @@ -0,0 +1,90 @@ +import { useParams } from 'next/navigation'; +import { Controller, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useDashboardMutation, useDashboardQuery } from '@/apis/dashboards/queries'; +import { Card, CardTitle } from '@/components/ui/Card/Card'; +import { Input } from '@/components/ui/Field'; +import ColorPicker from '@/components/ui/Chip/ColorPicker'; +import { dashboardFormSchema, DashboardFormType } from '@/apis/dashboards/types'; +import { getErrorMessage } from '@/utils/errorMessage'; +import Button from '@/components/ui/Button/Button'; +import { useEffect } from 'react'; +import { DEFAULT_COLORS } from '@/constants/colors'; +import useAlert from '@/hooks/useAlert'; + +export default function DetailModify() { + const { id } = useParams<{ id: string }>(); + const { data, isLoading } = useDashboardQuery(Number(id)); + const alert = useAlert(); + + const { + handleSubmit, + register, + control, + reset, + formState: { errors, isValid, isSubmitting, isDirty }, + } = useForm({ + resolver: zodResolver(dashboardFormSchema), + mode: 'onBlur', + defaultValues: { + title: '', + color: DEFAULT_COLORS[0], + }, + }); + const { update } = useDashboardMutation(); + + useEffect(() => { + //react query의 데이터가 오기전에 rhf의 기본값이 셋팅되어서 effect를 통해 재설정 + if (data) { + reset({ + title: data.title, + color: data.color, + }); + } + }, [data, reset]); + + const onSubmit = async (formData: DashboardFormType) => { + try { + await update({ id: Number(id), ...formData }); + reset(); + alert('수정했습니다.'); + } catch (error) { + const message = getErrorMessage(error); + alert(message); + } + }; + + const isDisabled = !isDirty || !isValid || isSubmitting; + + return ( + + {isLoading ? ( +
대시보드 정보를 가져오는 중입니다.
+ ) : ( + <> + {data?.title} +
+
+ + { + return field.onChange(value)} />; + }} + /> + +
+
+ + )} +
+ ); +} diff --git a/src/components/dashboard/InviteDashboard.tsx b/src/components/dashboard/InviteDashboard.tsx new file mode 100644 index 0000000..234d93c --- /dev/null +++ b/src/components/dashboard/InviteDashboard.tsx @@ -0,0 +1,79 @@ +import { forwardRef } from 'react'; +import { useParams } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import useAlert from '@/hooks/useAlert'; +import { Modal, ModalContent, ModalFooter, ModalHandle, ModalHeader } from '@/components/ui/Modal/Modal'; +import { Input } from '@/components/ui/Field'; +import Button from '@/components/ui/Button/Button'; +import { inviteDashboardFormSchema, InviteDashboardType } from '@/apis/dashboards/types'; +import { useDashboardMutation } from '@/apis/dashboards/queries'; +import { getErrorMessage } from '@/utils/errorMessage'; + +const InviteDashboard = forwardRef((props, ref) => { + const { + handleSubmit, + register, + reset, + formState: { errors, isValid, isSubmitting }, + } = useForm({ + resolver: zodResolver(inviteDashboardFormSchema), + mode: 'onBlur', + defaultValues: { + email: '', + }, + }); + const { id } = useParams<{ id: string }>(); + const { invite } = useDashboardMutation(); + const alert = useAlert(); + + const handleReset = () => { + reset(); + if (ref && 'current' in ref) { + ref.current?.close(); + } + }; + + const onSubmit = async (formData: InviteDashboardType) => { + try { + await invite({ id: Number(id), email: formData.email }); + alert('초대했습니다.'); + if (ref && 'current' in ref) { + ref.current?.close(); + } + } catch (error) { + const message = getErrorMessage(error); + alert(message); + } + }; + + return ( + + +
+ 초대하기 +
+ +
+ + + + +
+
+
+ ); +}); + +InviteDashboard.displayName = 'InviteDashboard'; + +export default InviteDashboard; diff --git a/src/components/dashboard/MyDashboardList.tsx b/src/components/dashboard/MyDashboardList.tsx index 0663ae2..67974ba 100644 --- a/src/components/dashboard/MyDashboardList.tsx +++ b/src/components/dashboard/MyDashboardList.tsx @@ -53,12 +53,12 @@ export default function MyDashboardList({ onAdd }: MyDashboardListProps) { ))} - {data?.totalCount && ( + {totalCount > 0 && (
{totalPage} 페이지중 {page} - +
)} diff --git a/src/components/dashboard/MyInvitedDashboardList.tsx b/src/components/dashboard/MyInvitedDashboardList.tsx new file mode 100644 index 0000000..debcc7f --- /dev/null +++ b/src/components/dashboard/MyInvitedDashboardList.tsx @@ -0,0 +1,91 @@ +'use client'; + +import Button from '../ui/Button/Button'; +import { useEffect, useState } from 'react'; +import { useInvitationMutation, useMyInvitationsQuery } from '@/apis/invitations/queries'; +import { useInView } from 'react-intersection-observer'; +import useDebounce from '@/hooks/useDebounce'; +import { Card, CardTitle } from '@/components/ui/Card/Card'; +import MyInvitedEmptyCard from './MyInvitedEmptyCard'; +import { SearchInput } from '../ui/Field'; +import useAlert from '@/hooks/useAlert'; +import { getErrorMessage } from '@/utils/errorMessage'; + +export default function MyInvitedDashboardList() { + const [searchKeyword, setSearchKeyword] = useState(''); + const debouncedKeyword = useDebounce(searchKeyword); + + const { data, fetchNextPage, hasNextPage } = useMyInvitationsQuery(10, debouncedKeyword); + const invitations = data?.pages.flatMap((page) => page.invitations) ?? []; + const [ref, inView] = useInView(); + const { accept } = useInvitationMutation(); + const alert = useAlert(); + + useEffect(() => { + if (inView && hasNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, fetchNextPage]); + + const hasNoInvitations = invitations.length === 0 && !searchKeyword; + const hasNoSearchResults = invitations.length === 0 && searchKeyword.length > 0; + + const handleAccept = async ({ id, flag }: { id: number; flag: boolean }) => { + try { + await accept({ id, flag }); + alert(flag ? '수락했습니다.' : '거절했습니다.'); + } catch (error) { + const message = getErrorMessage(error); + alert(message); + } + }; + + return ( + + 초대받은 대시보드 + + {hasNoInvitations ? ( + 아직 초대받은 대시보드가 없어요. + ) : ( +
+ setSearchKeyword(e.target.value)} placeholder='검색' /> + {hasNoSearchResults ? ( + 검색된 초대가 없습니다. + ) : ( + // TODO : 리스트 테이블 공용 컴포넌트화 필요 +
+
+
+
+ 이름 + 초대자 + 수락 여부 +
+
+ {invitations.map((item) => ( +
+ {item.dashboard.title} + {item.inviter.nickname} +
+ + +
+
+ ))} +
+
+ + {/* 공통 load more 트리거 요소 */} +
+
+
+ )} +
+ )} + + ); +} diff --git a/src/components/dashboard/MyInvitedEmptyCard.tsx b/src/components/dashboard/MyInvitedEmptyCard.tsx new file mode 100644 index 0000000..3154184 --- /dev/null +++ b/src/components/dashboard/MyInvitedEmptyCard.tsx @@ -0,0 +1,12 @@ +import { PropsWithChildren } from 'react'; +import Image from 'next/image'; +import empty from '@/assets/icons/empty.svg'; + +export default function MyInvitedEmptyCard({ children }: PropsWithChildren) { + return ( +
+ empty + {children} +
+ ); +} diff --git a/src/components/invited-dashboard/InvitedDashboardCard.tsx b/src/components/invited-dashboard/InvitedDashboardCard.tsx deleted file mode 100644 index 0b666e6..0000000 --- a/src/components/invited-dashboard/InvitedDashboardCard.tsx +++ /dev/null @@ -1,127 +0,0 @@ -'use client'; - -import Image from 'next/image'; -import Button from '../ui/Button/Button'; -import searchIc from '@/assets/icons/search.svg'; -import empty_dashboard from '@/assets/images/empty_dashboard.png'; -import { useEffect, useRef, useState } from 'react'; -import { useInfiniteInvitations } from '@/hooks/useInfiniteInvitations'; - -export default function InvitedDashboardCard() { - const [searchKeyword, setSearchKeyword] = useState(''); - const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteInvitations(); - const invitations = data?.pages.flatMap((page) => page.invitations) ?? []; - - const filteredInvitations = invitations.filter((item) => item.dashboard.title.toLowerCase().includes(searchKeyword.toLowerCase())); - - // 스크롤 컨테이너와 load more 요소의 ref 생성 - const scrollContainerRef = useRef(null); - const loadMoreRef = useRef(null); - - useEffect(() => { - const container = scrollContainerRef.current; - const trigger = loadMoreRef.current; - if (!container || !trigger) return; - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - console.log('Observer entry:', entry); - if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) { - fetchNextPage(); - } - }); - }, - { - root: container, - rootMargin: '100px', - threshold: 0.1, - }, - ); - - observer.observe(trigger); - - return () => { - observer.disconnect(); - }; - }, [fetchNextPage, hasNextPage, isFetchingNextPage]); - - return ( -
- {invitations.length === 0 ? ( -
-

초대받은 대시보드

-
- empty_dashboard - 아직 초대받은 대시보드가 없어요. -
-
- ) : ( -
- {/* 검색 영역 */} -
-

초대받은 대시보드

-
- search - setSearchKeyword(e.target.value)} /> -
-
- {/* 스크롤 컨테이너 */} -
- {/* 데스크탑 레이아웃 */} -
-
- 이름 - 초대자 - 수락 여부 -
-
- {filteredInvitations.map((item) => ( -
- {item.dashboard.title} - {item.inviter.nickname} -
- - -
-
- ))} -
-
- {/* 모바일 레이아웃 */} -
-
- {filteredInvitations.map((item) => ( -
-
-
- 이름 - {item.dashboard.title} -
-
- 초대자 - {item.inviter.nickname} -
-
- - -
-
-
- ))} -
-
- {/* 공통 load more 트리거 요소 */} -
-
-
- )} -
- ); -} diff --git a/src/components/ui/Card/Card.tsx b/src/components/ui/Card/Card.tsx new file mode 100644 index 0000000..c5a585c --- /dev/null +++ b/src/components/ui/Card/Card.tsx @@ -0,0 +1,9 @@ +import { PropsWithChildren } from 'react'; + +export function Card({ children }: PropsWithChildren) { + return
{children}
; +} + +export function CardTitle({ children }: PropsWithChildren) { + return

{children}

; +} diff --git a/src/hooks/useDebounce.tsx b/src/hooks/useDebounce.tsx new file mode 100644 index 0000000..db5c39f --- /dev/null +++ b/src/hooks/useDebounce.tsx @@ -0,0 +1,15 @@ +import { useState, useEffect } from 'react'; + +export default function useDebounce(value: T, delay: number = 300) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/hooks/useInfiniteInvitations.ts b/src/hooks/useInfiniteInvitations.ts deleted file mode 100644 index ce9c2ad..0000000 --- a/src/hooks/useInfiniteInvitations.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useInfiniteQuery, QueryFunctionContext } from '@tanstack/react-query'; -import mockData from '@/apis/invitations/mockData'; -import { InvitationsResponse } from '@/apis/invitations/types'; - -const PAGE_SIZE = 5; - -async function fetchInvitations({ pageParam = 0 }: { pageParam?: number }): Promise { - const start = pageParam * PAGE_SIZE; - const end = start + PAGE_SIZE; - const pageData = mockData.invitations.slice(start, end); - return { - invitations: pageData, - nextPage: end < mockData.invitations.length ? pageParam + 1 : undefined, - }; -} - -export function useInfiniteInvitations() { - return useInfiniteQuery({ - queryKey: ['invitations'], - queryFn: ({ pageParam = 0 }: QueryFunctionContext) => fetchInvitations({ pageParam: pageParam as number }), - getNextPageParam: (lastPage: InvitationsResponse) => lastPage.nextPage, - initialPageParam: 0, - }); -} diff --git a/src/utils/errorMessage.ts b/src/utils/errorMessage.ts new file mode 100644 index 0000000..cacec77 --- /dev/null +++ b/src/utils/errorMessage.ts @@ -0,0 +1,19 @@ +import { isAxiosError } from 'axios'; + +interface ErrorResponse { + message?: string; +} + +export const getErrorMessage = (error: unknown): string => { + if (isAxiosError(error)) { + const response = error.response?.data as ErrorResponse | undefined; + + if (response?.message) { + return response.message; + } + + return error.message; + } + + return error instanceof Error ? error.message : '알 수 없는 에러가 발생했어요.'; +};