From 89d00f096751b98d462c3b694765766aab599759 Mon Sep 17 00:00:00 2001 From: LMS10 Date: Thu, 14 May 2026 20:16:04 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=84=A4=EC=A0=95,=20?= =?UTF-8?q?=EB=A9=A4=EB=B2=84,=20=EC=82=AD=EC=A0=9C=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/workspace/workspace.type.ts | 32 ++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/apis/workspace/workspace.type.ts b/src/apis/workspace/workspace.type.ts index 42905cd..a178b08 100644 --- a/src/apis/workspace/workspace.type.ts +++ b/src/apis/workspace/workspace.type.ts @@ -121,3 +121,35 @@ export interface WorkspaceMembersResponse { traceId: string; }; } + +export interface UpdateWorkspaceSettingsRequest { + name: string; + color: string; +} + +export interface UpdateWorkspaceSettingsResponse { + success: boolean; + data: null; + meta: { + timestamp: string; + traceId: string; + }; +} + +export interface DeleteWorkspaceMemberResponse { + success: boolean; + data: null; + meta: { + timestamp: string; + traceId: string; + }; +} + +export interface DeleteWorkspaceResponse { + success: boolean; + data: null; + meta: { + timestamp: string; + traceId: string; + }; +} From bd36e053e4c2a1e592bbd188d19853e48a9dc3bc Mon Sep 17 00:00:00 2001 From: LMS10 Date: Thu, 14 May 2026 20:20:57 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=88=98=EC=A0=95,=20=EB=A9=A4=EB=B2=84=20=EA=B0=95=ED=87=B4,?= =?UTF-8?q?=20=EC=9B=8C=ED=81=AC=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/workspace/workspace.service.ts | 55 +++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/apis/workspace/workspace.service.ts b/src/apis/workspace/workspace.service.ts index 338e1c4..0710562 100644 --- a/src/apis/workspace/workspace.service.ts +++ b/src/apis/workspace/workspace.service.ts @@ -10,6 +10,10 @@ import { AdminNameResponse, WorkspaceMembersResponse, WorkspaceMemberStatus, + UpdateWorkspaceSettingsRequest, + UpdateWorkspaceSettingsResponse, + DeleteWorkspaceMemberResponse, + DeleteWorkspaceResponse, } from './workspace.type'; const BASE_URL = process.env.NEXT_PUBLIC_API_URL; @@ -133,4 +137,55 @@ export const workspaceService = { if (!res.ok) throw new Error('멤버 목록 조회에 실패했습니다.'); return res.json(); }, + + updateWorkspaceSettings: async ( + workspaceId: number, + body: UpdateWorkspaceSettingsRequest, + accessToken: string, + ): Promise => { + const res = await fetch(`${BASE_URL}/api/v1/workspaces/${workspaceId}/settings`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(body), + }); + + if (res.status === 403) throw new Error('수정 권한이 없습니다.'); + if (res.status === 404) throw new Error('워크스페이스를 찾을 수 없습니다.'); + if (!res.ok) throw new Error('워크스페이스 설정 수정에 실패했습니다.'); + return res.json(); + }, + + deleteWorkspaceMember: async ( + workspaceId: number, + userId: number, + accessToken: string, + ): Promise => { + const res = await fetch(`${BASE_URL}/api/v1/workspaces/${workspaceId}/members/${userId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (res.status === 403) throw new Error('강퇴 권한이 없습니다.'); + if (res.status === 404) throw new Error('멤버를 찾을 수 없습니다.'); + if (!res.ok) throw new Error('멤버 강퇴에 실패했습니다.'); + return res.json(); + }, + + deleteWorkspace: async ( + workspaceId: number, + accessToken: string, + ): Promise => { + const res = await fetch(`${BASE_URL}/api/v1/workspaces/${workspaceId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (res.status === 403) throw new Error('삭제 권한이 없습니다.'); + if (res.status === 404) throw new Error('워크스페이스를 찾을 수 없습니다.'); + if (!res.ok) throw new Error('워크스페이스 삭제에 실패했습니다.'); + return res.json(); + }, }; From 7f54529547c104a403a7dfac62747e64d741f74a Mon Sep 17 00:00:00 2001 From: LMS10 Date: Thu, 14 May 2026 20:21:23 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=88=98=EC=A0=95,=20=EB=A9=A4=EB=B2=84=20=EA=B0=95=ED=87=B4,?= =?UTF-8?q?=20=EC=9B=8C=ED=81=AC=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C,=20=ED=9B=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/workspace/workspace.queries.ts | 52 ++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/src/apis/workspace/workspace.queries.ts b/src/apis/workspace/workspace.queries.ts index 76bd577..27d7b00 100644 --- a/src/apis/workspace/workspace.queries.ts +++ b/src/apis/workspace/workspace.queries.ts @@ -4,6 +4,7 @@ import { workspaceService } from './workspace.service'; import { CreateWorkspaceRequest, InviteWorkspaceRequest, + UpdateWorkspaceSettingsRequest, WorkspaceMemberStatus, } from './workspace.type'; @@ -66,6 +67,18 @@ export const useGetAdminName = (workspaceId: number) => { }); }; +export const useGetWorkspaceMembers = (workspaceId: number, status: WorkspaceMemberStatus) => { + const { data: session } = useSession(); + const accessToken = session?.accessToken as string | undefined; + + return useQuery({ + queryKey: workspaceKeys.members(workspaceId, status), + queryFn: () => workspaceService.getWorkspaceMembers(workspaceId, status, accessToken!), + enabled: !!accessToken && !!workspaceId, + staleTime: 1000 * 60 * 5, + }); +}; + export const useCreateWorkspace = () => { const { data: session } = useSession(); const accessToken = session?.accessToken as string | undefined; @@ -123,14 +136,41 @@ export const useRejectInvitation = () => { }); }; -export const useGetWorkspaceMembers = (workspaceId: number, status: WorkspaceMemberStatus) => { +export const useUpdateWorkspaceSettings = (workspaceId: number) => { const { data: session } = useSession(); const accessToken = session?.accessToken as string | undefined; + const queryClient = useQueryClient(); - return useQuery({ - queryKey: workspaceKeys.members(workspaceId, status), - queryFn: () => workspaceService.getWorkspaceMembers(workspaceId, status, accessToken!), - enabled: !!accessToken && !!workspaceId, - staleTime: 1000 * 60 * 5, + return useMutation({ + mutationFn: (body: UpdateWorkspaceSettingsRequest) => + workspaceService.updateWorkspaceSettings(workspaceId, body, accessToken!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: workspaceKeys.detail(workspaceId) }); + queryClient.invalidateQueries({ queryKey: workspaceKeys.my }); + }, + }); +}; + +export const useDeleteWorkspaceMember = (workspaceId: number) => { + const { data: session } = useSession(); + const accessToken = session?.accessToken as string | undefined; + + return useMutation({ + mutationFn: (userId: number) => + workspaceService.deleteWorkspaceMember(workspaceId, userId, accessToken!), + }); +}; + +export const useDeleteWorkspace = () => { + const { data: session } = useSession(); + const accessToken = session?.accessToken as string | undefined; + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (workspaceId: number) => + workspaceService.deleteWorkspace(workspaceId, accessToken!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: workspaceKeys.my }); + }, }); }; From f86e89faee6697ba80a4b497b8bfabb8fb8d73c4 Mon Sep 17 00:00:00 2001 From: LMS10 Date: Thu, 14 May 2026 20:26:14 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace/[workspaceId]/settings/page.tsx | 294 +++++++++++++++++- 1 file changed, 293 insertions(+), 1 deletion(-) diff --git a/src/app/(after-login)/workspace/[workspaceId]/settings/page.tsx b/src/app/(after-login)/workspace/[workspaceId]/settings/page.tsx index 5906acd..bf255d2 100644 --- a/src/app/(after-login)/workspace/[workspaceId]/settings/page.tsx +++ b/src/app/(after-login)/workspace/[workspaceId]/settings/page.tsx @@ -1,3 +1,295 @@ +'use client'; + +import { useState } from 'react'; +import { toast } from 'react-toastify'; +import { useParams, useRouter } from 'next/navigation'; +import { + useGetWorkspace, + useGetWorkspaceMembers, + useUpdateWorkspaceSettings, + useDeleteWorkspaceMember, + useDeleteWorkspace, +} from '@/apis/workspace/workspace.queries'; +import { WorkspaceMember } from '@/apis/workspace/workspace.type'; +import Avatar from '@/components/Avatar'; +import Button from '@/components/Buttons'; +import ColorChips from '@/components/ColorChips'; +import Icon from '@/components/Icon'; +import Input from '@/components/Input'; +import ConfirmModal from '@/components/modal/contents/ConfirmModal'; +import InviteModal from '@/components/modal/contents/InviteModal'; +import Modal from '@/components/modal/Modal'; + export default function Page() { - return
워크스페이스 관리 페이지
; + const params = useParams(); + const router = useRouter(); + const workspaceId = Number( + Array.isArray(params?.workspaceId) ? params.workspaceId[0] : params?.workspaceId, + ); + + const { data: workspaceData } = useGetWorkspace(workspaceId); + const workspace = workspaceData?.data; + + const [name, setName] = useState(undefined); + const [color, setColor] = useState(undefined); + + const initializedRef = useState(false); + if (workspace && !initializedRef[0]) { + initializedRef[1](true); + if (name === undefined) setName(workspace.workspaceName); + if (color === undefined) setColor(workspace.color); + } + + const currentName = name ?? workspace?.workspaceName ?? ''; + const currentColor = color ?? workspace?.color ?? 'GREEN'; + + const isSettingsChanged = + workspace != null && + (currentName !== workspace.workspaceName || currentColor !== workspace.color); + + const { mutate: updateSettings, isPending: isUpdating } = useUpdateWorkspaceSettings(workspaceId); + + const handleUpdateSettings = () => { + updateSettings( + { name: currentName, color: currentColor }, + { + onSuccess: () => { + toast.success('워크스페이스 설정이 변경되었습니다.'); + }, + onError: (error: Error) => { + toast.error(error.message || '워크스페이스 설정 변경에 실패했습니다.'); + }, + }, + ); + }; + + const { data: membersData } = useGetWorkspaceMembers(workspaceId, 'ACCEPTED'); + const [kickedIds, setKickedIds] = useState([]); + const members: WorkspaceMember[] = (membersData?.data ?? []).filter( + (m: WorkspaceMember) => !kickedIds.includes(m.userId), + ); + + const [kickTarget, setKickTarget] = useState(null); + const { mutate: deleteMember } = useDeleteWorkspaceMember(workspaceId); + + const handleKickMember = async (member: WorkspaceMember) => { + return new Promise((resolve, reject) => { + deleteMember(member.userId, { + onSuccess: () => { + setKickedIds((prev) => [...prev, member.userId]); + toast.success(`${member.name}님을 강퇴했습니다.`); + resolve(); + }, + onError: (error: Error) => { + toast.error(error.message || '멤버 강퇴에 실패했습니다.'); + reject(error); + }, + }); + }); + }; + + const { data: pendingData } = useGetWorkspaceMembers(workspaceId, 'PENDING'); + const [cancelledIds, setCancelledIds] = useState([]); + const pendingMembers: WorkspaceMember[] = (pendingData?.data ?? []).filter( + (m: WorkspaceMember) => !cancelledIds.includes(m.userId), + ); + + const [cancelTarget, setCancelTarget] = useState(null); + const [showInviteModal, setShowInviteModal] = useState(false); + const { mutate: cancelInvite } = useDeleteWorkspaceMember(workspaceId); + + const handleCancelInvite = async (member: WorkspaceMember) => { + return new Promise((resolve, reject) => { + cancelInvite(member.userId, { + onSuccess: () => { + setCancelledIds((prev) => [...prev, member.userId]); + toast.success('초대를 취소했습니다.'); + resolve(); + }, + onError: (error: Error) => { + toast.error(error.message || '초대 취소에 실패했습니다.'); + reject(error); + }, + }); + }); + }; + + const [showDeleteWorkspaceModal, setShowDeleteWorkspaceModal] = useState(false); + const { mutate: deleteWorkspace } = useDeleteWorkspace(); + + const handleDeleteWorkspace = async () => { + return new Promise((resolve, reject) => { + deleteWorkspace(workspaceId, { + onSuccess: () => { + toast.success('워크스페이스가 삭제되었습니다.'); + router.push('/workspace'); + resolve(); + }, + onError: (error: Error) => { + toast.error(error.message || '워크스페이스 삭제에 실패했습니다.'); + reject(error); + }, + }); + }); + }; + + return ( +
+ + +
+

+ {workspace?.workspaceName} +

+ +
+ setName(e.target.value)} + /> + +
+ + +
+ +
+
+

멤버

+
+ +

이름

+ +
+ {members.length === 0 ? ( +

멤버가 없습니다.

+ ) : ( +
    + {members.map((member) => ( +
  • + + {member.role !== 'ADMIN' && ( + + )} +
  • + ))} +
+ )} +
+
+ +
+
+

초대 내역

+ +
+ +

이메일

+ +
+ {pendingMembers.length === 0 ? ( +

초대 내역이 없습니다.

+ ) : ( +
    + {pendingMembers.map((member) => ( +
  • + {member.email} + +
  • + ))} +
+ )} +
+
+ +
+ +
+ + {kickTarget && ( + setKickTarget(null)}> + handleKickMember(kickTarget)} + onClose={() => setKickTarget(null)} + /> + + )} + + {cancelTarget && ( + setCancelTarget(null)}> + handleCancelInvite(cancelTarget)} + onClose={() => setCancelTarget(null)} + /> + + )} + + {showDeleteWorkspaceModal && ( + setShowDeleteWorkspaceModal(false)}> + setShowDeleteWorkspaceModal(false)} + /> + + )} + + {showInviteModal && ( + setShowInviteModal(false)}> + setShowInviteModal(false)} /> + + )} +
+ ); } From 8533dabdbdb1a651d88c964784d132737d969f91 Mon Sep 17 00:00:00 2001 From: LMS10 Date: Thu, 14 May 2026 20:26:49 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=86?= =?UTF-8?q?=EC=9D=84=20=EB=95=8C=20=EC=9D=B4=EB=8B=88=EC=85=9C=20=EC=95=84?= =?UTF-8?q?=EB=B0=94=ED=83=80=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Avatar.tsx | 27 ++++++++++++--------- src/components/header/HeaderUserProfile.tsx | 9 ++++--- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index c8c129f..58e9280 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -2,39 +2,44 @@ import Image from 'next/image'; import { cn } from '@/utils/cn'; -import Icon from './Icon'; interface AvatarProps { name: string; picture: string | null; member?: boolean; + className?: string; } const BASE_URL = process.env.NEXT_PUBLIC_API_URL; -export default function Avatar({ name, picture, member = false }: AvatarProps) { +export default function Avatar({ name, picture, member = false, className }: AvatarProps) { const imageUrl = picture ? picture.startsWith('http') ? picture : `${BASE_URL}${picture}` : null; + const initial = name.charAt(0); + + const sizeClass = member ? 'h-8.5 w-8.5 md:h-9.5 md:w-9.5' : 'h-7.5 w-7.5'; + const textClass = member ? 'text-sm md:text-base' : 'text-xs'; + return ( -
+
{imageUrl ? ( +
+ {name} +
+ ) : (
- {name} + {initial}
- ) : ( - )}
{name}
diff --git a/src/components/header/HeaderUserProfile.tsx b/src/components/header/HeaderUserProfile.tsx index 3e77df3..269761c 100644 --- a/src/components/header/HeaderUserProfile.tsx +++ b/src/components/header/HeaderUserProfile.tsx @@ -5,7 +5,6 @@ import { signOut } from 'next-auth/react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import Dropdown from '@/components/Dropdown'; -import Icon from '@/components/Icon'; interface HeaderUserProfileProps { name: string; @@ -26,6 +25,8 @@ export default function HeaderUserProfile({ name, picture }: HeaderUserProfilePr : `${BASE_URL}${picture}` : null; + const initial = name.charAt(0); + useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { @@ -51,11 +52,13 @@ export default function HeaderUserProfile({ name, picture }: HeaderUserProfilePr onClick={() => setOpen((prev) => !prev)} > {imageUrl ? ( -
+
{name}
) : ( - +
+ {initial} +
)} From 8bfc5224f05e52fe5f390e27d76d50b744a1691c Mon Sep 17 00:00:00 2001 From: LMS10 Date: Thu, 14 May 2026 20:27:13 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=92=84=20Design:=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=8C=A8=EB=94=A9=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(after-login)/mypage/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/(after-login)/mypage/page.tsx b/src/app/(after-login)/mypage/page.tsx index 98e6d51..38276bc 100644 --- a/src/app/(after-login)/mypage/page.tsx +++ b/src/app/(after-login)/mypage/page.tsx @@ -157,7 +157,7 @@ export default function Page() { }; return ( -
+
) : ( -
+
{initial}
)}