diff --git a/src/apis/workspace/workspace.queries.ts b/src/apis/workspace/workspace.queries.ts index ec65d19..1f526ec 100644 --- a/src/apis/workspace/workspace.queries.ts +++ b/src/apis/workspace/workspace.queries.ts @@ -1,12 +1,14 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import { useSession } from 'next-auth/react'; import { workspaceService } from './workspace.service'; -import { InviteWorkspaceRequest } from './workspace.type'; +import { CreateWorkspaceRequest, InviteWorkspaceRequest } from './workspace.type'; export const workspaceKeys = { all: ['workspaces'] as const, my: ['workspaces', 'my'] as const, detail: (workspaceId: number) => ['workspaces', workspaceId] as const, + invitations: ['workspaces', 'invitations'] as const, + adminName: (workspaceId: number) => ['workspaces', workspaceId, 'admin-name'] as const, }; export const useGetMyWorkspaces = () => { @@ -17,6 +19,21 @@ export const useGetMyWorkspaces = () => { queryKey: workspaceKeys.my, queryFn: () => workspaceService.getMyWorkspaces(accessToken!), enabled: !!accessToken, + staleTime: 1000 * 60, + placeholderData: keepPreviousData, + }); +}; + +export const useGetInvitations = () => { + const { data: session } = useSession(); + const accessToken = session?.accessToken as string | undefined; + + return useQuery({ + queryKey: workspaceKeys.invitations, + queryFn: () => workspaceService.getInvitations(accessToken!), + enabled: !!accessToken, + staleTime: 1000 * 60, + placeholderData: keepPreviousData, }); }; @@ -31,6 +48,32 @@ export const useGetWorkspace = (workspaceId: number) => { }); }; +export const useGetAdminName = (workspaceId: number) => { + const { data: session } = useSession(); + const accessToken = session?.accessToken as string | undefined; + + return useQuery({ + queryKey: workspaceKeys.adminName(workspaceId), + queryFn: () => workspaceService.getAdminName(workspaceId, accessToken!), + enabled: !!accessToken && !!workspaceId, + staleTime: 1000 * 60 * 5, + }); +}; + +export const useCreateWorkspace = () => { + const { data: session } = useSession(); + const accessToken = session?.accessToken as string | undefined; + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: CreateWorkspaceRequest) => + workspaceService.createWorkspace(body, accessToken!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: workspaceKeys.my }); + }, + }); +}; + export const useInviteWorkspace = (workspaceId: number) => { const { data: session } = useSession(); const accessToken = session?.accessToken as string | undefined; @@ -44,3 +87,32 @@ export const useInviteWorkspace = (workspaceId: number) => { }, }); }; + +export const useAcceptInvitation = () => { + const { data: session } = useSession(); + const accessToken = session?.accessToken as string | undefined; + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (membershipId: number) => + workspaceService.acceptInvitation(membershipId, accessToken!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: workspaceKeys.my }); + queryClient.invalidateQueries({ queryKey: workspaceKeys.invitations }); + }, + }); +}; + +export const useRejectInvitation = () => { + const { data: session } = useSession(); + const accessToken = session?.accessToken as string | undefined; + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (membershipId: number) => + workspaceService.rejectInvitation(membershipId, accessToken!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: workspaceKeys.invitations }); + }, + }); +}; diff --git a/src/apis/workspace/workspace.service.ts b/src/apis/workspace/workspace.service.ts index cfb517f..925a897 100644 --- a/src/apis/workspace/workspace.service.ts +++ b/src/apis/workspace/workspace.service.ts @@ -3,6 +3,11 @@ import { WorkspaceDetailResponse, InviteWorkspaceRequest, InviteWorkspaceResponse, + CreateWorkspaceRequest, + CreateWorkspaceResponse, + InvitationListResponse, + AcceptRejectInvitationResponse, + AdminNameResponse, } from './workspace.type'; const BASE_URL = process.env.NEXT_PUBLIC_API_URL; @@ -10,9 +15,7 @@ const BASE_URL = process.env.NEXT_PUBLIC_API_URL; export const workspaceService = { getMyWorkspaces: async (accessToken: string): Promise => { const res = await fetch(`${BASE_URL}/api/v1/workspaces/my`, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, + headers: { Authorization: `Bearer ${accessToken}` }, }); if (!res.ok) throw new Error('워크스페이스 목록 조회 실패'); @@ -24,13 +27,28 @@ export const workspaceService = { accessToken: string, ): Promise => { const res = await fetch(`${BASE_URL}/api/v1/workspaces/${workspaceId}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (res.status === 404) throw new Error('워크스페이스를 찾을 수 없습니다.'); + if (!res.ok) throw new Error('워크스페이스 조회 실패'); + return res.json(); + }, + + createWorkspace: async ( + body: CreateWorkspaceRequest, + accessToken: string, + ): Promise => { + const res = await fetch(`${BASE_URL}/api/v1/workspaces`, { + method: 'POST', headers: { + 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, }, + body: JSON.stringify(body), }); - if (res.status === 404) throw new Error('워크스페이스를 찾을 수 없습니다.'); - if (!res.ok) throw new Error('워크스페이스 조회 실패'); + if (!res.ok) throw new Error('워크스페이스 생성 실패'); return res.json(); }, @@ -53,4 +71,48 @@ export const workspaceService = { if (!res.ok) throw new Error('초대에 실패했습니다. 이메일을 확인해 주세요.'); return res.json(); }, + + getInvitations: async (accessToken: string): Promise => { + const res = await fetch(`${BASE_URL}/api/v1/workspaces/invitations`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!res.ok) throw new Error('초대 목록 조회 실패'); + return res.json(); + }, + + acceptInvitation: async ( + membershipId: number, + accessToken: string, + ): Promise => { + const res = await fetch(`${BASE_URL}/api/v1/workspaces/invitations/${membershipId}/accept`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!res.ok) throw new Error('초대 수락 실패'); + return res.json(); + }, + + rejectInvitation: async ( + membershipId: number, + accessToken: string, + ): Promise => { + const res = await fetch(`${BASE_URL}/api/v1/workspaces/invitations/${membershipId}/reject`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!res.ok) throw new Error('초대 거절 실패'); + return res.json(); + }, + + getAdminName: async (workspaceId: number, accessToken: string): Promise => { + const res = await fetch(`${BASE_URL}/api/v1/workspaces/${workspaceId}/admin-name`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!res.ok) throw new Error('어드민 이름 조회 실패'); + return res.json(); + }, }; diff --git a/src/apis/workspace/workspace.type.ts b/src/apis/workspace/workspace.type.ts index 6564291..d618527 100644 --- a/src/apis/workspace/workspace.type.ts +++ b/src/apis/workspace/workspace.type.ts @@ -46,3 +46,56 @@ export interface InviteWorkspaceResponse { traceId: string; }; } + +export interface CreateWorkspaceRequest { + name: string; + color: string; +} + +export interface CreateWorkspaceResponse { + success: boolean; + data: number; + meta: { + timestamp: string; + traceId: string; + }; +} + +export interface InvitationItem { + color: string; + membershipId: number; + role: WorkspaceRole; + workspaceId: number; + workspaceName: string; +} + +export interface InvitationListResponse { + success: boolean; + totalCount: number; + nextCursor: number | null; + data: InvitationItem[]; + meta: { + timestamp: string; + traceId: string; + }; +} + +export interface AcceptRejectInvitationResponse { + success: boolean; + data: null; + meta: { + timestamp: string; + traceId: string; + }; +} + +export interface AdminNameResponse { + success: boolean; + data: { + adminName: string; + }; + meta: { + timestamp: string; + traceId: string; + }; +} diff --git a/src/app/(after-login)/workspace/page.tsx b/src/app/(after-login)/workspace/page.tsx index f1169fb..75c4f21 100644 --- a/src/app/(after-login)/workspace/page.tsx +++ b/src/app/(after-login)/workspace/page.tsx @@ -1,3 +1,250 @@ +'use client'; + +import { toast } from 'react-toastify'; +import Link from 'next/link'; +import { + useGetMyWorkspaces, + useGetInvitations, + useGetAdminName, + useAcceptInvitation, + useRejectInvitation, +} from '@/apis/workspace/workspace.queries'; +import Button from '@/components/Buttons'; +import Icon from '@/components/Icon'; +import WorkspaceCard from '@/components/WorkspaceCard'; +import { useModalStore } from '@/store/modal.store'; + +function WorkspaceListSkeleton() { + return ( + <> + {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} + + ); +} + +function InvitationSkeleton() { + return ( + <> + {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ ))} + {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} + + ); +} + +function AdminName({ workspaceId }: { workspaceId: number }) { + const { data, isLoading } = useGetAdminName(workspaceId); + if (isLoading) return
; + return {data?.data.adminName ?? '-'}; +} + export default function Page() { - return
내 워크스페이스 페이지
; + const open = useModalStore((s) => s.open); + + const { data: wsData } = useGetMyWorkspaces(); + const { data: invData } = useGetInvitations(); + + const { mutate: acceptInvitation } = useAcceptInvitation(); + const { mutate: rejectInvitation } = useRejectInvitation(); + + const workspaces = wsData?.data ?? []; + const invitations = invData?.data ?? []; + + const handleAccept = (membershipId: number) => { + open({ + type: 'confirm', + message: '초대를 수락하시겠습니까?', + onConfirm: () => + new Promise((resolve, reject) => { + acceptInvitation(membershipId, { + onSuccess: () => { + toast.success('초대를 수락했습니다.'); + resolve(); + }, + onError: () => { + toast.error('초대 수락에 실패했습니다.'); + reject(); + }, + }); + }), + }); + }; + + const handleReject = (membershipId: number) => { + open({ + type: 'confirm', + message: '초대를 거절하시겠습니까?', + onConfirm: () => + new Promise((resolve, reject) => { + rejectInvitation(membershipId, { + onSuccess: () => { + toast.success('초대를 거절했습니다.'); + resolve(); + }, + onError: () => { + toast.error('초대 거절에 실패했습니다.'); + reject(); + }, + }); + }), + }); + }; + + return ( +
+
+
+
+ + + {!wsData ? ( + + ) : ( + workspaces.map((ws) => ( + + + + )) + )} +
+
+
+ +
+

+ 초대받은 워크스페이스 +

+ + {!invData ? ( + + ) : invitations.length === 0 ? ( +
+ +

아직 초대받은 워크스페이스가 없어요

+
+ ) : ( +
+
+ 이름 + 초대자 + 수락 여부 +
+ +
+ {invitations.map((inv) => ( +
+
+ {inv.workspaceName} + + + +
+ + +
+
+ +
+
+
+ 이름 + + {inv.workspaceName} + +
+
+ 초대자 + + + +
+
+
+ + +
+
+
+ ))} +
+
+ )} +
+
+ ); } diff --git a/src/app/globals.css b/src/app/globals.css index 51bc365..9b11d94 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -34,3 +34,12 @@ body { width: 4px; } } + +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} + +.scrollbar-hide::-webkit-scrollbar { + display: none; +} diff --git a/src/components/ColorChips.tsx b/src/components/ColorChips.tsx index 08b058f..b0f8e19 100644 --- a/src/components/ColorChips.tsx +++ b/src/components/ColorChips.tsx @@ -1,21 +1,25 @@ 'use client'; -import { useState } from 'react'; import { cn } from '../utils/cn'; import OneColorChip from './OneColorChip'; -export default function ColorChips() { - const [selected, setSelected] = useState(null); - const colors = ['green', 'purple', 'orange', 'blue', 'pink']; +const COLORS = ['GREEN', 'PURPLE', 'ORANGE', 'BLUE', 'PINK'] as const; +export type ChipColor = (typeof COLORS)[number]; +interface ColorChipsProps { + selected?: string; + onSelect?: (color: string) => void; +} + +export default function ColorChips({ selected, onSelect }: ColorChipsProps) { return (
- {colors.map((color) => ( + {COLORS.map((color) => ( setSelected(color)} + onSelect={() => onSelect?.(color)} variant='modal' /> ))} diff --git a/src/components/WorkspaceCard.tsx b/src/components/WorkspaceCard.tsx index 98c5094..6de7096 100644 --- a/src/components/WorkspaceCard.tsx +++ b/src/components/WorkspaceCard.tsx @@ -14,7 +14,7 @@ export default function WorkspaceCard({ name, color, role }: WorkspaceCardProps) return (
(null); + const router = useRouter(); + + const { mutate: createWorkspace, isPending } = useCreateWorkspace(); useEffect(() => { inputRef.current?.focus(); }, []); - const handleSubmit = async () => { + const handleSubmit = () => { if (!name) return; - try { - setLoading(true); - console.log('생성:', name); - onClose(); - } finally { - setLoading(false); - } + createWorkspace( + { name, color }, + { + onSuccess: (data) => { + toast.success('워크스페이스가 생성되었습니다.'); + onClose(); + router.push(`/workspace/${data.data}`); + }, + onError: () => { + toast.error('워크스페이스 생성에 실패했습니다.'); + onClose(); + }, + }, + ); }; return ( @@ -43,9 +56,9 @@ export default function CreateWorkspaceModal({ onClose }: Props) { value={name} onChange={(e) => setName(e.target.value)} /> - - +
+
- diff --git a/src/components/sidebar/SidebarContent.tsx b/src/components/sidebar/SidebarContent.tsx index 13c7e20..c27b452 100644 --- a/src/components/sidebar/SidebarContent.tsx +++ b/src/components/sidebar/SidebarContent.tsx @@ -17,7 +17,7 @@ export default function SidebarContent({ }: Props) { if (isLoading) { return ( -
+
); @@ -26,7 +26,7 @@ export default function SidebarContent({ if (state === 'empty') return null; return ( -
+
); diff --git a/src/components/sidebar/SidebarHeader.tsx b/src/components/sidebar/SidebarHeader.tsx index 82321d8..5dc1e80 100644 --- a/src/components/sidebar/SidebarHeader.tsx +++ b/src/components/sidebar/SidebarHeader.tsx @@ -2,17 +2,21 @@ import Image from 'next/image'; import Link from 'next/link'; +import { useModalStore } from '@/store/modal.store'; import Icon from '../Icon'; export default function SidebarHeader() { + const open = useModalStore((s) => s.open); + return (
Re:Mate logo - - +
@@ -24,8 +28,9 @@ export default function SidebarHeader() {
Workspaces - - +
diff --git a/src/components/sidebar/SidebarView.tsx b/src/components/sidebar/SidebarView.tsx index e35ba16..eb6895e 100644 --- a/src/components/sidebar/SidebarView.tsx +++ b/src/components/sidebar/SidebarView.tsx @@ -15,9 +15,7 @@ interface SidebarViewProps { export default function SidebarView({ workspaces, totalCount, isLoading }: SidebarViewProps) { const params = useParams(); - const selectedWorkspaceId = params?.workspaceId ? Number(params.workspaceId) : undefined; - const state: SidebarState = totalCount === 0 ? 'empty' : selectedWorkspaceId ? 'selected' : 'all'; return (