diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 5f06366..18a953e 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -526,6 +526,26 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + /project-groups/{projectGroupId}/finish: + patch: + tags: [ProjectGroups] + security: + - bearerAuth: [] + summary: Agree to finish an active project group + description: Records the current member's finish agreement. The project group is finished after all required members agree. + parameters: + - $ref: '#/components/parameters/ProjectGroupId' + responses: + '200': + description: Finish agreement recorded + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' /project-groups/{projectGroupId}/checklists: get: tags: [ProjectChecklists] diff --git a/src/features/project-groups/hooks/use-project-group-queries.ts b/src/features/project-groups/hooks/use-project-group-queries.ts index f9a484c..8db4578 100644 --- a/src/features/project-groups/hooks/use-project-group-queries.ts +++ b/src/features/project-groups/hooks/use-project-group-queries.ts @@ -1,11 +1,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + finishProjectGroup, getMyProjectGroup, grantProjectGroupAdminPermission, revokeProjectGroupAdminPermission, } from "@/lib/api/project-groups"; -import type { ProjectGroupAdminPermissionRequest } from "@/lib/types/project-group"; +import type { + ProjectGroupAdminPermissionRequest, + ProjectGroupFinishRequest, +} from "@/lib/types/project-group"; import { isProjectGroupNotFoundError } from "@/features/project-groups/lib/errors"; export const projectGroupQueryKeys = { @@ -59,3 +63,15 @@ export function useRevokeProjectGroupAdminPermissionMutation() { }, }); } + +export function useFinishProjectGroupMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (payload: ProjectGroupFinishRequest) => + finishProjectGroup(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: projectGroupQueryKeys.all }); + }, + }); +} diff --git a/src/features/project-groups/lib/finish-agreement-storage.ts b/src/features/project-groups/lib/finish-agreement-storage.ts new file mode 100644 index 0000000..3baab8a --- /dev/null +++ b/src/features/project-groups/lib/finish-agreement-storage.ts @@ -0,0 +1,61 @@ +interface ProjectGroupFinishAgreementKey { + projectGroupId: number; + userId: number; +} + +const finishAgreementStorageKey = "team-po.project-group-finish-agreements"; + +export function hasStoredProjectGroupFinishAgreement( + key: ProjectGroupFinishAgreementKey, +) { + return readAgreementKeys().has(createAgreementKey(key)); +} + +export function storeProjectGroupFinishAgreement( + key: ProjectGroupFinishAgreementKey, +) { + if (typeof window === "undefined") { + return; + } + + const agreementKeys = readAgreementKeys(); + agreementKeys.add(createAgreementKey(key)); + window.localStorage.setItem( + finishAgreementStorageKey, + JSON.stringify([...agreementKeys]), + ); +} + +function readAgreementKeys() { + if (typeof window === "undefined") { + return new Set(); + } + + const rawValue = window.localStorage.getItem(finishAgreementStorageKey); + + if (!rawValue) { + return new Set(); + } + + try { + const parsedValue = JSON.parse(rawValue) as unknown; + + if (!Array.isArray(parsedValue)) { + return new Set(); + } + + return new Set( + parsedValue.filter((value): value is string => typeof value === "string"), + ); + } catch { + window.localStorage.removeItem(finishAgreementStorageKey); + return new Set(); + } +} + +function createAgreementKey({ + projectGroupId, + userId, +}: ProjectGroupFinishAgreementKey) { + return `${projectGroupId}:${userId}`; +} diff --git a/src/features/team/components/team-space-view.tsx b/src/features/team/components/team-space-view.tsx index d25dff8..f6e424a 100644 --- a/src/features/team/components/team-space-view.tsx +++ b/src/features/team/components/team-space-view.tsx @@ -41,6 +41,11 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { + hasStoredProjectGroupFinishAgreement, + storeProjectGroupFinishAgreement, +} from "@/features/project-groups/lib/finish-agreement-storage"; +import { + useFinishProjectGroupMutation, useGrantProjectGroupAdminPermissionMutation, useMyProjectGroupQuery, useRevokeProjectGroupAdminPermissionMutation, @@ -103,6 +108,11 @@ type ActionFeedback = { tone: "error" | "success"; }; type AdminPermissionFeedback = ActionFeedback; +type ProjectGroupFinishState = { + agreedProjectGroupId: number | null; + feedback: ActionFeedback | null; + feedbackProjectGroupId: number | null; +}; const tabs: Array<{ icon: ComponentType<{ className?: string }>; @@ -257,12 +267,18 @@ function RealTeamSpaceView({ isSignedIn }: { isSignedIn: boolean }) { useGrantProjectGroupAdminPermissionMutation(); const revokeAdminPermissionMutation = useRevokeProjectGroupAdminPermissionMutation(); + const finishProjectGroupMutation = useFinishProjectGroupMutation(); const { isPending: isCompletingGithubInstallation, mutate: completeGithubAppInstallation, } = useCompleteGithubAppInstallationMutation(); const [adminPermissionFeedback, setAdminPermissionFeedback] = useState(null); + const [finishState, setFinishState] = useState({ + agreedProjectGroupId: null, + feedback: null, + feedbackProjectGroupId: null, + }); const [githubCompletionFeedback, setGithubCompletionFeedback] = useState(null); const completedGithubInstallationKeyRef = useRef(null); @@ -295,6 +311,22 @@ function RealTeamSpaceView({ isSignedIn }: { isSignedIn: boolean }) { : revokeAdminPermissionMutation.isPending ? revokeAdminPermissionMutation.variables.targetUserId : null; + const currentProjectGroupId = projectGroup?.projectGroupId ?? null; + const currentProjectGroupUserId = projectGroup?.currentUserId ?? null; + const finishFeedback = + finishState.feedbackProjectGroupId === currentProjectGroupId + ? finishState.feedback + : null; + const hasStoredFinishAgreement = + currentProjectGroupId !== null && currentProjectGroupUserId !== null + ? hasStoredProjectGroupFinishAgreement({ + projectGroupId: currentProjectGroupId, + userId: currentProjectGroupUserId, + }) + : false; + const hasCurrentUserAgreedFinish = + finishState.agreedProjectGroupId === currentProjectGroupId || + hasStoredFinishAgreement; useEffect(() => { const installationIdParam = searchParams.get("installation_id"); @@ -398,6 +430,47 @@ function RealTeamSpaceView({ isSignedIn }: { isSignedIn: boolean }) { grantAdminPermissionMutation.mutate(payload, mutationOptions); } + function handleFinishProjectGroup() { + if (!projectGroup) { + return; + } + + const projectGroupId = projectGroup.projectGroupId; + const userId = projectGroup.currentUserId; + + setFinishState((current) => ({ + ...current, + feedback: null, + feedbackProjectGroupId: projectGroupId, + })); + finishProjectGroupMutation.mutate( + { projectGroupId }, + { + onError: (error: unknown) => { + setFinishState((current) => ({ + ...current, + feedback: { + message: getApiErrorMessage(error), + tone: "error", + }, + feedbackProjectGroupId: projectGroupId, + })); + }, + onSuccess: () => { + storeProjectGroupFinishAgreement({ projectGroupId, userId }); + setFinishState({ + agreedProjectGroupId: projectGroupId, + feedback: { + message: "팀 종료 동의를 기록했어요.", + tone: "success", + }, + feedbackProjectGroupId: projectGroupId, + }); + }, + }, + ); + } + return ( ) : null} + {isSignedIn && + projectGroupQuery.isSuccess && + !projectGroupQuery.data ? ( + + + + 매칭 화면으로 이동 + + + } + description="참여 중인 활성 팀 스페이스가 없어요." + status="팀 없음" + title="새 팀을 매칭할 수 있어요" + /> + ) : null} + {projectGroup ? ( <> void; + onFinishProjectGroup: () => void; pendingAdminPermissionTargetId: number | null; projectGroup: MyProjectGroup; }) { @@ -1573,8 +1676,8 @@ function RealManagePanel({
일부 준비 중} - description="멤버 관리자 권한은 바로 조정할 수 있어요. 팀 상태 편집은 준비 중이에요." + action={active} + description="멤버 관리자 권한과 팀 종료 동의를 관리해요." eyebrow="Manage" title="팀 관리" /> @@ -1601,9 +1704,55 @@ function RealManagePanel({
- 팀 이름과 상태 편집 API는 아직 연결되지 않았어요. 기능이 생기면 이 - 관리 탭에서 활성화할게요. + 팀 이름 편집은 준비 중이에요. 팀 종료는 모든 팀원의 동의가 모이면 + 완료돼요. +
+ + + + + + {hasCurrentUserAgreedFinish ? "agreed" : "pending"} + + } + description="진행 중인 팀 스페이스를 종료하려면 팀원 전원의 동의가 필요해요." + eyebrow="Finish" + title="팀 종료 동의" + /> +
+
+
+

+ {projectGroup.projectName} 종료 동의 +

+

+ 동의가 기록되면 팀 스페이스 종료 조건에 반영돼요. +

+
+
+
diff --git a/src/lib/api/mocks/handlers.ts b/src/lib/api/mocks/handlers.ts index 37fdb7d..3a8c991 100644 --- a/src/lib/api/mocks/handlers.ts +++ b/src/lib/api/mocks/handlers.ts @@ -56,6 +56,7 @@ let activeProjectRequestRole: MatchRole | null = null; let activeMatchMembers: MatchMemberResponse["members"] = []; let activeMatchProject: MatchProjectResponse | null = null; let activeProjectGroup: MyProjectGroup | null = createMockProjectGroup(); +let projectGroupFinishAgreementUserIds = new Set(); let activeProjectChecklists: ProjectChecklist[] = createMockProjectChecklists(); let activeDevGuide: DevGuideContent | null = createMockDevGuide(activeProjectGroup); @@ -257,6 +258,7 @@ function resetMatchState() { } function resetTeamSpaceApiState() { + projectGroupFinishAgreementUserIds = new Set(); activeProjectChecklists = activeProjectGroup ? createMockProjectChecklists() : []; @@ -965,10 +967,14 @@ export const handlers = [ ); } - if (email === previewAuthSeed.email && body.password === mockUserPasswords.get(email)) { + if ( + email === previewAuthSeed.email && + body.password === mockUserPasswords.get(email) + ) { currentUserId = 1; currentUser = createPreviewUser(); - currentPassword = mockUserPasswords.get(email) ?? previewAuthSeed.password; + currentPassword = + mockUserPasswords.get(email) ?? previewAuthSeed.password; resetDeleteEmailState(); resetMatchState(); activeProjectGroup = createMockProjectGroup(); @@ -985,10 +991,7 @@ export const handlers = [ ); } - if ( - email !== currentUser.email || - body.password !== currentPassword - ) { + if (email !== currentUser.email || body.password !== currentPassword) { return buildErrorResponse( 401, "이메일 또는 비밀번호가 올바르지 않습니다.", @@ -1864,6 +1867,44 @@ export const handlers = [ }, ), + http.patch( + getPath("/project-groups/:projectGroupId/finish"), + async ({ params, request }) => { + await delay(250); + syncSessionFromRequest(request); + + const projectGroupId = Number(params.projectGroupId); + + if (projectGroupId === 500) { + return buildErrorResponse( + 500, + "팀 스페이스 종료 동의 처리 중 서버 오류가 발생했습니다.", + "MATCH_DATA_ERROR", + ); + } + + const accessError = assertProjectGroupAccess(projectGroupId); + + if (accessError) { + return accessError; + } + + projectGroupFinishAgreementUserIds.add(currentUserId); + + const hasEveryoneAgreed = + activeProjectGroup?.members.every((member) => + projectGroupFinishAgreementUserIds.has(member.userId), + ) ?? false; + + if (hasEveryoneAgreed) { + activeProjectGroup = null; + resetTeamSpaceApiState(); + } + + return new HttpResponse(null, { status: 200 }); + }, + ), + http.get( getPath("/project-groups/:projectGroupId/checklists"), async ({ params, request }) => { diff --git a/src/lib/api/project-groups.ts b/src/lib/api/project-groups.ts index a22789f..4a9177f 100644 --- a/src/lib/api/project-groups.ts +++ b/src/lib/api/project-groups.ts @@ -1,5 +1,6 @@ import { apiRequest } from "@/lib/api/client"; import type { + ProjectGroupFinishRequest, MyProjectGroup, ProjectGroupAdminPermissionRequest, } from "@/lib/types/project-group"; @@ -31,3 +32,11 @@ export function revokeProjectGroupAdminPermission({ }, ); } + +export function finishProjectGroup({ + projectGroupId, +}: ProjectGroupFinishRequest) { + return apiRequest(`/project-groups/${projectGroupId}/finish`, { + method: "PATCH", + }); +} diff --git a/src/lib/types/project-group.ts b/src/lib/types/project-group.ts index c2f2add..fdb467f 100644 --- a/src/lib/types/project-group.ts +++ b/src/lib/types/project-group.ts @@ -8,6 +8,10 @@ export interface ProjectGroupAdminPermissionRequest { targetUserId: number; } +export interface ProjectGroupFinishRequest { + projectGroupId: number; +} + export interface ProjectGroupMember { admin: boolean; groupRole: ProjectGroupRole;