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/apis/external/useGetGithubInstallationId.ts b/src/apis/external/useGetGithubInstallationId.ts index c38785de..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 { @@ -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..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` @@ -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/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/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] }); + }, + }); +}; 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..dee708df 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,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'; 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'; +import { + getGithubRepository, + useGetGithubRepository, +} from '../../apis/external/useGetGithubRepository.ts'; +import { + getGithubInstallationId, + useGetGithubInstallationId, +} from '../../apis/external/useGetGithubInstallationId.ts'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -52,47 +80,228 @@ 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 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: 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') { + // 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 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 = 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 }); @@ -159,7 +410,13 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { { + setTitle(v); + // view 모드에서 즉시 PATCH + if (isCompleted && Number.isFinite(numericExternalId)) { + updateExternal({ title: v }); + } + }} isEditable={isEditable} /> @@ -193,6 +450,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 +467,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 +482,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 +533,10 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { {isOpen && content?.name === 'date' && ( setSelectedDate(date)} + onSelect={(date) => { + setSelectedDate(date); + handleSelectDateAndPatch(date); // view 모드 시 즉시 PATCH + }} /> )} @@ -239,7 +544,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 +576,21 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { 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 ExternalDetail = ({ initialMode }: ExternalDetailProps) => { 0} isCompleted={isCompleted} - isSaving={isSaving} - onToggle={handleToggleMode} + isSaving={isSaving || isGithubLoading} + onToggle={handleCompletion} /> 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} /> diff --git a/src/types/external.ts b/src/types/external.ts index 30a20072..b56993a7 100644 --- a/src/types/external.ts +++ b/src/types/external.ts @@ -1,8 +1,10 @@ -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; - end: string; + start?: string; + end?: string; }; export type ManagerInfo = { @@ -41,3 +43,62 @@ 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; +}; + +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; + goalId?: Pick; // TODO: 데이터 구조 통일해달라고 하기 + goalTitle?: Pick; // TODO: 데이터 구조 통일해달라고 하기 + extServiceType?: string; + managers: Manager; + deadline: Deadline; + comments: ResponseCommentListDto; +}; + +export type ResponseViewExternalDetailDto = 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 = { + externalId: number; + updatedAt: string; // LocalDateTime +}; + +export type ResponseUpdateExternalDetailDto = CommonResponse; 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;