From 7bbc9ee3f74417d4c0b6123c3a440f0f0b29924a Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Mon, 18 Aug 2025 12:48:56 +0900 Subject: [PATCH 1/9] =?UTF-8?q?#173=20[FEAT]=20external.ts=EC=97=90=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/external.ts | 65 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/src/types/external.ts b/src/types/external.ts index 30a20072..c280b9a9 100644 --- a/src/types/external.ts +++ b/src/types/external.ts @@ -1,4 +1,6 @@ -import type { CursorBasedResponse } from './common'; +import type { ResponseCommentListDto } from './comment'; +import type { CommonResponse, CursorBasedResponse } from './common'; +import type { Goal } from './issue'; export type Deadline = { start: string; @@ -41,3 +43,64 @@ export type RequestExternalListDto = { }; export type ResponseExternalDto = CursorBasedResponse; + +// 외부 이슈 생성 +export type CreateExternalDetailDto = { + owner?: string; + repo?: string; + installationId?: number; + title: string; + content: string; + state: string; + priority: string; + managersId: number[]; + deadline?: Deadline; + extServiceType: string; + goalId?: number; // TODO: 이거 필수 요소 아닌 채로도 가능한지 확인 +}; + +export type CreateExternalResultDto = { + externalId: number; + createdAt: string; +}; + +export type ResponseCreateExternalDetatilDto = CommonResponse; + +// 외부 이슈 조회 +export type ViewExternalDetailDto = { + id: number; + name: string; + title: string; + content: string; + priority: string; + state: string; + startDate?: string | null; // TODO: 빼야 할 듯 + endDate?: string | null; // TODO: 빼야 할 듯 + goalId?: Pick; // TODO: 데이터 구조 통일해달라고 하기 + goalTitle?: Pick; // TODO: 데이터 구조 통일해달라고 하기 + extServiceType: string; + managers: Manager; + deadlines: Deadline; // TODO: 이름 통일해달라고 하기 + comments: ResponseCommentListDto; +}; + +export type ResponseViewIssueDetailDto = CommonResponse; + +// 외부 이슈 수정 +export type UpdateExternalDetailDto = { + // 변경사항이 없는 속성은 Null값 가능, 키 생략(undef)도 가능 + title?: string | null; + content?: string | null; + state?: string | null; + priority?: string | null; + managersId?: number[] | null; + deadline?: Deadline | null; + goalId?: number | null; +}; + +export type UpdateExternalResultDto = { + issueId: number; + updatedAt: string; // LocalDateTime +}; + +export type ResponseUpdateIssueDetailDto = CommonResponse; From 8bb9b0d1fe90f9e4f969259668abd058424c9cf7 Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Mon, 18 Aug 2025 12:53:34 +0900 Subject: [PATCH 2/9] =?UTF-8?q?#173=20[FEAT]=20=EC=99=B8=EB=B6=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=88=20=EC=9E=91=EC=84=B1=20API=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../external/usePostCreateExternalDetail.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/apis/external/usePostCreateExternalDetail.ts diff --git a/src/apis/external/usePostCreateExternalDetail.ts b/src/apis/external/usePostCreateExternalDetail.ts new file mode 100644 index 00000000..08f8e0aa --- /dev/null +++ b/src/apis/external/usePostCreateExternalDetail.ts @@ -0,0 +1,48 @@ +import { axiosInstance } from '../axios.ts'; +import { useMutation } from '@tanstack/react-query'; +import { mutationKey } from '../../constants/mutationKey.ts'; +import { queryKey } from '../../constants/queryKey.ts'; +import queryClient from '../../utils/queryClient.ts'; +import type { + CreateExternalDetailDto, + CreateExternalResultDto, + ResponseCreateExternalDetatilDto, +} from '../../types/external.ts'; + +/** + * 외부이슈 작성 함수 + * - 외부이슈 상세페이지 생성 모드에서 사용 + * - pages/external/ExternalDetail.tsx + * - pages/workspace/WorkspaceExternalDetail.tsx + */ +const createExternal = async ( + teamId: number, + payload: CreateExternalDetailDto +): Promise => { + try { + const response = await axiosInstance.post( + `/api/teams/${teamId}/externals`, + payload + ); + + if (!response.data.result) return Promise.reject(response); + return response.data.result; + } catch (error: any) { + console.error('외부이슈 작성 실패:', error); + console.log('👉 RESPONSE STATUS:', error?.response?.status); + console.log('👉 RESPONSE DATA:', error?.response?.data); + throw error; + } +}; + +export const useCreateExternal = (teamId: number) => { + return useMutation({ + mutationKey: [mutationKey.EXTERNAL_CREATE, teamId], + mutationFn: (payload) => createExternal(teamId, payload), + onSuccess: () => { + // 외부이슈 작성하여 POST 후 조회되는 데이터 최신화 + queryClient.invalidateQueries({ queryKey: [queryKey.EXTERNAL_LIST, teamId] }); + queryClient.invalidateQueries({ queryKey: [queryKey.EXTERNAL_NAME, teamId] }); + }, + }); +}; From 3428ce9ca35e44d70e04a7341431bd5a3b38c711 Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Mon, 18 Aug 2025 12:59:42 +0900 Subject: [PATCH 3/9] =?UTF-8?q?#173=20[FEAT]=20=EC=99=B8=EB=B6=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=88=20=EC=83=81=EC=84=B8=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/external/useGetExternalDetail.ts | 47 +++++++++++++++++++++++ src/types/external.ts | 4 +- 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 src/apis/external/useGetExternalDetail.ts diff --git a/src/apis/external/useGetExternalDetail.ts b/src/apis/external/useGetExternalDetail.ts new file mode 100644 index 00000000..c8cc72b9 --- /dev/null +++ b/src/apis/external/useGetExternalDetail.ts @@ -0,0 +1,47 @@ +import { axiosInstance } from '../axios.ts'; +import { useQuery } from '@tanstack/react-query'; +import { queryKey } from '../../constants/queryKey.ts'; +import type { ResponseViewExternalDetailDto, ViewExternalDetailDto } from '../../types/external.ts'; + +/** + * 외부이슈 상세 조회 함수 + * - 외부이슈 상세페이지 조회 모드에서 사용 + * - pages/external/ExternalDetail.tsx + * - pages/workspace/WorkspaceExternalDetail.tsx + */ +const getExternalDetail = async ( + teamId: number, + externalId: number +): Promise => { + try { + const { data } = await axiosInstance.get( + `/api/teams/${teamId}/externals/${externalId}` + ); + if (!data.result) return Promise.reject(data); + if (data?.isSuccess) { + console.log('조회 성공:', data.result); + } + return data.result; + } catch (error) { + console.error('외부이슈 상세 조회 실패', error); + throw error; + } +}; + +export const useGetExternalDetail = ( + teamId: number, + externalId: number, + opts?: { enabled?: boolean } +) => { + const enabled = (opts?.enabled ?? true) && Number.isFinite(externalId) && externalId > 0; + + return useQuery({ + queryKey: [queryKey.EXTERNAL_DETAIL, externalId], + queryFn: () => getExternalDetail(teamId, externalId), + enabled, // ← create 경로 등에서 NaN/0이면 쿼리 미실행 + retry: (failureCount, error: any) => { + if (error?.response?.status === 404) return false; // 404면 재시도 안함 + return failureCount < 2; + }, + }); +}; diff --git a/src/types/external.ts b/src/types/external.ts index c280b9a9..ce396335 100644 --- a/src/types/external.ts +++ b/src/types/external.ts @@ -84,7 +84,7 @@ export type ViewExternalDetailDto = { comments: ResponseCommentListDto; }; -export type ResponseViewIssueDetailDto = CommonResponse; +export type ResponseViewExternalDetailDto = CommonResponse; // 외부 이슈 수정 export type UpdateExternalDetailDto = { @@ -103,4 +103,4 @@ export type UpdateExternalResultDto = { updatedAt: string; // LocalDateTime }; -export type ResponseUpdateIssueDetailDto = CommonResponse; +export type ResponseUpdateExternalDetailDto = CommonResponse; From 855102de063bd109273a0883f2c8b1c4743c287c Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Mon, 18 Aug 2025 13:03:58 +0900 Subject: [PATCH 4/9] =?UTF-8?q?#173=20[FEAT]=20=EC=99=B8=EB=B6=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=88=20=EC=88=98=EC=A0=95=20API=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/external/usePatchExternalDetail.ts | 49 +++++++++++++++++++++ src/types/external.ts | 2 +- 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/apis/external/usePatchExternalDetail.ts diff --git a/src/apis/external/usePatchExternalDetail.ts b/src/apis/external/usePatchExternalDetail.ts new file mode 100644 index 00000000..9b4dd10d --- /dev/null +++ b/src/apis/external/usePatchExternalDetail.ts @@ -0,0 +1,49 @@ +import { axiosInstance } from '../axios.ts'; +import { useMutation } from '@tanstack/react-query'; +import { mutationKey } from '../../constants/mutationKey.ts'; +import { queryKey } from '../../constants/queryKey.ts'; +import queryClient from '../../utils/queryClient.ts'; +import type { + ResponseUpdateExternalDetailDto, + UpdateExternalDetailDto, + UpdateExternalResultDto, +} from '../../types/external.ts'; + +/** + * 외부이슈 수정 (PATCH) 함수 + * - 동일 teamId / 동일 externalId 대상의 상세 내용 반영 + * - pages/external/ExternalDetail.tsx + * - pages/workspace/WorkspaceExternalDetail.tsx + */ +const updateExternal = async ( + teamId: number, + externalId: number, + payload: UpdateExternalDetailDto +): Promise => { + try { + const response = await axiosInstance.patch( + `/api/teams/${teamId}/externals/${externalId}`, + payload + ); + + if (!response.data.result) return Promise.reject(response); + return response.data.result; + } catch (error: any) { + console.error('외부이슈 수정 실패:', error); + console.log('👉 RESPONSE STATUS:', error?.response?.status); + console.log('👉 RESPONSE DATA:', error?.response?.data); + throw error; + } +}; + +export const useUpdateExternal = (teamId: number, externalId: number) => { + return useMutation({ + mutationKey: [mutationKey.EXTERNAL_UPDATE, teamId, externalId], + mutationFn: (payload) => updateExternal(teamId, externalId, payload), + onSuccess: () => { + // 상세/목록/관련 파생 쿼리 최신화 + queryClient.invalidateQueries({ queryKey: [queryKey.EXTERNAL_LIST, teamId] }); + queryClient.invalidateQueries({ queryKey: [queryKey.EXTERNAL_NAME, teamId] }); + }, + }); +}; diff --git a/src/types/external.ts b/src/types/external.ts index ce396335..2f136e27 100644 --- a/src/types/external.ts +++ b/src/types/external.ts @@ -99,7 +99,7 @@ export type UpdateExternalDetailDto = { }; export type UpdateExternalResultDto = { - issueId: number; + externalId: number; updatedAt: string; // LocalDateTime }; From 4da7763594be23abce04a00195eb03ea71453eab Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Mon, 18 Aug 2025 18:00:48 +0900 Subject: [PATCH 5/9] =?UTF-8?q?#173=20[FEAT]=20=EC=99=B8=EB=B6=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=88=20=EC=83=81=EC=84=B8=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EC=97=90=20API=201=EC=B0=A8=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useExternalDeadlinePatch.ts | 94 +++++++ src/hooks/useHydrateExternalDetail.ts | 134 ++++++++++ src/hooks/useIssueDeadlinePatch.ts | 2 +- src/pages/external/ExternalDetail.tsx | 356 ++++++++++++++++++++++---- src/types/external.ts | 16 +- src/types/listItem.ts | 5 + 6 files changed, 550 insertions(+), 57 deletions(-) create mode 100644 src/hooks/useExternalDeadlinePatch.ts create mode 100644 src/hooks/useHydrateExternalDetail.ts diff --git a/src/hooks/useExternalDeadlinePatch.ts b/src/hooks/useExternalDeadlinePatch.ts new file mode 100644 index 00000000..0c4519c7 --- /dev/null +++ b/src/hooks/useExternalDeadlinePatch.ts @@ -0,0 +1,94 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { buildDeadlinePatch } from '../utils/deadlinePatch'; +import type { UpdateExternalDetailDto } from '../types/external'; + +type UseExternalDeadlinePatchParams = { + externalDetail: any; + isViewMode: boolean; + canPatch: boolean; + mutateUpdate: (payload: UpdateExternalDetailDto, opts?: { onSuccess?: () => void }) => void; +}; + +/** + * - externalDetail의 기존 deadline(start/end)을 기억 + * - 달력 onSelect / edit 제출 시 변경분만 계산해 PATCH 전송 + */ +export function useExternalDeadlinePatch({ + externalDetail, + isViewMode, + canPatch, + mutateUpdate, +}: UseExternalDeadlinePatchParams) { + const originalDeadlineRef = useRef<{ start: string | null; end: string | null }>({ + start: null, + end: null, + }); + + // externalDetail 변경 시 원본 저장 + useEffect(() => { + const prevStart = + externalDetail?.deadline?.start && typeof externalDetail.deadline.start === 'string' + ? externalDetail.deadline.start + : null; + const prevEnd = + externalDetail?.deadline?.end && typeof externalDetail.deadline.end === 'string' + ? externalDetail.deadline.end + : null; + originalDeadlineRef.current = { start: prevStart, end: prevEnd }; + }, [externalDetail]); + + /** view 모드에서 달력 선택 → 즉시 PATCH */ + const handleSelectDateAndPatch = useCallback( + (date: [Date | null, Date | null]) => { + if (!isViewMode || !canPatch) return; + + const [nextStart, nextEnd] = date; + const patch = buildDeadlinePatch( + originalDeadlineRef.current.start, + originalDeadlineRef.current.end, + nextStart, + nextEnd + ); + + if (!patch) return; + + mutateUpdate(patch, { + onSuccess: () => { + // 전송 성공 시 원본 갱신 + const d = patch.deadline; + originalDeadlineRef.current = { + start: + d.start !== undefined + ? d.start === 'null' + ? null + : d.start + : originalDeadlineRef.current.start, + end: + d.end !== undefined + ? d.end === 'null' + ? null + : d.end + : originalDeadlineRef.current.end, + }; + }, + }); + }, + [isViewMode, canPatch, mutateUpdate] + ); + + /** edit 모드에서 '작성 완료' 시 선택된 날짜로 PATCH 조각 생성 */ + const buildPatchForEditSubmit = useCallback((date: [Date | null, Date | null]) => { + const [nextStart, nextEnd] = date; + return buildDeadlinePatch( + originalDeadlineRef.current.start, + originalDeadlineRef.current.end, + nextStart, + nextEnd + ); + }, []); + + return { + handleSelectDateAndPatch, + buildPatchForEditSubmit, + }; +} diff --git a/src/hooks/useHydrateExternalDetail.ts b/src/hooks/useHydrateExternalDetail.ts new file mode 100644 index 00000000..ba6b1be8 --- /dev/null +++ b/src/hooks/useHydrateExternalDetail.ts @@ -0,0 +1,134 @@ +/** + * @todo + * - 실제 external.ts 데이터 구조에 맞게 리팩토링 + */ +import { useEffect, useRef } from 'react'; +import type { SubmitHandleRef } from '../components/DetailView/TextEditor/lexical-plugins/SubmitHandlePlugin'; +import { + type StatusCode, + type PriorityCode, + type ExternalCode, + EXTERNAL_CODES, +} from '../types/listItem'; +import type { SimpleGoal } from '../types/goal'; +import type { ViewExternalDetailDto } from '../types/external'; + +type Params = { + externalDetail?: ViewExternalDetailDto | undefined; + externalId?: number; + editorRef: React.RefObject; + + // 외부 옵션/매핑 준비여부 판단용 + workspaceMembers?: Array<{ memberId: number; name: string }>; + simpleGoals?: SimpleGoal[]; // 목표 연결용 간단 목록 + nameToId: Record; + + // 상태 세터들 + setTitle: (v: string) => void; + setState: (v: StatusCode) => void; + setPriority: (v: PriorityCode) => void; + setSelectedDate: (v: [Date | null, Date | null]) => void; + setManagersId: (v: number[]) => void; + setGoalId: (v: number | null) => void; // 단일 선택(없음 가능) + setExtServiceType: (v: ExternalCode | null) => void; +}; + +const isExternalCode = (v: unknown): v is ExternalCode => + typeof v === 'string' && (EXTERNAL_CODES as readonly string[]).includes(v as string); + +export const useHydrateExternalDetail = ({ + externalDetail, + externalId, + editorRef, + workspaceMembers, + simpleGoals, + nameToId, + setTitle, + setState, + setPriority, + setSelectedDate, + setManagersId, + setGoalId, + setExtServiceType, +}: Params) => { + const hydratedRef = useRef(false); + + useEffect(() => { + if (!externalDetail) return; + if (!Number.isFinite(externalId)) return; + if (hydratedRef.current) return; + + // 옵션 준비여부 판단 + // - 담당자가 존재하면 멤버 옵션 준비 필요 + const membersReady = + (workspaceMembers?.length ?? 0) > 0 || (externalDetail.managers?.cnt ?? 0) === 0; + + // - 목표 연결(단일) 세팅용: 서버 응답에 goal.id가 있으면 바로 세팅 가능 + // goal.id가 없고 title만 있을 경우, simpleGoals 준비 후 title->id 매핑 필요 + const needGoalsByTitle = !!externalDetail.goalTitle && externalDetail.goalId == null; + const goalsReady = needGoalsByTitle ? (simpleGoals?.length ?? 0) > 0 : true; + + if (!membersReady || !goalsReady) return; + + // 1) 기본 필드 + setTitle(externalDetail.title ?? ''); + setState((externalDetail.state ?? 'NONE') as StatusCode); + setPriority((externalDetail.priority ?? 'NONE') as PriorityCode); + setExtServiceType( + isExternalCode(externalDetail.extServiceType) ? externalDetail.extServiceType : null + ); + + // 2) 기한 + const s = externalDetail.deadline?.start ? new Date(externalDetail.deadline.start) : null; + const e = externalDetail.deadline?.end ? new Date(externalDetail.deadline.end) : null; + setSelectedDate([s, e]); + + // 3) 담당자 ids + if ((externalDetail.managers?.cnt ?? 0) > 0) { + const managerNames = externalDetail.managers?.info?.map((m) => m.name) ?? []; + const ids = managerNames + .map((n) => nameToId[n]) + .filter((v): v is number => typeof v === 'number'); + setManagersId(ids); + } else { + setManagersId([]); + } + + // 4) 목표 goalId (단일) + // - 우선 응답에 id가 있으면 그걸 사용 + // - 없고 title만 있으면 simpleGoals에서 title로 찾아 id 매핑 + // - 둘 다 없으면 null + if (externalDetail.goalId != null && typeof (externalDetail.goalId as any).id === 'number') { + setGoalId(Number((externalDetail.goalId as any).id)); + } else if (typeof (externalDetail as any).goalId === 'number') { + setGoalId(Number((externalDetail as any).goalId)); + } else if (externalDetail.goalTitle) { + const map = new Map((simpleGoals ?? []).map((g) => [g.title, g.id] as const)); + const mapped = map.get( + String((externalDetail.goalTitle as any).title ?? externalDetail.goalTitle) + ); + setGoalId(typeof mapped === 'number' ? mapped : null); + } else { + setGoalId(null); + } + + // 5) 에디터 역직렬화 + editorRef.current?.loadJson?.(externalDetail.content ?? ''); + + hydratedRef.current = true; + }, [ + externalDetail, + externalId, + editorRef, + workspaceMembers, + simpleGoals, + nameToId, + setTitle, + setState, + setPriority, + setSelectedDate, + setManagersId, + setGoalId, + setExtServiceType, + ]); +}; diff --git a/src/hooks/useIssueDeadlinePatch.ts b/src/hooks/useIssueDeadlinePatch.ts index 39ebe7c5..2228e187 100644 --- a/src/hooks/useIssueDeadlinePatch.ts +++ b/src/hooks/useIssueDeadlinePatch.ts @@ -24,7 +24,7 @@ export function useIssueDeadlinePatch({ end: null, }); - // goalDetail 변경 시 원본 저장 + // issueDetail 변경 시 원본 저장 useEffect(() => { const prevStart = issueDetail?.deadline?.start && typeof issueDetail.deadline.start === 'string' diff --git a/src/pages/external/ExternalDetail.tsx b/src/pages/external/ExternalDetail.tsx index 3f03a508..9f16125c 100644 --- a/src/pages/external/ExternalDetail.tsx +++ b/src/pages/external/ExternalDetail.tsx @@ -1,7 +1,7 @@ // ExternalDetail.tsx // 외부 상세페이지 -import { useState, useRef } from 'react'; +import { useState, useRef, useMemo, startTransition } from 'react'; import DetailHeader from '../../components/DetailView/DetailHeader'; import PropertyItem from '../../components/DetailView/PropertyItem'; import DetailTitle from '../../components/DetailView/DetailTitle'; @@ -20,23 +20,43 @@ import IcGoal from '../../assets/icons/goal.svg'; import IcExt from '../../assets/icons/external.svg'; import { getStatusColor } from '../../utils/listItemUtils'; -import { statusLabelToCode } from '../../types/detailitem.ts'; +import { priorityLabelToCode, statusLabelToCode } from '../../types/detailitem'; import CommentSection from '../../components/DetailView/Comment/CommentSection'; import CalendarDropdown from '../../components/Calendar/CalendarDropdown'; import { useDropdownActions, useDropdownInfo } from '../../hooks/useDropdown'; -import { formatDateDot } from '../../utils/formatDate'; +import { formatDateDot, formatDateHyphen } from '../../utils/formatDate'; import { useToggleMode } from '../../hooks/useToggleMode'; import { useParams } from 'react-router-dom'; -import { useGetExternalSimpleIssue } from '../../apis/external/useGetExternalSimplelssue.ts'; import { useGetExternalLinks } from '../../apis/external/useGetExternalLinks.ts'; import CommentInput from '../../components/DetailView/Comment/CommentInput'; import { usePostComment } from '../../apis/comment/usePostComment'; import MultiSelectPropertyItem from '../../components/DetailView/MultiSelectPropertyItem.tsx'; -import type { SubmitHandleRef } from '../../components/DetailView/TextEditor/lexical-plugins/SubmitHandlePlugin.tsx'; -import { useCreateGoal } from '../../apis/goal/usePostCreateGoalDetail.ts'; +import { + EMPTY_EDITOR_STATE, + type SubmitHandleRef, +} from '../../components/DetailView/TextEditor/lexical-plugins/SubmitHandlePlugin.tsx'; import { useIsMutating } from '@tanstack/react-query'; import { mutationKey } from '../../constants/mutationKey.ts'; +import { + EXTERNAL_LABELS, + PRIORITY_LABELS, + STATUS_LABELS, + LABEL_TO_EXTERNAL_CODE, + type ExternalCode, + type PriorityCode, + type StatusCode, +} from '../../types/listItem.ts'; +import { useGetWorkspaceMembers } from '../../apis/setting/useGetWorkspaceMembers.ts'; +import { useGetSimpleGoalList } from '../../apis/goal/useGetSimpleGoalList.ts'; +import { useCreateExternal } from '../../apis/external/usePostCreateExternalDetail.ts'; +import { useGetExternalDetail } from '../../apis/external/useGetExternalDetail.ts'; +import { useUpdateExternal } from '../../apis/external/usePatchExternalDetail.ts'; +import { useExternalDeadlinePatch } from '../../hooks/useExternalDeadlinePatch.ts'; +import type { CreateExternalDetailDto, UpdateExternalDetailDto } from '../../types/external.ts'; +import queryClient from '../../utils/queryClient.ts'; +import { queryKey } from '../../constants/queryKey.ts'; +import { useHydrateExternalDetail } from '../../hooks/useHydrateExternalDetail.ts'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -52,47 +72,166 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { const [selectedDate, setSelectedDate] = useState<[Date | null, Date | null]>([null, null]); // '기한' 속성의 달력 드롭다운: 시작일, 종료일 2개를 저장 const [title, setTitle] = useState(''); + const [state, setState] = useState('NONE'); + const [priority, setPriority] = useState('NONE'); + const [managersId, setManagersId] = useState([]); + const [extServiceType, setExtServiceType] = useState(null); + + const [goalId, setGoalId] = useState(null); // null 허용 const editorSubmitRef = useRef(null); // 텍스트에디터 컨텐츠 접근용 플래그 const isSubmittingRequestRef = useRef(false); // API 제출 중복 요청 가드 플래그 const teamId = Number(useParams<{ teamId: string }>().teamId); - /** - * @todo: 나중에 useCreateExt로 제대로 연결 - */ - const { isPending } = useCreateGoal(teamId); + + // extId를 useParams로부터 가져옴 + const { extId: extIdParam } = useParams<{ extId: string }>(); + const numericExternalId = Number(extIdParam); + + const { data: workspaceMembers } = useGetWorkspaceMembers(); + const { data: simpleGoals } = useGetSimpleGoalList(teamId); // 팀 목표 간단 조회 (select로 info만 나오도록 되어 있음) + const { mutate: submitExternal, isPending: isCreating } = useCreateExternal(teamId); + const { data: externalDetail } = useGetExternalDetail(teamId, numericExternalId, { + enabled: true, + }); + const { mutate: updateExternal, isPending: isUpdating } = useUpdateExternal( + teamId, + numericExternalId + ); + const isCreatingGlobal = useIsMutating({ mutationKey: [mutationKey.EXTERNAL_CREATE, teamId] }) > 0; - const isSaving = isPending || isCreatingGlobal || isSubmittingRequestRef.current; + const isSaving = isCreating || isUpdating || isCreatingGlobal || isSubmittingRequestRef.current; - const { isOpen, content } = useDropdownInfo(); // 현재 드롭다운의 열림 여부와 내용 가져옴 - const { openDropdown } = useDropdownActions(); + const { isOpen, content } = useDropdownInfo(); // 작성 완료 여부 (view 모드일 때 true) + const { openDropdown } = useDropdownActions(); // 수정 가능 여부 (create 또는 edit 모드일 때 true) const isCompleted = mode === 'view'; // 작성 완료 여부 (view 모드일 때 true) const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) - - const { data: externalIssues } = useGetExternalSimpleIssue(teamId); - const issues = externalIssues?.info.map((issue) => issue.title) || []; + const canPatch = Number.isFinite(numericExternalId); // PATCH 가능 조건 const { data: linkedTools } = useGetExternalLinks(teamId); const linkedToolsList = linkedTools - ? Object.entries(linkedTools) - .filter(([, value]) => value) - .map(([key]) => - key === 'linkedWithGithub' ? 'Github' : key === 'linkedWithSlack' ? 'Slack' : key - ) + ? [ + ...(linkedTools.linkedWithGithub ? [EXTERNAL_LABELS.GITHUB] : []), + ...(linkedTools.linkedWithSlack ? [EXTERNAL_LABELS.SLACK] : []), + ] : []; - // extId를 useParams로부터 가져옴 - const { extId } = useParams<{ extId: string }>(); + // 단일 선택 라벨 + const selectedStatusLabel = STATUS_LABELS[state]; + const selectedPriorityLabel = PRIORITY_LABELS[priority]; + const selectedExternalLabel = extServiceType ? EXTERNAL_LABELS[extServiceType] : '외부'; + + const selectedGoalLabel = useMemo(() => { + const match = (simpleGoals ?? []).find((g) => g.id === goalId); + return match?.title ?? '목표'; // 데이터 없거나 매칭 실패 시 기본 라벨 + }, [simpleGoals, goalId]); + + // 다중 선택 라벨 + const selectedManagerLabels = useMemo(() => { + if (!workspaceMembers) return []; + const idToName = new Map(workspaceMembers.map((m) => [m.memberId, m.name] as const)); + return managersId.map((id) => idToName.get(id)).filter((v): v is string => !!v); + }, [managersId, workspaceMembers]); + const [managersShowNoneLabel] = useState(false); + + // deadline('기한' 속성) patch 훅 + const { handleSelectDateAndPatch, buildPatchForEditSubmit } = useExternalDeadlinePatch({ + externalDetail, + isViewMode: isCompleted, + canPatch, + mutateUpdate: updateExternal, + }); const handleToggleMode = useToggleMode({ mode, setMode, type: 'ext', - id: Number(extId), + id: Number(extIdParam), isDefaultTeam: false, }); + // handleSubmit: Lexical 에디터 내용을 JSON 문자열로 직렬화 후 API로 전송하는 함수 + const handleSubmit = () => { + if (editorSubmitRef.current) { + // ref를 통해 직렬화된 에디터 내용 가져오기 + const serialized = editorSubmitRef.current?.getJson() ?? ''; + const byteLength = new TextEncoder().encode(serialized).length; + console.log('Serialized JSON byte length:', byteLength); + } + + if (isSaving) return; + isSubmittingRequestRef.current = true; + + const [start, end] = selectedDate; + + // 화면 상태를 공통 페이로드로 구성 + const basePayload = { + title, + content: editorSubmitRef.current?.getJson() ?? EMPTY_EDITOR_STATE, + state, + priority, + managersId, + ...(goalId !== null && goalId !== undefined && goalId !== -1 ? { goalId } : {}), + ...(extServiceType ? { extServiceType } : {}), + }; + + console.log('Request body:', basePayload); + + if (mode === 'create') { + // 생성 시에는 기존 로직 유지 (규칙 제약 없음) + const payload: CreateExternalDetailDto = { + ...basePayload, + deadline: { + ...(start ? { start: formatDateHyphen(start) } : {}), + ...(end ? { end: formatDateHyphen(end) } : {}), + }, + }; + + submitExternal(payload, { + onSuccess: ({ externalId }) => { + queryClient.invalidateQueries({ queryKey: [queryKey.EXTERNAL_LIST, String(teamId)] }); + queryClient.invalidateQueries({ queryKey: [queryKey.EXTERNAL_NAME, String(teamId)] }); + startTransition(() => handleToggleMode(externalId)); + }, + onSettled: () => { + isSubmittingRequestRef.current = false; + }, + }); + } else if (mode === 'edit') { + const patch = buildPatchForEditSubmit(selectedDate); + const { extServiceType: _omit, ...rest } = basePayload; + const payload = { ...rest, ...(patch ?? {}) } as UpdateExternalDetailDto; + + // 수정 시 goalId가 없으면 생략된 상태로 보냄 + if (goalId === null || goalId === undefined || goalId === -1) { + delete (payload as any).goalId; // goalId가 null, undefined, -1이면 삭제 + } + + updateExternal(payload, { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [queryKey.EXTERNAL_DETAIL, numericExternalId], + }); + startTransition(() => handleToggleMode()); + }, + onSettled: () => (isSubmittingRequestRef.current = false), + }); + } + }; + + // handleCompletion - 하단 작성 완료<-수정하기 버튼 클릭 시 실행 + // - create/edit → view: API 저장 후 모드 전환 + // - view → edit: API 호출 없이 모드 전환 + const handleCompletion = () => { + if (!isCompleted) { + // create 또는 edit 모드에서 view 모드로 전환하려는 시점 + handleSubmit(); // 저장 성공 시 모드 전환 + } else { + handleToggleMode(); // 모드 전환 + } + }; + // '기한' 속성의 텍스트(시작일, 종료일) 결정하는 함수 const getDisplayText = () => { const [start, end] = selectedDate; @@ -111,32 +250,73 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { 긴급: pr4, }; - // '담당자' 속성 아이콘 매핑 (나중에 API로부터 받아온 데이터로 대체 예정) - const managerIconMap = { - 담당자: IcProfile, - 없음: IcProfile, - 전채운: IcProfile, - 염주원: IcProfile, - 박유민: IcProfile, - 이가을: IcProfile, - 김선화: IcProfile, - 박진주: IcProfile, - }; + const goalOptions = useMemo( + () => ['없음', ...(simpleGoals ?? []).map((g) => g.title)], + [simpleGoals] + ); - const goalIconMap = { - 목표: IcGoal, - 없음: IcGoal, - '백호를 사용해서 다른 사람들과 협업해보기': IcGoal, - '기획 및 요구사항 분석': IcGoal, - }; + const goalIconMap = useMemo>(() => { + const map: Record = { 목표: IcGoal }; // '목표' 기본값도 포함 + for (const label of goalOptions) map[label] = IcGoal; + return map; + }, [goalOptions]); + + // 해당 teamId에 속한 멤버만 필터 + const teamMembers = useMemo( + () => (workspaceMembers ?? []).filter((m) => m.teams?.some((t) => t.teamId === teamId)), + [workspaceMembers, teamId] + ); + // '담당자' 항목의 옵션: ['없음', ...팀 멤버 이름들] + const managerOptions = useMemo(() => ['없음', ...teamMembers.map((m) => m.name)], [teamMembers]); + + // 멤버 이름 → 멤버 id 매핑 (선택 결과를 id 배열로 변환용) + const nameToId = useMemo( + () => Object.fromEntries(teamMembers.map((m) => [m.name, m.memberId] as const)), + [teamMembers] + ); + + // '담당자' 아이콘 매핑: 이름 → 프로필 URL(없으면 기본 아이콘), '담당자'/'없음' 기본 아이콘 포함 + const managerIconMap = useMemo>(() => { + const base: Record = { + 담당자: IcProfile, + 없음: IcProfile, + }; + for (const m of teamMembers) { + base[m.name] = m.profileImageUrl || IcProfile; + } + return base; + }, [teamMembers]); + + // title -> id 역매핑 + const goalTitleToId = useMemo(() => { + const info = simpleGoals ?? []; + return new Map(info.map((g) => [g.title, g.id] as const)); + }, [simpleGoals]); + + // 외부 툴 아이콘 매핑 const externalIconMap = { 외부: IcExt, Slack: IcExt, - Notion: IcExt, Github: IcExt, }; + useHydrateExternalDetail({ + externalDetail, + externalId: numericExternalId, + editorRef: editorSubmitRef, + workspaceMembers, + simpleGoals, // 단일 목표 라벨/매핑용 간단 목록 + nameToId, // 멤버 이름 -> id 매핑 + setTitle, + setState, + setPriority, + setSelectedDate, + setManagersId, + setGoalId, + setExtServiceType, + }); + const bottomRef = useRef(null); const shouldScrollRef = useRef(false); const { mutate: addComment } = usePostComment({ bottomRef, shouldScrollRef, useDoubleRaf: true }); @@ -159,7 +339,13 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { { + setTitle(v); + // view 모드에서 즉시 PATCH + if (isCompleted && Number.isFinite(numericExternalId)) { + updateExternal({ title: v }); + } + }} isEditable={isEditable} /> @@ -193,6 +379,14 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { const code = statusLabelToCode[label] ?? 'NONE'; return getStatusColor(code); }} + onSelect={(label) => { + const next = statusLabelToCode[label] ?? 'NONE'; + setState(next); + if (isCompleted && Number.isFinite(numericExternalId)) { + updateExternal({ state: next }); + } + }} + selected={selectedStatusLabel} /> @@ -202,6 +396,14 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { defaultValue="우선순위" options={['없음', '긴급', '높음', '중간', '낮음']} iconMap={priorityIconMap} + onSelect={(label) => { + const next = priorityLabelToCode[label] ?? 'NONE'; + setPriority(next); + if (isCompleted && Number.isFinite(numericExternalId)) { + updateExternal({ priority: next }); + } + }} + selected={selectedPriorityLabel} /> @@ -209,8 +411,37 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => {
e.stopPropagation()}> { + // 1) '없음'만 선택된 경우만 비우기 + if (labels.length === 1 && labels[0] === '없음') { + setManagersId([]); + if (isCompleted && Number.isFinite(numericExternalId)) { + updateExternal({ managersId: [] }); + } + return; + } + + // 2) '없음'이 다른 값과 섞여 오면 제거 + const cleaned = labels.filter((l) => l !== '없음'); + + const ids = cleaned + .map((label) => nameToId[label]) + .filter((v): v is number => typeof v === 'number'); + + setManagersId(ids); + if (isCompleted && Number.isFinite(numericExternalId)) { + updateExternal({ managersId: ids }); + } + }} + selected={ + managersId.length === 0 + ? managersShowNoneLabel + ? ['없음'] + : [] // 비어있지만 '없음'을 선택했으면 '없음'을 내려줌 + : selectedManagerLabels + } />
@@ -231,7 +462,10 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { {isOpen && content?.name === 'date' && ( setSelectedDate(date)} + onSelect={(date) => { + setSelectedDate(date); + handleSelectDateAndPatch(date); // view 모드 시 즉시 PATCH + }} /> )} @@ -239,7 +473,30 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { {/* (5) 목표 */}
e.stopPropagation()}> - + { + // '없음' 대응 (백엔드가 null 허용 전이라면 0으로) + if (label === '없음') { + setGoalId(null); + if (isCompleted && Number.isFinite(numericExternalId)) { + } + return; + } + + // title -> id 매핑 + const id = goalTitleToId.get(label); + if (typeof id === 'number') { + setGoalId(id); + if (isCompleted && Number.isFinite(numericExternalId)) { + updateExternal({ goalId: id }); + } + } + }} + />
{/* (6) 외부 */} @@ -248,6 +505,11 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { defaultValue="외부" options={linkedToolsList} iconMap={externalIconMap} + selected={selectedExternalLabel} + onSelect={(label) => { + const code = LABEL_TO_EXTERNAL_CODE[label]; + setExtServiceType(code ?? null); + }} /> @@ -258,7 +520,7 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { isTitleFilled={title.trim().length > 0} isCompleted={isCompleted} isSaving={isSaving} - onToggle={handleToggleMode} + onToggle={handleCompletion} /> diff --git a/src/types/external.ts b/src/types/external.ts index 2f136e27..b56993a7 100644 --- a/src/types/external.ts +++ b/src/types/external.ts @@ -3,8 +3,8 @@ import type { CommonResponse, CursorBasedResponse } from './common'; import type { Goal } from './issue'; export type Deadline = { - start: string; - end: string; + start?: string; + end?: string; }; export type ManagerInfo = { @@ -55,8 +55,8 @@ export type CreateExternalDetailDto = { priority: string; managersId: number[]; deadline?: Deadline; - extServiceType: string; - goalId?: number; // TODO: 이거 필수 요소 아닌 채로도 가능한지 확인 + extServiceType?: string; + goalId?: number; }; export type CreateExternalResultDto = { @@ -66,7 +66,7 @@ export type CreateExternalResultDto = { export type ResponseCreateExternalDetatilDto = CommonResponse; -// 외부 이슈 조회 +// 외부 이슈 상세 조회 export type ViewExternalDetailDto = { id: number; name: string; @@ -74,13 +74,11 @@ export type ViewExternalDetailDto = { content: string; priority: string; state: string; - startDate?: string | null; // TODO: 빼야 할 듯 - endDate?: string | null; // TODO: 빼야 할 듯 goalId?: Pick; // TODO: 데이터 구조 통일해달라고 하기 goalTitle?: Pick; // TODO: 데이터 구조 통일해달라고 하기 - extServiceType: string; + extServiceType?: string; managers: Manager; - deadlines: Deadline; // TODO: 이름 통일해달라고 하기 + deadline: Deadline; comments: ResponseCommentListDto; }; diff --git a/src/types/listItem.ts b/src/types/listItem.ts index 68d6c34e..d54bdacb 100644 --- a/src/types/listItem.ts +++ b/src/types/listItem.ts @@ -33,6 +33,11 @@ export const EXTERNAL_LABELS: Record = { SLACK: 'Slack', }; +// 라벨→코드 역매핑 +export const LABEL_TO_EXTERNAL_CODE: Record = Object.fromEntries( + EXTERNAL_CODES.map((code) => [EXTERNAL_LABELS[code], code]) +) as Record; + // 리스트 export const STATUS_LIST: readonly StatusCode[] = STATUS_CODES; export const PRIORITY_LIST: readonly PriorityCode[] = PRIORITY_CODES; From b2ec2bada36d5d1f06d1a8d71b2963923363830f Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Thu, 21 Aug 2025 10:54:22 +0900 Subject: [PATCH 6/9] =?UTF-8?q?#173=20[FEAT]=20=EA=B9=83=ED=97=88=EB=B8=8C?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99=20=EC=99=B8=EB=B6=80=EC=9D=B4=EC=8A=88?= =?UTF-8?q?=EC=9D=BC=20=EA=B2=BD=EC=9A=B0=20=EA=B9=83=ED=97=88=EB=B8=8C=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EB=A5=BC=20POST=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=97=90=20=EB=B3=B4=EB=82=B4=EB=8F=84=EB=A1=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/external/ExternalDetail.tsx | 58 ++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/src/pages/external/ExternalDetail.tsx b/src/pages/external/ExternalDetail.tsx index 9f16125c..c67ba2b6 100644 --- a/src/pages/external/ExternalDetail.tsx +++ b/src/pages/external/ExternalDetail.tsx @@ -57,6 +57,8 @@ import type { CreateExternalDetailDto, UpdateExternalDetailDto } from '../../typ import queryClient from '../../utils/queryClient.ts'; import { queryKey } from '../../constants/queryKey.ts'; import { useHydrateExternalDetail } from '../../hooks/useHydrateExternalDetail.ts'; +import { useGetGithubRepository } from '../../apis/external/useGetGithubRepository.ts'; +import { useGetGithubInstallationId } from '../../apis/external/useGetGithubInstallationId.ts'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -97,6 +99,8 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { teamId, numericExternalId ); + const { data: githubRepo } = useGetGithubRepository(teamId); + const { data: githubInstall } = useGetGithubInstallationId(teamId); const isCreatingGlobal = useIsMutating({ mutationKey: [mutationKey.EXTERNAL_CREATE, teamId] }) > 0; @@ -135,6 +139,14 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { }, [managersId, workspaceMembers]); const [managersShowNoneLabel] = useState(false); + // 깃허브 연동 외부이슈일 경우 POST 요청시 필요한 owner, repo, installationId 데이터 + const githubPayload = useMemo(() => { + const owner = githubRepo?.owner?.login; + const repo = githubRepo?.name; + const installationId = githubInstall?.installationId; + return { owner, repo, installationId }; + }, [githubRepo, githubInstall]); + // deadline('기한' 속성) patch 훅 const { handleSelectDateAndPatch, buildPatchForEditSubmit } = useExternalDeadlinePatch({ externalDetail, @@ -179,9 +191,32 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { console.log('Request body:', basePayload); if (mode === 'create') { + // GitHub 선택 시 필수값 검증 + if (extServiceType === 'GITHUB') { + const { owner, repo, installationId } = githubPayload; + if (!owner || !repo || !installationId) { + isSubmittingRequestRef.current = false; + console.error('GitHub 연동 누락: owner/repo/installationId 필요', { + owner, + repo, + installationId, + }); + alert('GitHub 연동 정보가 부족합니다. 팀의 GitHub 레포지토리와 설치 ID를 확인해 주세요.'); + return; + } + } + // 생성 시에는 기존 로직 유지 (규칙 제약 없음) const payload: CreateExternalDetailDto = { ...basePayload, + // GitHub일 때만 추가 + ...(extServiceType === 'GITHUB' + ? { + owner: githubPayload.owner!, // GetGithubRepositoryResponse.owner.login + repo: githubPayload.repo!, // GetGithubRepositoryResponse.name + installationId: githubPayload.installationId!, // GetGithubInstallationIdResponse.installationId + } + : {}), deadline: { ...(start ? { start: formatDateHyphen(start) } : {}), ...(end ? { end: formatDateHyphen(end) } : {}), @@ -210,9 +245,13 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { updateExternal(payload, { onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: [queryKey.EXTERNAL_DETAIL, numericExternalId], - }); + if (Number.isFinite(numericExternalId)) { + queryClient.invalidateQueries({ queryKey: [queryKey.EXTERNAL_LIST, String(teamId)] }); + queryClient.invalidateQueries({ queryKey: [queryKey.EXTERNAL_NAME, String(teamId)] }); + queryClient.invalidateQueries({ + queryKey: [queryKey.EXTERNAL_DETAIL, numericExternalId], + }); + } startTransition(() => handleToggleMode()); }, onSettled: () => (isSubmittingRequestRef.current = false), @@ -255,11 +294,12 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { [simpleGoals] ); - const goalIconMap = useMemo>(() => { - const map: Record = { 목표: IcGoal }; // '목표' 기본값도 포함 - for (const label of goalOptions) map[label] = IcGoal; - return map; - }, [goalOptions]); + const goalIconMap = new Proxy( + {}, + { + get: () => IcGoal, + } + ) as Record; // 해당 teamId에 속한 멤버만 필터 const teamMembers = useMemo( @@ -298,7 +338,7 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { const externalIconMap = { 외부: IcExt, Slack: IcExt, - Github: IcExt, + GitHub: IcExt, }; useHydrateExternalDetail({ From 054925cf509acf5502ba6a98a73bbd66ca191275 Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Thu, 21 Aug 2025 11:32:32 +0900 Subject: [PATCH 7/9] =?UTF-8?q?#173=20[FEAT]=20=EC=99=B8=EB=B6=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=88=20API=20=EC=97=B0=EB=8F=99=201=EC=B0=A8=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../external/useGetGithubInstallationId.ts | 3 +- src/apis/external/useGetGithubRepository.ts | 3 +- src/pages/external/ExternalDetail.tsx | 46 +++++++++++++------ 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/src/apis/external/useGetGithubInstallationId.ts b/src/apis/external/useGetGithubInstallationId.ts index c38785de..572fdcbb 100644 --- a/src/apis/external/useGetGithubInstallationId.ts +++ b/src/apis/external/useGetGithubInstallationId.ts @@ -24,9 +24,10 @@ const getGithubInstallationId = async ( } }; -export const useGetGithubInstallationId = (teamId: number) => { +export const useGetGithubInstallationId = (teamId: number, opts?: { enabled?: boolean }) => { return useQuery({ queryKey: [queryKey.GITHUB_INSTALLATION_ID, teamId], queryFn: () => getGithubInstallationId(teamId), + enabled: opts?.enabled ?? true, }); }; diff --git a/src/apis/external/useGetGithubRepository.ts b/src/apis/external/useGetGithubRepository.ts index faa3402a..b2f7b4cc 100644 --- a/src/apis/external/useGetGithubRepository.ts +++ b/src/apis/external/useGetGithubRepository.ts @@ -32,9 +32,10 @@ const getGithubRepository = async (teamId: number): Promise { +export const useGetGithubRepository = (teamId: number, opts?: { enabled?: boolean }) => { return useQuery({ queryKey: [queryKey.GITHUB_REPOSITORIES, teamId], queryFn: () => getGithubRepository(teamId), + enabled: opts?.enabled ?? true, }); }; diff --git a/src/pages/external/ExternalDetail.tsx b/src/pages/external/ExternalDetail.tsx index c67ba2b6..2a74bc0a 100644 --- a/src/pages/external/ExternalDetail.tsx +++ b/src/pages/external/ExternalDetail.tsx @@ -1,7 +1,7 @@ // ExternalDetail.tsx // 외부 상세페이지 -import { useState, useRef, useMemo, startTransition } from 'react'; +import { useState, useRef, useMemo, startTransition, useEffect } from 'react'; import DetailHeader from '../../components/DetailView/DetailHeader'; import PropertyItem from '../../components/DetailView/PropertyItem'; import DetailTitle from '../../components/DetailView/DetailTitle'; @@ -99,8 +99,13 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { teamId, numericExternalId ); - const { data: githubRepo } = useGetGithubRepository(teamId); - const { data: githubInstall } = useGetGithubInstallationId(teamId); + const needGithubMeta = extServiceType === 'GITHUB'; + const { data: githubRepo, isError: isRepoError } = useGetGithubRepository(teamId, { + enabled: needGithubMeta, + }); + const { data: githubInstall, isError: isInstallError } = useGetGithubInstallationId(teamId, { + enabled: needGithubMeta, + }); const isCreatingGlobal = useIsMutating({ mutationKey: [mutationKey.EXTERNAL_CREATE, teamId] }) > 0; @@ -113,6 +118,23 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) const canPatch = Number.isFinite(numericExternalId); // PATCH 가능 조건 + // 깃허브 연동 외부이슈일 경우 POST 요청시 필요한 owner, repo, installationId 데이터 + const githubPayload = useMemo(() => { + const owner = githubRepo?.owner?.login; + const repo = githubRepo?.name; + const installationId = githubInstall?.installationId; + return { owner, repo, installationId }; + }, [githubRepo, githubInstall]); + + // 설치가 안 되어 있거나 리다이렉트로 막히는 경우 → 온보딩으로 이동 + useEffect(() => { + if (!needGithubMeta) return; + if (isRepoError || isInstallError) { + // 서버가 API 호출에 대해 302로 온보딩을 주는 구조라면, 프론트는 네비게이션으로 처리 + window.location.href = 'https://web.vecoservice.shop/onboarding'; + } + }, [needGithubMeta, isRepoError, isInstallError]); + const { data: linkedTools } = useGetExternalLinks(teamId); const linkedToolsList = linkedTools ? [ @@ -139,14 +161,6 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { }, [managersId, workspaceMembers]); const [managersShowNoneLabel] = useState(false); - // 깃허브 연동 외부이슈일 경우 POST 요청시 필요한 owner, repo, installationId 데이터 - const githubPayload = useMemo(() => { - const owner = githubRepo?.owner?.login; - const repo = githubRepo?.name; - const installationId = githubInstall?.installationId; - return { owner, repo, installationId }; - }, [githubRepo, githubInstall]); - // deadline('기한' 속성) patch 훅 const { handleSelectDateAndPatch, buildPatchForEditSubmit } = useExternalDeadlinePatch({ externalDetail, @@ -201,7 +215,9 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { repo, installationId, }); - alert('GitHub 연동 정보가 부족합니다. 팀의 GitHub 레포지토리와 설치 ID를 확인해 주세요.'); + // 안내 후 온보딩으로 + alert('GitHub 연동이 필요합니다. 온보딩 페이지로 이동합니다.'); + window.location.href = 'https://web.vecoservice.shop/onboarding'; return; } } @@ -212,9 +228,9 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { // GitHub일 때만 추가 ...(extServiceType === 'GITHUB' ? { - owner: githubPayload.owner!, // GetGithubRepositoryResponse.owner.login - repo: githubPayload.repo!, // GetGithubRepositoryResponse.name - installationId: githubPayload.installationId!, // GetGithubInstallationIdResponse.installationId + owner: githubPayload.owner!, + repo: githubPayload.repo!, + installationId: githubPayload.installationId!, } : {}), deadline: { From 0508b601164bc33a234699e6dcc2a2b7cb78f893 Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Thu, 21 Aug 2025 13:50:01 +0900 Subject: [PATCH 8/9] =?UTF-8?q?#173=20[FEAT]=20=EA=B9=83=ED=97=88=EB=B8=8C?= =?UTF-8?q?=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=ED=95=A8=EC=88=98,=20=EA=B9=83=ED=97=88?= =?UTF-8?q?=EB=B8=8C=20=EC=84=A4=EC=B9=98=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EB=82=B4=20fetcher=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=20export=20=EC=B2=98=EB=A6=AC=20=ED=9B=84=20ExternalDetail?= =?UTF-8?q?=EC=97=90=20API=20=EC=97=B0=EB=8F=99=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../external/useGetGithubInstallationId.ts | 2 +- src/apis/external/useGetGithubRepository.ts | 2 +- src/pages/external/ExternalDetail.tsx | 71 +++++++++++++------ 3 files changed, 50 insertions(+), 25 deletions(-) diff --git a/src/apis/external/useGetGithubInstallationId.ts b/src/apis/external/useGetGithubInstallationId.ts index 572fdcbb..6c6d4c7a 100644 --- a/src/apis/external/useGetGithubInstallationId.ts +++ b/src/apis/external/useGetGithubInstallationId.ts @@ -9,7 +9,7 @@ interface GetGithubInstallationIdResponse { } // 깃허브 설치 ID 조회 -const getGithubInstallationId = async ( +export const getGithubInstallationId = async ( teamId: number ): Promise => { try { diff --git a/src/apis/external/useGetGithubRepository.ts b/src/apis/external/useGetGithubRepository.ts index b2f7b4cc..e0d234b9 100644 --- a/src/apis/external/useGetGithubRepository.ts +++ b/src/apis/external/useGetGithubRepository.ts @@ -19,7 +19,7 @@ interface GetGithubRepositoryResponse { } // 깃허브 레포지토리 조회 -const getGithubRepository = async (teamId: number): Promise => { +export const getGithubRepository = async (teamId: number): Promise => { try { const response = await axiosInstance.get>( `/api/teams/${teamId}/github/repositories` diff --git a/src/pages/external/ExternalDetail.tsx b/src/pages/external/ExternalDetail.tsx index 2a74bc0a..dee708df 100644 --- a/src/pages/external/ExternalDetail.tsx +++ b/src/pages/external/ExternalDetail.tsx @@ -1,7 +1,7 @@ // ExternalDetail.tsx // 외부 상세페이지 -import { useState, useRef, useMemo, startTransition, useEffect } from 'react'; +import { useState, useRef, useMemo, startTransition } from 'react'; import DetailHeader from '../../components/DetailView/DetailHeader'; import PropertyItem from '../../components/DetailView/PropertyItem'; import DetailTitle from '../../components/DetailView/DetailTitle'; @@ -57,8 +57,14 @@ import type { CreateExternalDetailDto, UpdateExternalDetailDto } from '../../typ import queryClient from '../../utils/queryClient.ts'; import { queryKey } from '../../constants/queryKey.ts'; import { useHydrateExternalDetail } from '../../hooks/useHydrateExternalDetail.ts'; -import { useGetGithubRepository } from '../../apis/external/useGetGithubRepository.ts'; -import { useGetGithubInstallationId } from '../../apis/external/useGetGithubInstallationId.ts'; +import { + getGithubRepository, + useGetGithubRepository, +} from '../../apis/external/useGetGithubRepository.ts'; +import { + getGithubInstallationId, + useGetGithubInstallationId, +} from '../../apis/external/useGetGithubInstallationId.ts'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -100,10 +106,10 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { numericExternalId ); const needGithubMeta = extServiceType === 'GITHUB'; - const { data: githubRepo, isError: isRepoError } = useGetGithubRepository(teamId, { + const { data: githubRepo, isLoading: repoLoading } = useGetGithubRepository(teamId, { enabled: needGithubMeta, }); - const { data: githubInstall, isError: isInstallError } = useGetGithubInstallationId(teamId, { + const { data: githubInstall, isLoading: installLoading } = useGetGithubInstallationId(teamId, { enabled: needGithubMeta, }); @@ -118,22 +124,23 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) const canPatch = Number.isFinite(numericExternalId); // PATCH 가능 조건 + const repoObj = useMemo( + () => (Array.isArray(githubRepo) ? githubRepo[0] : githubRepo), + [githubRepo] + ); + // 깃허브 연동 외부이슈일 경우 POST 요청시 필요한 owner, repo, installationId 데이터 const githubPayload = useMemo(() => { - const owner = githubRepo?.owner?.login; - const repo = githubRepo?.name; + const owner = repoObj?.owner?.login; + const repo = repoObj?.name; const installationId = githubInstall?.installationId; return { owner, repo, installationId }; - }, [githubRepo, githubInstall]); - - // 설치가 안 되어 있거나 리다이렉트로 막히는 경우 → 온보딩으로 이동 - useEffect(() => { - if (!needGithubMeta) return; - if (isRepoError || isInstallError) { - // 서버가 API 호출에 대해 302로 온보딩을 주는 구조라면, 프론트는 네비게이션으로 처리 - window.location.href = 'https://web.vecoservice.shop/onboarding'; - } - }, [needGithubMeta, isRepoError, isInstallError]); + }, [repoObj, githubInstall]); + + const isGithubLoading = needGithubMeta && (repoLoading || installLoading); + const isGithubReady = + !needGithubMeta || + (!!githubPayload.owner && !!githubPayload.repo && !!githubPayload.installationId); const { data: linkedTools } = useGetExternalLinks(teamId); const linkedToolsList = linkedTools @@ -205,8 +212,20 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { console.log('Request body:', basePayload); if (mode === 'create') { - // GitHub 선택 시 필수값 검증 + // 1) GitHub 선택 시 필수값 검증 if (extServiceType === 'GITHUB') { + if (repoLoading || installLoading) { + isSubmittingRequestRef.current = false; + alert('GitHub 정보를 불러오는 중입니다. 잠시만요!'); + return; + } + // 2) 값이 준비되지 않았으면 중단 + if (!isGithubReady) { + isSubmittingRequestRef.current = false; + console.error('GitHub 연동 누락:', githubPayload); + alert('GitHub 연동 정보가 부족합니다. 설치/온보딩을 먼저 완료해 주세요.'); + return; + } const { owner, repo, installationId } = githubPayload; if (!owner || !repo || !installationId) { isSubmittingRequestRef.current = false; @@ -215,10 +234,6 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { repo, installationId, }); - // 안내 후 온보딩으로 - alert('GitHub 연동이 필요합니다. 온보딩 페이지로 이동합니다.'); - window.location.href = 'https://web.vecoservice.shop/onboarding'; - return; } } @@ -565,6 +580,16 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { onSelect={(label) => { const code = LABEL_TO_EXTERNAL_CODE[label]; setExtServiceType(code ?? null); + if (code === 'GITHUB') { + queryClient.prefetchQuery({ + queryKey: [queryKey.GITHUB_REPOSITORIES, teamId], + queryFn: () => getGithubRepository(teamId), + }); + queryClient.prefetchQuery({ + queryKey: [queryKey.GITHUB_INSTALLATION_ID, teamId], + queryFn: () => getGithubInstallationId(teamId), + }); + } }} /> @@ -575,7 +600,7 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { 0} isCompleted={isCompleted} - isSaving={isSaving} + isSaving={isSaving || isGithubLoading} onToggle={handleCompletion} /> From 447f7c3eebf9309aea0fa4d7c8f2c78b109ab9a9 Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Thu, 21 Aug 2025 14:00:05 +0900 Subject: [PATCH 9/9] =?UTF-8?q?#173=20[FEAT]=20=EC=9B=8C=ED=81=AC=EC=8A=A4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EA=B8=B0=EB=B3=B8=20=ED=8C=80?= =?UTF-8?q?=EC=9D=98=20=EC=99=B8=EB=B6=80=20=EC=83=81=EC=84=B8=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20API=20=EC=97=B0=EB=8F=99=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace/WorkspaceExternalDetail.tsx | 441 ++++++++++++++++-- 1 file changed, 392 insertions(+), 49 deletions(-) diff --git a/src/pages/workspace/WorkspaceExternalDetail.tsx b/src/pages/workspace/WorkspaceExternalDetail.tsx index 789ff3e4..8832e73d 100644 --- a/src/pages/workspace/WorkspaceExternalDetail.tsx +++ b/src/pages/workspace/WorkspaceExternalDetail.tsx @@ -1,7 +1,7 @@ // WorkspaceExternalDetail.tsx // 워크스페이스 전체 팀 - 외부 상세페이지 -import { useState, useRef } from 'react'; +import { useState, useRef, useMemo, startTransition } from 'react'; import WorkspaceDetailHeader from '../../components/DetailView/WorkspaceDetailHeader'; import PropertyItem from '../../components/DetailView/PropertyItem'; import DetailTitle from '../../components/DetailView/DetailTitle'; @@ -20,23 +20,51 @@ import IcGoal from '../../assets/icons/goal.svg'; import IcExt from '../../assets/icons/external.svg'; import { getStatusColor } from '../../utils/listItemUtils'; -import { statusLabelToCode } from '../../types/detailitem.ts'; +import { priorityLabelToCode, statusLabelToCode } from '../../types/detailitem.ts'; import CommentSection from '../../components/DetailView/Comment/CommentSection'; import CalendarDropdown from '../../components/Calendar/CalendarDropdown'; import { useDropdownActions, useDropdownInfo } from '../../hooks/useDropdown'; -import { formatDateDot } from '../../utils/formatDate'; +import { formatDateDot, formatDateHyphen } from '../../utils/formatDate'; import { useToggleMode } from '../../hooks/useToggleMode'; import { useParams } from 'react-router-dom'; -import { useGetExternalSimpleIssue } from '../../apis/external/useGetExternalSimplelssue.ts'; import { useGetExternalLinks } from '../../apis/external/useGetExternalLinks.ts'; import { usePostComment } from '../../apis/comment/usePostComment'; import CommentInput from '../../components/DetailView/Comment/CommentInput'; import MultiSelectPropertyItem from '../../components/DetailView/MultiSelectPropertyItem.tsx'; -import type { SubmitHandleRef } from '../../components/DetailView/TextEditor/lexical-plugins/SubmitHandlePlugin.tsx'; -import { useCreateGoal } from '../../apis/goal/usePostCreateGoalDetail.ts'; +import { + EMPTY_EDITOR_STATE, + type SubmitHandleRef, +} from '../../components/DetailView/TextEditor/lexical-plugins/SubmitHandlePlugin.tsx'; import { useIsMutating } from '@tanstack/react-query'; import { mutationKey } from '../../constants/mutationKey.ts'; +import { + EXTERNAL_LABELS, + LABEL_TO_EXTERNAL_CODE, + PRIORITY_LABELS, + STATUS_LABELS, + type ExternalCode, + type PriorityCode, + type StatusCode, +} from '../../types/listItem.ts'; +import { useGetWorkspaceMembers } from '../../apis/setting/useGetWorkspaceMembers.ts'; +import { useGetSimpleGoalList } from '../../apis/goal/useGetSimpleGoalList.ts.ts'; +import { useCreateExternal } from '../../apis/external/usePostCreateExternalDetail.ts'; +import { useGetExternalDetail } from '../../apis/external/useGetExternalDetail.ts'; +import { useUpdateExternal } from '../../apis/external/usePatchExternalDetail.ts'; +import { + getGithubRepository, + useGetGithubRepository, +} from '../../apis/external/useGetGithubRepository.ts'; +import { + getGithubInstallationId, + useGetGithubInstallationId, +} from '../../apis/external/useGetGithubInstallationId.ts'; +import { useExternalDeadlinePatch } from '../../hooks/useExternalDeadlinePatch.ts'; +import type { CreateExternalDetailDto, UpdateExternalDetailDto } from '../../types/external.ts'; +import queryClient from '../../utils/queryClient.ts'; +import { queryKey } from '../../constants/queryKey.ts'; +import { useHydrateExternalDetail } from '../../hooks/useHydrateExternalDetail.ts'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -52,47 +80,228 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) const [selectedDate, setSelectedDate] = useState<[Date | null, Date | null]>([null, null]); // '기한' 속성의 달력 드롭다운: 시작일, 종료일 2개를 저장 const [title, setTitle] = useState(''); + const [state, setState] = useState('NONE'); + const [priority, setPriority] = useState('NONE'); + const [managersId, setManagersId] = useState([]); + const [extServiceType, setExtServiceType] = useState(null); + + const [goalId, setGoalId] = useState(null); // null 허용 const editorSubmitRef = useRef(null); // 텍스트에디터 컨텐츠 접근용 플래그 const isSubmittingRequestRef = useRef(false); // API 제출 중복 요청 가드 플래그 const teamId = Number(useParams<{ teamId: string }>().teamId); - /** - * @todo: 나중에 useCreateExt로 제대로 연결 - */ - const { isPending } = useCreateGoal(teamId); + + // extId를 useParams로부터 가져옴 + const { extId: extIdParam } = useParams<{ extId: string }>(); + const numericExternalId = Number(extIdParam); + + const { data: workspaceMembers } = useGetWorkspaceMembers(); + const { data: simpleGoals } = useGetSimpleGoalList(teamId); // 팀 목표 간단 조회 (select로 info만 나오도록 되어 있음) + const { mutate: submitExternal, isPending: isCreating } = useCreateExternal(teamId); + const { data: externalDetail } = useGetExternalDetail(teamId, numericExternalId, { + enabled: true, + }); + const { mutate: updateExternal, isPending: isUpdating } = useUpdateExternal( + teamId, + numericExternalId + ); + const needGithubMeta = extServiceType === 'GITHUB'; + const { data: githubRepo, isLoading: repoLoading } = useGetGithubRepository(teamId, { + enabled: needGithubMeta, + }); + const { data: githubInstall, isLoading: installLoading } = useGetGithubInstallationId(teamId, { + enabled: needGithubMeta, + }); + const isCreatingGlobal = useIsMutating({ mutationKey: [mutationKey.EXTERNAL_CREATE, teamId] }) > 0; - const isSaving = isPending || isCreatingGlobal || isSubmittingRequestRef.current; + const isSaving = isCreating || isUpdating || isCreatingGlobal || isSubmittingRequestRef.current; - const { isOpen, content } = useDropdownInfo(); // 현재 드롭다운의 열림 여부와 내용 가져옴 - const { openDropdown } = useDropdownActions(); + const { isOpen, content } = useDropdownInfo(); // 작성 완료 여부 (view 모드일 때 true) + const { openDropdown } = useDropdownActions(); // 수정 가능 여부 (create 또는 edit 모드일 때 true) const isCompleted = mode === 'view'; // 작성 완료 여부 (view 모드일 때 true) const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) + const canPatch = Number.isFinite(numericExternalId); // PATCH 가능 조건 + + const repoObj = useMemo( + () => (Array.isArray(githubRepo) ? githubRepo[0] : githubRepo), + [githubRepo] + ); - const { data: externalIssues } = useGetExternalSimpleIssue(teamId); - const issues = externalIssues?.info.map((issue) => issue.title) || []; + // 깃허브 연동 외부이슈일 경우 POST 요청시 필요한 owner, repo, installationId 데이터 + const githubPayload = useMemo(() => { + const owner = repoObj?.owner?.login; + const repo = repoObj?.name; + const installationId = githubInstall?.installationId; + return { owner, repo, installationId }; + }, [repoObj, githubInstall]); + + const isGithubLoading = needGithubMeta && (repoLoading || installLoading); + const isGithubReady = + !needGithubMeta || + (!!githubPayload.owner && !!githubPayload.repo && !!githubPayload.installationId); const { data: linkedTools } = useGetExternalLinks(teamId); const linkedToolsList = linkedTools - ? Object.entries(linkedTools) - .filter(([, value]) => value) - .map(([key]) => - key === 'linkedWithGithub' ? 'Github' : key === 'linkedWithSlack' ? 'Slack' : key - ) + ? [ + ...(linkedTools.linkedWithGithub ? [EXTERNAL_LABELS.GITHUB] : []), + ...(linkedTools.linkedWithSlack ? [EXTERNAL_LABELS.SLACK] : []), + ] : []; - // extId를 useParams로부터 가져옴 - const { extId } = useParams<{ extId: string }>(); + // 단일 선택 라벨 + const selectedStatusLabel = STATUS_LABELS[state]; + const selectedPriorityLabel = PRIORITY_LABELS[priority]; + const selectedExternalLabel = extServiceType ? EXTERNAL_LABELS[extServiceType] : '외부'; + + const selectedGoalLabel = useMemo(() => { + const match = (simpleGoals ?? []).find((g) => g.id === goalId); + return match?.title ?? '목표'; // 데이터 없거나 매칭 실패 시 기본 라벨 + }, [simpleGoals, goalId]); + + // 다중 선택 라벨 + const selectedManagerLabels = useMemo(() => { + if (!workspaceMembers) return []; + const idToName = new Map(workspaceMembers.map((m) => [m.memberId, m.name] as const)); + return managersId.map((id) => idToName.get(id)).filter((v): v is string => !!v); + }, [managersId, workspaceMembers]); + const [managersShowNoneLabel] = useState(false); + + // deadline('기한' 속성) patch 훅 + const { handleSelectDateAndPatch, buildPatchForEditSubmit } = useExternalDeadlinePatch({ + externalDetail, + isViewMode: isCompleted, + canPatch, + mutateUpdate: updateExternal, + }); const handleToggleMode = useToggleMode({ mode, setMode, type: 'ext', - id: Number(extId), + id: Number(extIdParam), isDefaultTeam: true, }); + // handleSubmit: Lexical 에디터 내용을 JSON 문자열로 직렬화 후 API로 전송하는 함수 + const handleSubmit = () => { + if (editorSubmitRef.current) { + // ref를 통해 직렬화된 에디터 내용 가져오기 + const serialized = editorSubmitRef.current?.getJson() ?? ''; + const byteLength = new TextEncoder().encode(serialized).length; + console.log('Serialized JSON byte length:', byteLength); + } + + if (isSaving) return; + isSubmittingRequestRef.current = true; + + const [start, end] = selectedDate; + + // 화면 상태를 공통 페이로드로 구성 + const basePayload = { + title, + content: editorSubmitRef.current?.getJson() ?? EMPTY_EDITOR_STATE, + state, + priority, + managersId, + ...(goalId !== null && goalId !== undefined && goalId !== -1 ? { goalId } : {}), + ...(extServiceType ? { extServiceType } : {}), + }; + + console.log('Request body:', basePayload); + + if (mode === 'create') { + // 1) GitHub 선택 시 필수값 검증 + if (extServiceType === 'GITHUB') { + if (repoLoading || installLoading) { + isSubmittingRequestRef.current = false; + alert('GitHub 정보를 불러오는 중입니다. 잠시만요!'); + return; + } + // 2) 값이 준비되지 않았으면 중단 + if (!isGithubReady) { + isSubmittingRequestRef.current = false; + console.error('GitHub 연동 누락:', githubPayload); + alert('GitHub 연동 정보가 부족합니다. 설치/온보딩을 먼저 완료해 주세요.'); + return; + } + const { owner, repo, installationId } = githubPayload; + if (!owner || !repo || !installationId) { + isSubmittingRequestRef.current = false; + console.error('GitHub 연동 누락: owner/repo/installationId 필요', { + owner, + repo, + installationId, + }); + } + } + + // 생성 시에는 기존 로직 유지 (규칙 제약 없음) + const payload: CreateExternalDetailDto = { + ...basePayload, + // GitHub일 때만 추가 + ...(extServiceType === 'GITHUB' + ? { + owner: githubPayload.owner!, + repo: githubPayload.repo!, + installationId: githubPayload.installationId!, + } + : {}), + deadline: { + ...(start ? { start: formatDateHyphen(start) } : {}), + ...(end ? { end: formatDateHyphen(end) } : {}), + }, + }; + + submitExternal(payload, { + onSuccess: ({ externalId }) => { + queryClient.invalidateQueries({ queryKey: [queryKey.EXTERNAL_LIST, String(teamId)] }); + queryClient.invalidateQueries({ queryKey: [queryKey.EXTERNAL_NAME, String(teamId)] }); + startTransition(() => handleToggleMode(externalId)); + }, + onSettled: () => { + isSubmittingRequestRef.current = false; + }, + }); + } else if (mode === 'edit') { + const patch = buildPatchForEditSubmit(selectedDate); + const { extServiceType: _omit, ...rest } = basePayload; + const payload = { ...rest, ...(patch ?? {}) } as UpdateExternalDetailDto; + + // 수정 시 goalId가 없으면 생략된 상태로 보냄 + if (goalId === null || goalId === undefined || goalId === -1) { + delete (payload as any).goalId; // goalId가 null, undefined, -1이면 삭제 + } + + updateExternal(payload, { + onSuccess: () => { + if (Number.isFinite(numericExternalId)) { + queryClient.invalidateQueries({ queryKey: [queryKey.EXTERNAL_LIST, String(teamId)] }); + queryClient.invalidateQueries({ queryKey: [queryKey.EXTERNAL_NAME, String(teamId)] }); + queryClient.invalidateQueries({ + queryKey: [queryKey.EXTERNAL_DETAIL, numericExternalId], + }); + } + startTransition(() => handleToggleMode()); + }, + onSettled: () => (isSubmittingRequestRef.current = false), + }); + } + }; + + // handleCompletion - 하단 작성 완료<-수정하기 버튼 클릭 시 실행 + // - create/edit → view: API 저장 후 모드 전환 + // - view → edit: API 호출 없이 모드 전환 + const handleCompletion = () => { + if (!isCompleted) { + // create 또는 edit 모드에서 view 모드로 전환하려는 시점 + handleSubmit(); // 저장 성공 시 모드 전환 + } else { + handleToggleMode(); // 모드 전환 + } + }; + // '기한' 속성의 텍스트(시작일, 종료일) 결정하는 함수 const getDisplayText = () => { const [start, end] = selectedDate; @@ -111,32 +320,74 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) 긴급: pr4, }; - // '담당자' 속성 아이콘 매핑 (나중에 API로부터 받아온 데이터로 대체 예정) - const managerIconMap = { - 담당자: IcProfile, - 없음: IcProfile, - 전채운: IcProfile, - 염주원: IcProfile, - 박유민: IcProfile, - 이가을: IcProfile, - 김선화: IcProfile, - 박진주: IcProfile, - }; + const goalOptions = useMemo( + () => ['없음', ...(simpleGoals ?? []).map((g) => g.title)], + [simpleGoals] + ); - const goalIconMap = { - 목표: IcGoal, - 없음: IcGoal, - '백호를 사용해서 다른 사람들과 협업해보기': IcGoal, - '기획 및 요구사항 분석': IcGoal, - }; + const goalIconMap = new Proxy( + {}, + { + get: () => IcGoal, + } + ) as Record; + + // 해당 teamId에 속한 멤버만 필터 + const teamMembers = useMemo( + () => (workspaceMembers ?? []).filter((m) => m.teams?.some((t) => t.teamId === teamId)), + [workspaceMembers, teamId] + ); + + // '담당자' 항목의 옵션: ['없음', ...팀 멤버 이름들] + const managerOptions = useMemo(() => ['없음', ...teamMembers.map((m) => m.name)], [teamMembers]); + + // 멤버 이름 → 멤버 id 매핑 (선택 결과를 id 배열로 변환용) + const nameToId = useMemo( + () => Object.fromEntries(teamMembers.map((m) => [m.name, m.memberId] as const)), + [teamMembers] + ); + // '담당자' 아이콘 매핑: 이름 → 프로필 URL(없으면 기본 아이콘), '담당자'/'없음' 기본 아이콘 포함 + const managerIconMap = useMemo>(() => { + const base: Record = { + 담당자: IcProfile, + 없음: IcProfile, + }; + for (const m of teamMembers) { + base[m.name] = m.profileImageUrl || IcProfile; + } + return base; + }, [teamMembers]); + + // title -> id 역매핑 + const goalTitleToId = useMemo(() => { + const info = simpleGoals ?? []; + return new Map(info.map((g) => [g.title, g.id] as const)); + }, [simpleGoals]); + + // 외부 툴 아이콘 매핑 const externalIconMap = { 외부: IcExt, Slack: IcExt, - Notion: IcExt, - Github: IcExt, + GitHub: IcExt, }; + useHydrateExternalDetail({ + externalDetail, + externalId: numericExternalId, + editorRef: editorSubmitRef, + workspaceMembers, + simpleGoals, // 단일 목표 라벨/매핑용 간단 목록 + nameToId, // 멤버 이름 -> id 매핑 + setTitle, + setState, + setPriority, + setSelectedDate, + setManagersId, + setGoalId, + setExtServiceType, + }); + const bottomRef = useRef(null); const shouldScrollRef = useRef(false); const { mutate: addComment } = usePostComment({ bottomRef, shouldScrollRef, useDoubleRaf: true }); @@ -154,12 +405,18 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) {/* 상세페이지 메인 */}
{/* 상세페이지 좌측 영역 - 제목 & 상세설명 & 댓글 */} -
+
{/* 상세페이지 제목 */} { + setTitle(v); + // view 모드에서 즉시 PATCH + if (isCompleted && Number.isFinite(numericExternalId)) { + updateExternal({ title: v }); + } + }} isEditable={isEditable} /> @@ -193,6 +450,14 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) const code = statusLabelToCode[label] ?? 'NONE'; return getStatusColor(code); }} + onSelect={(label) => { + const next = statusLabelToCode[label] ?? 'NONE'; + setState(next); + if (isCompleted && Number.isFinite(numericExternalId)) { + updateExternal({ state: next }); + } + }} + selected={selectedStatusLabel} />
@@ -202,6 +467,14 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) defaultValue="우선순위" options={['없음', '긴급', '높음', '중간', '낮음']} iconMap={priorityIconMap} + onSelect={(label) => { + const next = priorityLabelToCode[label] ?? 'NONE'; + setPriority(next); + if (isCompleted && Number.isFinite(numericExternalId)) { + updateExternal({ priority: next }); + } + }} + selected={selectedPriorityLabel} />
@@ -209,8 +482,37 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps)
e.stopPropagation()}> { + // 1) '없음'만 선택된 경우만 비우기 + if (labels.length === 1 && labels[0] === '없음') { + setManagersId([]); + if (isCompleted && Number.isFinite(numericExternalId)) { + updateExternal({ managersId: [] }); + } + return; + } + + // 2) '없음'이 다른 값과 섞여 오면 제거 + const cleaned = labels.filter((l) => l !== '없음'); + + const ids = cleaned + .map((label) => nameToId[label]) + .filter((v): v is number => typeof v === 'number'); + + setManagersId(ids); + if (isCompleted && Number.isFinite(numericExternalId)) { + updateExternal({ managersId: ids }); + } + }} + selected={ + managersId.length === 0 + ? managersShowNoneLabel + ? ['없음'] + : [] // 비어있지만 '없음'을 선택했으면 '없음'을 내려줌 + : selectedManagerLabels + } />
@@ -231,7 +533,10 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) {isOpen && content?.name === 'date' && ( setSelectedDate(date)} + onSelect={(date) => { + setSelectedDate(date); + handleSelectDateAndPatch(date); // view 모드 시 즉시 PATCH + }} /> )}
@@ -239,7 +544,30 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) {/* (5) 목표 */}
e.stopPropagation()}> - + { + // '없음' 대응 (백엔드가 null 허용 전이라면 0으로) + if (label === '없음') { + setGoalId(null); + if (isCompleted && Number.isFinite(numericExternalId)) { + } + return; + } + + // title -> id 매핑 + const id = goalTitleToId.get(label); + if (typeof id === 'number') { + setGoalId(id); + if (isCompleted && Number.isFinite(numericExternalId)) { + updateExternal({ goalId: id }); + } + } + }} + />
{/* (6) 외부 */} @@ -248,6 +576,21 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) defaultValue="외부" options={linkedToolsList} iconMap={externalIconMap} + selected={selectedExternalLabel} + onSelect={(label) => { + const code = LABEL_TO_EXTERNAL_CODE[label]; + setExtServiceType(code ?? null); + if (code === 'GITHUB') { + queryClient.prefetchQuery({ + queryKey: [queryKey.GITHUB_REPOSITORIES, teamId], + queryFn: () => getGithubRepository(teamId), + }); + queryClient.prefetchQuery({ + queryKey: [queryKey.GITHUB_INSTALLATION_ID, teamId], + queryFn: () => getGithubInstallationId(teamId), + }); + } + }} /> @@ -257,8 +600,8 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) 0} isCompleted={isCompleted} - isSaving={isSaving} - onToggle={handleToggleMode} + isSaving={isSaving || isGithubLoading} + onToggle={handleCompletion} />