From b293406ff53af4792c3f83303a955611b15f65ca Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Fri, 15 Aug 2025 17:54:01 +0900 Subject: [PATCH 1/9] =?UTF-8?q?#172=20[FEAT]=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20api=20=ED=83=80=EC=9E=85=20=EA=B5=AC?= =?UTF-8?q?=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/issue.ts | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/src/types/issue.ts b/src/types/issue.ts index 1d558b5f..af7b50e3 100644 --- a/src/types/issue.ts +++ b/src/types/issue.ts @@ -1,10 +1,10 @@ -import type { CursorBasedResponse } from './common'; +import type { CommonResponse, CursorBasedResponse } from './common'; /* 임시 구조, api 명세서 완성 시 변경 필요하면 수정 예정 */ export type Deadline = { - start: string; - end: string; + start?: string; + end?: string; }; export type Goal = { @@ -54,3 +54,34 @@ export type SimpleIssueListDto = { cnt: number; info: SimpleIssue[]; }; + +// 이슈 작성 +export type CreateIssueDetailDto = { + title: string; + content: string; + state: string; + priority: string; + managersId: number[]; + deadline?: Deadline; + goalId: number; // 이거 필수 요소인지 확인 +}; + +export type CreateIssueResultDto = { + issueId: number; + createdAt: string; +}; + +export type ResponseCreateIssueDetailDto = CommonResponse; + +// 이슈 상세 조회 +export type ViewIssueDetailDto = { + id: number; + name: string; + title: string; + content: string; + state: string; + priority: string; + deadline: Deadline; +}; + +// 이슈 수정 From b559a8a62da7e4f78f11dd85ea2b7d63b8c0a688 Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Fri, 15 Aug 2025 18:46:16 +0900 Subject: [PATCH 2/9] =?UTF-8?q?#172=20[FEAT]=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EC=9D=98=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/issue.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/types/issue.ts b/src/types/issue.ts index af7b50e3..e9eaea8e 100644 --- a/src/types/issue.ts +++ b/src/types/issue.ts @@ -1,4 +1,6 @@ +import type { ResponseCommentListDto } from './comment'; import type { CommonResponse, CursorBasedResponse } from './common'; +import type { SimpleGoal } from './goal'; /* 임시 구조, api 명세서 완성 시 변경 필요하면 수정 예정 */ @@ -63,7 +65,7 @@ export type CreateIssueDetailDto = { priority: string; managersId: number[]; deadline?: Deadline; - goalId: number; // 이거 필수 요소인지 확인 + goalId: number; // 이거 필수 요소인지 서버에 확인 }; export type CreateIssueResultDto = { @@ -82,6 +84,28 @@ export type ViewIssueDetailDto = { state: string; priority: string; deadline: Deadline; + goal: SimpleGoal; + managers: Manager; + comments: ResponseCommentListDto; }; +export type ResponseViewIssueDetailDto = CommonResponse; + // 이슈 수정 +export type UpdateIssueDetailDto = { + // 변경사항이 없는 속성은 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 UpdateIssueResultDto = { + issueId: number; + updatedAt: string; // LocalDateTime +}; + +export type ResponseUpdateIssueDetailDto = CommonResponse; From 169258d401fa4c1608b99be2b675dbf100ef89be Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Fri, 15 Aug 2025 18:54:28 +0900 Subject: [PATCH 3/9] =?UTF-8?q?#172=20[FEAT]=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=ED=95=A8=EC=88=98=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A3=BC=EC=84=9D=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/goal/useGetGoalDetail.ts | 2 +- src/apis/goal/usePatchGoalDetail.ts | 2 +- src/apis/goal/usePostCreateGoalDetail.ts | 2 +- src/apis/issue/usePostCreateIssueDetail.ts | 48 ++++++++++++++++++++++ src/types/issue.ts | 2 +- 5 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 src/apis/issue/usePostCreateIssueDetail.ts diff --git a/src/apis/goal/useGetGoalDetail.ts b/src/apis/goal/useGetGoalDetail.ts index 810890d0..f383d629 100644 --- a/src/apis/goal/useGetGoalDetail.ts +++ b/src/apis/goal/useGetGoalDetail.ts @@ -7,7 +7,7 @@ import type { ResponseViewGoalDetailDto, ViewGoalDetailDto } from '../../types/g * 목표 상세 조회 함수 * - 목표 상세페이지 조회 모드에서 사용 * - pages/goal/GoalDetail.tsx - * - pages/goal/WorkspaceGoalDetail.tsx + * - pages/workspace/WorkspaceGoalDetail.tsx */ const getGoalDetail = async (goalId: number): Promise => { try { diff --git a/src/apis/goal/usePatchGoalDetail.ts b/src/apis/goal/usePatchGoalDetail.ts index f064a1a4..f1694444 100644 --- a/src/apis/goal/usePatchGoalDetail.ts +++ b/src/apis/goal/usePatchGoalDetail.ts @@ -14,7 +14,7 @@ import queryClient from '../../utils/queryClient.ts'; * 목표 수정 (PATCH) 함수 * - 동일 teamId / 동일 goalId 대상의 상세 내용 반영 * - pages/goal/GoalDetail.tsx - * - pages/goal/WorkspaceGoalDetail.tsx + * - pages/workspace/WorkspaceGoalDetail.tsx */ const updateGoal = async ( teamId: number, diff --git a/src/apis/goal/usePostCreateGoalDetail.ts b/src/apis/goal/usePostCreateGoalDetail.ts index 9fbf4d06..df5a65aa 100644 --- a/src/apis/goal/usePostCreateGoalDetail.ts +++ b/src/apis/goal/usePostCreateGoalDetail.ts @@ -13,7 +13,7 @@ import queryClient from '../../utils/queryClient.ts'; * 목표 작성 함수 * - 목표 상세페이지 생성 모드에서 사용 * - pages/goal/GoalDetail.tsx - * - pages/goal/WorkspaceGoalDetail.tsx + * - pages/workspace/WorkspaceGoalDetail.tsx */ const createGoal = async ( teamId: number, diff --git a/src/apis/issue/usePostCreateIssueDetail.ts b/src/apis/issue/usePostCreateIssueDetail.ts new file mode 100644 index 00000000..fee7813f --- /dev/null +++ b/src/apis/issue/usePostCreateIssueDetail.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 { + CreateIssueDetailDto, + CreateIssueResultDto, + ResponseCreateIssueDetailDto, +} from '../../types/issue.ts'; + +/** + * 이슈 작성 함수 + * - 이슈 상세페이지 생성 모드에서 사용 + * - pages/issue/IssueDetail.tsx + * - pages/goal/WorkspaceIssueDetail.tsx + */ +const createIssue = async ( + teamId: number, + payload: CreateIssueDetailDto +): Promise => { + try { + const response = await axiosInstance.post( + `/api/teams/${teamId}/issues`, + 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 useCreateIssue = (teamId: number) => { + return useMutation({ + mutationKey: [mutationKey.ISSUE_CREATE, teamId], + mutationFn: (payload) => createIssue(teamId, payload), + onSuccess: () => { + // 이슈 작성하여 POST 후 조회되는 데이터 최신화 + queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_LIST, teamId] }); + queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_NAME, teamId] }); + }, + }); +}; diff --git a/src/types/issue.ts b/src/types/issue.ts index e9eaea8e..0660d9df 100644 --- a/src/types/issue.ts +++ b/src/types/issue.ts @@ -65,7 +65,7 @@ export type CreateIssueDetailDto = { priority: string; managersId: number[]; deadline?: Deadline; - goalId: number; // 이거 필수 요소인지 서버에 확인 + goalId: number; }; export type CreateIssueResultDto = { From 775345f9389961346ea486555851aaf2b0cd2244 Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Fri, 15 Aug 2025 19:02:03 +0900 Subject: [PATCH 4/9] =?UTF-8?q?#172=20[FEAT]=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20API=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/issue/useGetIssueDetail.ts | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/apis/issue/useGetIssueDetail.ts diff --git a/src/apis/issue/useGetIssueDetail.ts b/src/apis/issue/useGetIssueDetail.ts new file mode 100644 index 00000000..318709c0 --- /dev/null +++ b/src/apis/issue/useGetIssueDetail.ts @@ -0,0 +1,33 @@ +import { axiosInstance } from '../axios.ts'; +import { useQuery } from '@tanstack/react-query'; +import { queryKey } from '../../constants/queryKey.ts'; +import type { ResponseViewIssueDetailDto, ViewIssueDetailDto } from '../../types/issue.ts'; + +/** + * 이슈 상세 조회 함수 + * - 이슈 상세페이지 조회 모드에서 사용 + * - pages/goal/GoalDetail.tsx + * - pages/workspace/WorkspaceGoalDetail.tsx + */ +const getIssueDetail = async (issueId: number): Promise => { + try { + const response = await axiosInstance.get(`/api/issues/${issueId}`); + + if (!response.data.result) return Promise.reject(response); + if (response.data?.isSuccess) { + console.log('조회 성공:', response.data.result); + } + return response.data.result; + } catch (error) { + console.error('이슈 상세 조회 실패', error); + throw error; + } +}; + +export const useGetIssueDetail = (issueId: number) => { + return useQuery({ + queryKey: [queryKey.ISSUE_DETAIL, issueId], + queryFn: () => getIssueDetail(issueId), + enabled: Number.isFinite(issueId), + }); +}; From c57316750ff2f21d564c279720c817c710efe884 Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Fri, 15 Aug 2025 19:07:48 +0900 Subject: [PATCH 5/9] =?UTF-8?q?#172=20[FEAT]=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/issue/useGetIssueDetail.ts | 4 +- src/apis/issue/usePatchIssueDetail.ts | 49 ++++++++++++++++++++++ src/apis/issue/usePostCreateIssueDetail.ts | 2 +- 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 src/apis/issue/usePatchIssueDetail.ts diff --git a/src/apis/issue/useGetIssueDetail.ts b/src/apis/issue/useGetIssueDetail.ts index 318709c0..8abdfab4 100644 --- a/src/apis/issue/useGetIssueDetail.ts +++ b/src/apis/issue/useGetIssueDetail.ts @@ -6,8 +6,8 @@ import type { ResponseViewIssueDetailDto, ViewIssueDetailDto } from '../../types /** * 이슈 상세 조회 함수 * - 이슈 상세페이지 조회 모드에서 사용 - * - pages/goal/GoalDetail.tsx - * - pages/workspace/WorkspaceGoalDetail.tsx + * - pages/issue/IssueDetail.tsx + * - pages/workspace/WorkspaceIssueDetail.tsx */ const getIssueDetail = async (issueId: number): Promise => { try { diff --git a/src/apis/issue/usePatchIssueDetail.ts b/src/apis/issue/usePatchIssueDetail.ts new file mode 100644 index 00000000..aeb75491 --- /dev/null +++ b/src/apis/issue/usePatchIssueDetail.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 { + ResponseUpdateIssueDetailDto, + UpdateIssueDetailDto, + UpdateIssueResultDto, +} from '../../types/issue.ts'; + +/** + * 이슈 수정 (PATCH) 함수 + * - 동일 teamId / 동일 issueId 대상의 상세 내용 반영 + * - pages/issue/IssueDetail.tsx + * - pages/workspace/WorkspaceIssueDetail.tsx + */ +const updateIssue = async ( + teamId: number, + issueId: number, + payload: UpdateIssueDetailDto +): Promise => { + try { + const response = await axiosInstance.patch( + `/api/teams/${teamId}/issues/${issueId}`, + 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 useUpdateIssue = (teamId: number, issueId: number) => { + return useMutation({ + mutationKey: [mutationKey.ISSUE_UPDATE, teamId, issueId], + mutationFn: (payload) => updateIssue(teamId, issueId, payload), + onSuccess: () => { + // 상세/목록/관련 파생 쿼리 최신화 + queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_LIST, teamId] }); + queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_NAME, teamId] }); + }, + }); +}; diff --git a/src/apis/issue/usePostCreateIssueDetail.ts b/src/apis/issue/usePostCreateIssueDetail.ts index fee7813f..4d9794cc 100644 --- a/src/apis/issue/usePostCreateIssueDetail.ts +++ b/src/apis/issue/usePostCreateIssueDetail.ts @@ -13,7 +13,7 @@ import type { * 이슈 작성 함수 * - 이슈 상세페이지 생성 모드에서 사용 * - pages/issue/IssueDetail.tsx - * - pages/goal/WorkspaceIssueDetail.tsx + * - pages/workspace/WorkspaceIssueDetail.tsx */ const createIssue = async ( teamId: number, From 567f55abe6da360585a1ee4a7096fd68686a7009 Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Fri, 15 Aug 2025 19:37:36 +0900 Subject: [PATCH 6/9] =?UTF-8?q?#172=20[FEAT]=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=ED=8E=98=EC=9D=B4=EC=A7=80=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EC=A4=91=EA=B0=84=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/goal/GoalDetail.tsx | 6 ++-- src/pages/issue/IssueDetail.tsx | 35 ++++++++++++++++----- src/pages/workspace/WorkspaceGoalDetail.tsx | 14 +++++---- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/src/pages/goal/GoalDetail.tsx b/src/pages/goal/GoalDetail.tsx index 338582f9..3fa98c9a 100644 --- a/src/pages/goal/GoalDetail.tsx +++ b/src/pages/goal/GoalDetail.tsx @@ -32,7 +32,7 @@ import { import type { CreateGoalDetailDto, UpdateGoalDetailDto } from '../../types/goal'; import { useCreateGoal } from '../../apis/goal/usePostCreateGoalDetail'; import { useParams } from 'react-router-dom'; -import { useIsMutating, useQueryClient } from '@tanstack/react-query'; +import { useIsMutating } from '@tanstack/react-query'; import { mutationKey } from '../../constants/mutationKey'; import MultiSelectPropertyItem from '../../components/DetailView/MultiSelectPropertyItem'; import { statusLabelToCode, priorityLabelToCode } from '../../types/detailitem'; @@ -50,6 +50,7 @@ import { useGetGoalDetail } from '../../apis/goal/useGetGoalDetail'; import { useUpdateGoal } from '../../apis/goal/usePatchGoalDetail'; import { useGoalDeadlinePatch } from '../../hooks/useGoalDeadlinePatch'; import { queryKey } from '../../constants/queryKey'; +import queryClient from '../../utils/queryClient'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -94,9 +95,6 @@ const GoalDetail = ({ initialMode }: GoalDetailProps) => { const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) const canPatch = Number.isFinite(numericGoalId); // PATCH 가능 조건 - // 상세 조회 훅: goalId가 있을 때만 자동 실행됨 - const queryClient = useQueryClient(); - // 단일 선택 라벨 const selectedStatusLabel = STATUS_LABELS[state]; const selectedPriorityLabel = PRIORITY_LABELS[priority]; diff --git a/src/pages/issue/IssueDetail.tsx b/src/pages/issue/IssueDetail.tsx index 4a36b67e..4986d997 100644 --- a/src/pages/issue/IssueDetail.tsx +++ b/src/pages/issue/IssueDetail.tsx @@ -34,6 +34,12 @@ import { useCreateGoal } from '../../apis/goal/usePostCreateGoalDetail'; import { useIsMutating } from '@tanstack/react-query'; import { mutationKey } from '../../constants/mutationKey'; import { useParams } from 'react-router-dom'; +import type { PriorityCode, StatusCode } from '../../types/listItem'; +import { useGetWorkspaceMembers } from '../../apis/setting/useGetWorkspaceMembers'; +import { useGetSimpleGoalList } from '../../apis/goal/useGetSimpleGoalList.ts'; +import { useCreateIssue } from '../../apis/issue/usePostCreateIssueDetail.ts'; +import { useGetIssueDetail } from '../../apis/issue/useGetIssueDetail.ts'; +import { useUpdateIssue } from '../../apis/issue/usePatchIssueDetail.ts'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -49,25 +55,40 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { 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 [goalId, setGoalId] = useState(0); + // 없을 수 있는 값이면: null 허용 + // const [goalId, setGoalId] = useState(null); const editorSubmitRef = useRef(null); // 텍스트에디터 컨텐츠 접근용 플래그 const isSubmittingRequestRef = useRef(false); // API 제출 중복 요청 가드 플래그 const teamId = Number(useParams<{ teamId: string }>().teamId); - /** - * @todo: 나중에 useCreateIssue로 제대로 연결 - */ - const { isPending } = useCreateGoal(teamId); + + // issueId를 useParams로부터 가져옴 + const { issueId: issueIdParam } = useParams<{ issueId: string }>(); + const numericIssueId = Number(issueIdParam); + + const { data: workspaceMembers } = useGetWorkspaceMembers(); + const { data: simpleGoals } = useGetSimpleGoalList(teamId); // 팀 목표 간단 조회 (select로 info만 나오도록 되어 있음) + const { mutate: submitIssue, isPending: isCreating } = useCreateIssue(teamId); + const { data: issueDetail } = useGetIssueDetail(numericIssueId); + const { mutate: updateIssue, isPending: isUpdating } = useUpdateIssue(teamId, numericIssueId); + const isCreatingGlobal = useIsMutating({ mutationKey: [mutationKey.ISSUE_CREATE, teamId] }) > 0; - const isSaving = isPending || isCreatingGlobal || isSubmittingRequestRef.current; + const isSaving = isCreating || isCreatingGlobal || isSubmittingRequestRef.current; 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(numericIssueId); // PATCH 가능 조건 - // issueId를 useParams로부터 가져옴 - const { issueId } = useParams<{ issueId: string }>(); + // 여기까지 작성했음 const handleToggleMode = useToggleMode({ mode, diff --git a/src/pages/workspace/WorkspaceGoalDetail.tsx b/src/pages/workspace/WorkspaceGoalDetail.tsx index 613996e9..a32382e1 100644 --- a/src/pages/workspace/WorkspaceGoalDetail.tsx +++ b/src/pages/workspace/WorkspaceGoalDetail.tsx @@ -42,13 +42,15 @@ import { useParams } from 'react-router-dom'; import { useGetWorkspaceMembers } from '../../apis/setting/useGetWorkspaceMembers'; import { useGetSimpleIssueList } from '../../apis/issue/useGetSimpleIssueList'; import { useCreateGoal } from '../../apis/goal/usePostCreateGoalDetail'; -import { useIsMutating, useQueryClient } from '@tanstack/react-query'; +import { useIsMutating } from '@tanstack/react-query'; import { mutationKey } from '../../constants/mutationKey'; import type { CreateGoalDetailDto, UpdateGoalDetailDto } from '../../types/goal'; import { useGetGoalDetail } from '../../apis/goal/useGetGoalDetail'; import { useHydrateGoalDetail } from '../../hooks/useHydrateGoalDetail'; import { useUpdateGoal } from '../../apis/goal/usePatchGoalDetail'; import { useGoalDeadlinePatch } from '../../hooks/useGoalDeadlinePatch'; +import queryClient from '../../utils/queryClient'; +import { queryKey } from '../../constants/queryKey'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -93,9 +95,6 @@ const WorkspaceGoalDetail = ({ initialMode }: WorkspaceGoalDetailProps) => { const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) const canPatch = Number.isFinite(numericGoalId); // PATCH 가능 조건 - // 상세 조회 훅: goalId가 있을 때만 자동 실행됨 - const queryClient = useQueryClient(); - // 단일 선택 라벨 const selectedStatusLabel = STATUS_LABELS[state]; const selectedPriorityLabel = PRIORITY_LABELS[priority]; @@ -166,7 +165,8 @@ const WorkspaceGoalDetail = ({ initialMode }: WorkspaceGoalDetailProps) => { submitGoal(payload, { onSuccess: ({ goalId }) => { - queryClient.invalidateQueries({ queryKey: ['GOAL_DETAIL', goalId] }); + queryClient.invalidateQueries({ queryKey: [queryKey.GOAL_LIST, String(teamId)] }); + queryClient.invalidateQueries({ queryKey: [queryKey.GOAL_NAME, String(teamId)] }); startTransition(() => handleToggleMode(goalId)); }, onSettled: () => { @@ -180,7 +180,9 @@ const WorkspaceGoalDetail = ({ initialMode }: WorkspaceGoalDetailProps) => { updateGoal(payload, { onSuccess: () => { if (Number.isFinite(numericGoalId)) { - queryClient.invalidateQueries({ queryKey: ['GOAL_DETAIL', numericGoalId] }); + queryClient.invalidateQueries({ queryKey: [queryKey.GOAL_LIST, String(teamId)] }); + queryClient.invalidateQueries({ queryKey: [queryKey.GOAL_NAME, String(teamId)] }); + queryClient.invalidateQueries({ queryKey: [queryKey.GOAL_DETAIL, numericGoalId] }); } startTransition(() => handleToggleMode()); }, From 3d2d3f192a1a767f5397632322a37688eed825d8 Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Fri, 15 Aug 2025 21:37:45 +0900 Subject: [PATCH 7/9] =?UTF-8?q?#172=20[FEAT]=20=EC=83=81=EC=84=B8=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EB=82=B4=20=EC=9D=B4=EC=8A=88=20API=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=201=EC=B0=A8=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/issue/useGetIssueDetail.ts | 23 +- src/components/Dropdown/Dropdown.tsx | 11 +- src/hooks/useGoalDeadlinePatch.ts | 1 - src/hooks/useHydrateIssueDetail.ts | 113 ++++++++++ src/hooks/useIssueDeadlinePatch.ts | 94 +++++++++ src/hooks/useToggleMode.ts | 6 +- src/pages/issue/IssueDetail.tsx | 301 +++++++++++++++++++++++---- src/types/issue.ts | 4 +- 8 files changed, 493 insertions(+), 60 deletions(-) create mode 100644 src/hooks/useHydrateIssueDetail.ts create mode 100644 src/hooks/useIssueDeadlinePatch.ts diff --git a/src/apis/issue/useGetIssueDetail.ts b/src/apis/issue/useGetIssueDetail.ts index 8abdfab4..aafb65d0 100644 --- a/src/apis/issue/useGetIssueDetail.ts +++ b/src/apis/issue/useGetIssueDetail.ts @@ -11,23 +11,28 @@ import type { ResponseViewIssueDetailDto, ViewIssueDetailDto } from '../../types */ const getIssueDetail = async (issueId: number): Promise => { try { - const response = await axiosInstance.get(`/api/issues/${issueId}`); - - if (!response.data.result) return Promise.reject(response); - if (response.data?.isSuccess) { - console.log('조회 성공:', response.data.result); + const { data } = await axiosInstance.get(`/api/issues/${issueId}`); + if (!data.result) return Promise.reject(data); + if (data?.isSuccess) { + console.log('조회 성공:', data.result); } - return response.data.result; + return data.result; } catch (error) { console.error('이슈 상세 조회 실패', error); throw error; } }; -export const useGetIssueDetail = (issueId: number) => { - return useQuery({ +export const useGetIssueDetail = (issueId: number, opts?: { enabled?: boolean }) => { + const enabled = (opts?.enabled ?? true) && Number.isFinite(issueId) && issueId > 0; + + return useQuery({ queryKey: [queryKey.ISSUE_DETAIL, issueId], queryFn: () => getIssueDetail(issueId), - enabled: Number.isFinite(issueId), + enabled, // ← create 경로 등에서 NaN/0이면 쿼리 미실행 + retry: (failureCount, error: any) => { + if (error?.response?.status === 404) return false; // 404면 재시도 안함 + return failureCount < 2; + }, }); }; diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx index 86ef4614..a121b2bd 100644 --- a/src/components/Dropdown/Dropdown.tsx +++ b/src/components/Dropdown/Dropdown.tsx @@ -1,7 +1,7 @@ import IcCheck from '../../assets/icons/check.svg'; import IcDownArrow from '../../assets/icons/down-arrow.svg'; import useDropdownRef from '../../hooks/useDropdownRef.ts'; -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; interface DropdownProps { value?: string; // 현재 선택된 값 @@ -22,6 +22,9 @@ const Dropdown = ({ }: DropdownProps) => { const dropdownRef = useDropdownRef(onClose); + // 중복 라벨 제거 + const safeOptions = useMemo(() => Array.from(new Set(options)), [options]); + const handleSelect = useCallback( (option: string) => { defaultValue && onSelect(option); @@ -46,9 +49,9 @@ const Dropdown = ({ {defaultValue} )} - {options.map((option) => ( + {safeOptions.map((option, idx) => (
handleSelect(option)} > @@ -56,7 +59,7 @@ const Dropdown = ({ {option}
))} diff --git a/src/hooks/useGoalDeadlinePatch.ts b/src/hooks/useGoalDeadlinePatch.ts index 26e64282..f4cbdc79 100644 --- a/src/hooks/useGoalDeadlinePatch.ts +++ b/src/hooks/useGoalDeadlinePatch.ts @@ -1,4 +1,3 @@ -// src/hooks/useGoalDeadlinePatch.ts import { useEffect, useRef, useCallback } from 'react'; import { buildDeadlinePatch } from '../utils/deadlinePatch'; import type { UpdateGoalDetailDto } from '../types/goal'; diff --git a/src/hooks/useHydrateIssueDetail.ts b/src/hooks/useHydrateIssueDetail.ts new file mode 100644 index 00000000..573fc3d6 --- /dev/null +++ b/src/hooks/useHydrateIssueDetail.ts @@ -0,0 +1,113 @@ +// src/hooks/useHydrateIssueDetail.ts +import { useEffect, useRef } from 'react'; +import type { SubmitHandleRef } from '../components/DetailView/TextEditor/lexical-plugins/SubmitHandlePlugin'; +import type { StatusCode, PriorityCode } from '../types/listItem'; +import type { ViewIssueDetailDto } from '../types/issue'; +import type { SimpleGoal } from '../types/goal'; + +type Params = { + issueDetail?: ViewIssueDetailDto | undefined; + issueId?: 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; // 단일 선택(없음 가능) +}; + +export const useHydrateIssueDetail = ({ + issueDetail, + issueId, + editorRef, + workspaceMembers, + simpleGoals, + nameToId, + setTitle, + setState, + setPriority, + setSelectedDate, + setManagersId, + setGoalId, +}: Params) => { + const hydratedRef = useRef(false); + + useEffect(() => { + if (!issueDetail) return; + if (!Number.isFinite(issueId)) return; + if (hydratedRef.current) return; + + // 옵션 준비여부 판단 + // - 담당자가 존재하면 멤버 옵션 준비 필요 + const membersReady = + (workspaceMembers?.length ?? 0) > 0 || (issueDetail.managers?.cnt ?? 0) === 0; + + // - 목표 연결(단일) 세팅용: 서버 응답에 goal.id가 있으면 바로 세팅 가능 + // goal.id가 없고 title만 있을 경우, simpleGoals 준비 후 title->id 매핑 필요 + const needGoalsByTitle = !!issueDetail.goal?.title && issueDetail.goal?.id == null; + const goalsReady = needGoalsByTitle ? (simpleGoals?.length ?? 0) > 0 : true; + + if (!membersReady || !goalsReady) return; + + // 1) 기본 필드 + setTitle(issueDetail.title ?? ''); + setState((issueDetail.state ?? 'NONE') as StatusCode); + setPriority((issueDetail.priority ?? 'NONE') as PriorityCode); + + // 2) 기한 + const s = issueDetail.deadline?.start ? new Date(issueDetail.deadline.start) : null; + const e = issueDetail.deadline?.end ? new Date(issueDetail.deadline.end) : null; + setSelectedDate([s, e]); + + // 3) 담당자 ids + if ((issueDetail.managers?.cnt ?? 0) > 0) { + const managerNames = issueDetail.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 (issueDetail.goal?.id != null) { + setGoalId(issueDetail.goal.id); + } else if (issueDetail.goal?.title) { + const map = new Map((simpleGoals ?? []).map((g) => [g.title, g.id] as const)); + const mapped = map.get(issueDetail.goal.title); + setGoalId(typeof mapped === 'number' ? mapped : null); + } else { + setGoalId(null); + } + + // 5) 에디터 역직렬화 + editorRef.current?.loadJson?.(issueDetail.content ?? ''); + + hydratedRef.current = true; + }, [ + issueDetail, + issueId, + editorRef, + workspaceMembers, + simpleGoals, + nameToId, + setTitle, + setState, + setPriority, + setSelectedDate, + setManagersId, + setGoalId, + ]); +}; diff --git a/src/hooks/useIssueDeadlinePatch.ts b/src/hooks/useIssueDeadlinePatch.ts new file mode 100644 index 00000000..39ebe7c5 --- /dev/null +++ b/src/hooks/useIssueDeadlinePatch.ts @@ -0,0 +1,94 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { buildDeadlinePatch } from '../utils/deadlinePatch'; +import type { UpdateIssueDetailDto } from '../types/issue'; + +type UseIssueDeadlinePatchParams = { + issueDetail: any; + isViewMode: boolean; + canPatch: boolean; + mutateUpdate: (payload: UpdateIssueDetailDto, opts?: { onSuccess?: () => void }) => void; +}; + +/** + * - issueDetail의 기존 deadline(start/end)을 기억 + * - 달력 onSelect / edit 제출 시 변경분만 계산해 PATCH 전송 + */ +export function useIssueDeadlinePatch({ + issueDetail, + isViewMode, + canPatch, + mutateUpdate, +}: UseIssueDeadlinePatchParams) { + const originalDeadlineRef = useRef<{ start: string | null; end: string | null }>({ + start: null, + end: null, + }); + + // goalDetail 변경 시 원본 저장 + useEffect(() => { + const prevStart = + issueDetail?.deadline?.start && typeof issueDetail.deadline.start === 'string' + ? issueDetail.deadline.start + : null; + const prevEnd = + issueDetail?.deadline?.end && typeof issueDetail.deadline.end === 'string' + ? issueDetail.deadline.end + : null; + originalDeadlineRef.current = { start: prevStart, end: prevEnd }; + }, [issueDetail]); + + /** 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/useToggleMode.ts b/src/hooks/useToggleMode.ts index 1b068b07..9e66c278 100644 --- a/src/hooks/useToggleMode.ts +++ b/src/hooks/useToggleMode.ts @@ -34,14 +34,14 @@ export const useToggleMode = ({ mode, setMode, type, id, isDefaultTeam }: UseTog if (!base) return; const effectiveId = overrideId ?? id; - if (effectiveId == null) return; // id 없으면 아무 것도 하지 않음 + if (!Number.isFinite(effectiveId as number)) return; if (mode === 'create' || mode === 'edit') { setMode('view'); - navigate(`${base}/${effectiveId}`, { replace: mode === 'create' }); // create 모드일 때만 replace 적용하여 기존 생성 페이지 기록 삭제 + navigate(`${base}/${String(effectiveId)}`, { replace: mode === 'create' }); // create 모드일 때만 replace 적용하여 기존 생성 페이지 기록 삭제 } else if (mode === 'view') { setMode('edit'); - navigate(`${base}/${effectiveId}/edit`); + navigate(`${base}/${String(effectiveId)}/edit`); } }, [getBasePath, mode, id, navigate, setMode] diff --git a/src/pages/issue/IssueDetail.tsx b/src/pages/issue/IssueDetail.tsx index 4986d997..1c957907 100644 --- a/src/pages/issue/IssueDetail.tsx +++ b/src/pages/issue/IssueDetail.tsx @@ -1,7 +1,7 @@ // IssueDetail.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'; @@ -19,27 +19,39 @@ import IcCalendar from '../../assets/icons/date-lg.svg'; import IcGoal from '../../assets/icons/goal.svg'; import { getStatusColor } from '../../utils/listItemUtils'; -import { statusLabelToCode } from '../../types/detailitem'; +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 CommentInput from '../../components/DetailView/Comment/CommentInput'; import { usePostComment } from '../../apis/comment/usePostComment'; import MultiSelectPropertyItem from '../../components/DetailView/MultiSelectPropertyItem'; -import type { SubmitHandleRef } from '../../components/DetailView/TextEditor/lexical-plugins/SubmitHandlePlugin'; -import { useCreateGoal } from '../../apis/goal/usePostCreateGoalDetail'; +import { + EMPTY_EDITOR_STATE, + type SubmitHandleRef, +} from '../../components/DetailView/TextEditor/lexical-plugins/SubmitHandlePlugin'; import { useIsMutating } from '@tanstack/react-query'; import { mutationKey } from '../../constants/mutationKey'; import { useParams } from 'react-router-dom'; -import type { PriorityCode, StatusCode } from '../../types/listItem'; +import { + PRIORITY_LABELS, + STATUS_LABELS, + type PriorityCode, + type StatusCode, +} from '../../types/listItem'; import { useGetWorkspaceMembers } from '../../apis/setting/useGetWorkspaceMembers'; import { useGetSimpleGoalList } from '../../apis/goal/useGetSimpleGoalList.ts'; import { useCreateIssue } from '../../apis/issue/usePostCreateIssueDetail.ts'; import { useGetIssueDetail } from '../../apis/issue/useGetIssueDetail.ts'; import { useUpdateIssue } from '../../apis/issue/usePatchIssueDetail.ts'; +import { useIssueDeadlinePatch } from '../../hooks/useIssueDeadlinePatch.ts'; +import type { CreateIssueDetailDto, UpdateIssueDetailDto } from '../../types/issue.ts'; +import queryClient from '../../utils/queryClient.ts'; +import { queryKey } from '../../constants/queryKey.ts'; +import { useHydrateIssueDetail } from '../../hooks/useHydrateIssueDetail.ts'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -59,10 +71,7 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { const [priority, setPriority] = useState('NONE'); const [managersId, setManagersId] = useState([]); - // 일단 임시처리임. 백엔드에서 널값 허용해주는 방향으로 수정해주면 반영 - const [goalId, setGoalId] = useState(0); - // 없을 수 있는 값이면: null 허용 - // const [goalId, setGoalId] = useState(null); + const [goalId, setGoalId] = useState(null); // null 허용 const editorSubmitRef = useRef(null); // 텍스트에디터 컨텐츠 접근용 플래그 const isSubmittingRequestRef = useRef(false); // API 제출 중복 요청 가드 플래그 @@ -75,11 +84,13 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { const { data: workspaceMembers } = useGetWorkspaceMembers(); const { data: simpleGoals } = useGetSimpleGoalList(teamId); // 팀 목표 간단 조회 (select로 info만 나오도록 되어 있음) const { mutate: submitIssue, isPending: isCreating } = useCreateIssue(teamId); - const { data: issueDetail } = useGetIssueDetail(numericIssueId); + const { data: issueDetail } = useGetIssueDetail(numericIssueId, { + enabled: true, + }); const { mutate: updateIssue, isPending: isUpdating } = useUpdateIssue(teamId, numericIssueId); const isCreatingGlobal = useIsMutating({ mutationKey: [mutationKey.ISSUE_CREATE, teamId] }) > 0; - const isSaving = isCreating || isCreatingGlobal || isSubmittingRequestRef.current; + const isSaving = isCreating || isUpdating || isCreatingGlobal || isSubmittingRequestRef.current; const { isOpen, content } = useDropdownInfo(); // 작성 완료 여부 (view 모드일 때 true) const { openDropdown } = useDropdownActions(); // 수정 가능 여부 (create 또는 edit 모드일 때 true) @@ -88,16 +99,114 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) const canPatch = Number.isFinite(numericIssueId); // PATCH 가능 조건 - // 여기까지 작성했음 + // 단일 선택 라벨 + const selectedStatusLabel = STATUS_LABELS[state]; + const selectedPriorityLabel = PRIORITY_LABELS[priority]; + + 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 } = useIssueDeadlinePatch({ + issueDetail, + isViewMode: isCompleted, + canPatch, + mutateUpdate: updateIssue, + }); const handleToggleMode = useToggleMode({ mode, setMode, type: 'issue', - id: Number(issueId), + id: Number(issueIdParam), 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 } : {}), // null이면 키 생략 + }; + if (mode === 'create') { + // 생성 시에는 기존 로직 유지 (규칙 제약 없음) + const payload: CreateIssueDetailDto = { + ...basePayload, + deadline: { + ...(start ? { start: formatDateHyphen(start) } : {}), + ...(end ? { end: formatDateHyphen(end) } : {}), + }, + }; + + submitIssue(payload, { + onSuccess: ({ issueId }) => { + queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_LIST, String(teamId)] }); + queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_NAME, String(teamId)] }); + startTransition(() => handleToggleMode(issueId)); + }, + onSettled: () => { + isSubmittingRequestRef.current = false; + }, + }); + } else if (mode === 'edit') { + const patch = buildPatchForEditSubmit(selectedDate); + const payload = { ...basePayload, ...(patch ?? {}) } as UpdateIssueDetailDto; + + updateIssue(payload, { + onSuccess: () => { + if (Number.isFinite(numericIssueId)) { + queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_LIST, String(teamId)] }); + queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_NAME, String(teamId)] }); + queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_DETAIL, numericIssueId] }); + } + 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; @@ -116,24 +225,65 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { 긴급: pr4, }; - // '담당자' 속성 아이콘 매핑 (나중에 API로부터 받아온 데이터로 대체 예정) - const managerIconMap = { - 담당자: IcProfile, - 없음: IcProfile, - 전채운: IcProfile, - 염주원: IcProfile, - 박유민: IcProfile, - 이가을: IcProfile, - 김선화: IcProfile, - 박진주: IcProfile, - }; + 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 goalIconMap = { - 목표: IcGoal, - 없음: IcGoal, - '백호를 사용해서 다른 사람들과 협업해보기': IcGoal, - '기획 및 요구사항 분석': IcGoal, - }; + // '담당자' 항목의 옵션: ['없음', ...팀 멤버 이름들] + 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]); + + const goalOptions = useMemo( + () => ['없음', ...(simpleGoals ?? []).map((g) => g.title)], + [simpleGoals] + ); + + // title -> id 역매핑 + const goalTitleToId = useMemo(() => { + const info = simpleGoals ?? []; + return new Map(info.map((g) => [g.title, g.id] as const)); + }, [simpleGoals]); + + useHydrateIssueDetail({ + issueDetail, + issueId: numericIssueId, + editorRef: editorSubmitRef, + workspaceMembers, + simpleGoals, // 단일 목표 라벨/매핑용 간단 목록 + nameToId, // 멤버 이름 -> id 매핑 + setTitle, + setState, + setPriority, + setSelectedDate, + setManagersId, + setGoalId, + }); const bottomRef = useRef(null); const shouldScrollRef = useRef(false); @@ -157,7 +307,13 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { { + setTitle(v); + // view 모드에서 즉시 PATCH + if (isCompleted && Number.isFinite(numericIssueId)) { + updateIssue({ title: v }); + } + }} isEditable={isEditable} /> @@ -191,6 +347,14 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { const code = statusLabelToCode[label] ?? 'NONE'; return getStatusColor(code); }} + onSelect={(label) => { + const next = statusLabelToCode[label] ?? 'NONE'; + setState(next); + if (isCompleted && Number.isFinite(numericIssueId)) { + updateIssue({ state: next }); + } + }} + selected={selectedStatusLabel} /> @@ -200,6 +364,14 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { defaultValue="우선순위" options={['없음', '긴급', '높음', '중간', '낮음']} iconMap={priorityIconMap} + onSelect={(label) => { + const next = priorityLabelToCode[label] ?? 'NONE'; + setPriority(next); + if (isCompleted && Number.isFinite(numericIssueId)) { + updateIssue({ priority: next }); + } + }} + selected={selectedPriorityLabel} /> @@ -207,8 +379,37 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => {
e.stopPropagation()}> { + // 1) '없음'만 선택된 경우만 비우기 + if (labels.length === 1 && labels[0] === '없음') { + setManagersId([]); + if (isCompleted && Number.isFinite(numericIssueId)) { + updateIssue({ 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(numericIssueId)) { + updateIssue({ managersId: ids }); + } + }} + selected={ + managersId.length === 0 + ? managersShowNoneLabel + ? ['없음'] + : [] // 비어있지만 '없음'을 선택했으면 '없음'을 내려줌 + : selectedManagerLabels + } />
@@ -229,7 +430,10 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { {isOpen && content?.name === 'date' && ( setSelectedDate(date)} + onSelect={(date) => { + setSelectedDate(date); + handleSelectDateAndPatch(date); // view 모드 시 즉시 PATCH + }} /> )} @@ -239,12 +443,27 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => {
e.stopPropagation()}> { + // '없음' 대응 (백엔드가 null 허용 전이라면 0으로) + if (label === '없음') { + setGoalId(null); + if (isCompleted && Number.isFinite(numericIssueId)) { + } + return; + } + + // title -> id 매핑 + const id = goalTitleToId.get(label); + if (typeof id === 'number') { + setGoalId(id); + if (isCompleted && Number.isFinite(numericIssueId)) { + updateIssue({ goalId: id }); + } + } + }} />
@@ -255,7 +474,7 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { isTitleFilled={title.trim().length > 0} isCompleted={isCompleted} isSaving={isSaving} - onToggle={handleToggleMode} + onToggle={handleCompletion} /> diff --git a/src/types/issue.ts b/src/types/issue.ts index 0660d9df..bee75320 100644 --- a/src/types/issue.ts +++ b/src/types/issue.ts @@ -65,7 +65,7 @@ export type CreateIssueDetailDto = { priority: string; managersId: number[]; deadline?: Deadline; - goalId: number; + goalId?: number; }; export type CreateIssueResultDto = { @@ -100,7 +100,7 @@ export type UpdateIssueDetailDto = { priority?: string | null; managersId?: number[] | null; deadline?: Deadline | null; - goalId: number | null; + goalId?: number | null; }; export type UpdateIssueResultDto = { From 21081f3bc8a535c9ecf40fcc7fe923273d92ffa2 Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Fri, 15 Aug 2025 22:13:20 +0900 Subject: [PATCH 8/9] =?UTF-8?q?#172=20[FIX]=20=EB=AA=A9=ED=91=9C=20request?= =?UTF-8?q?=EA=B0=92=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/issue/IssueDetail.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/pages/issue/IssueDetail.tsx b/src/pages/issue/IssueDetail.tsx index 1c957907..a8f16a4b 100644 --- a/src/pages/issue/IssueDetail.tsx +++ b/src/pages/issue/IssueDetail.tsx @@ -153,8 +153,11 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { state, priority, managersId, - ...(goalId !== null ? { goalId } : {}), // null이면 키 생략 + ...(goalId !== null && goalId !== undefined && goalId !== -1 ? { goalId } : {}), }; + + console.log('Request body:', basePayload); + if (mode === 'create') { // 생성 시에는 기존 로직 유지 (규칙 제약 없음) const payload: CreateIssueDetailDto = { @@ -179,6 +182,11 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { const patch = buildPatchForEditSubmit(selectedDate); const payload = { ...basePayload, ...(patch ?? {}) } as UpdateIssueDetailDto; + // 수정 시 goalId가 없으면 생략된 상태로 보냄 + if (goalId === null || goalId === undefined || goalId === -1) { + delete payload.goalId; // goalId가 null, undefined, -1이면 삭제 + } + updateIssue(payload, { onSuccess: () => { if (Number.isFinite(numericIssueId)) { From cff675e7005380e0074bbe761576c46ee8bd51ae Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Fri, 15 Aug 2025 22:32:36 +0900 Subject: [PATCH 9/9] =?UTF-8?q?#172=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=ED=8C=80=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=88=20=EC=83=81=EC=84=B8=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=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 --- src/pages/workspace/WorkspaceIssueDetail.tsx | 334 ++++++++++++++++--- 1 file changed, 291 insertions(+), 43 deletions(-) diff --git a/src/pages/workspace/WorkspaceIssueDetail.tsx b/src/pages/workspace/WorkspaceIssueDetail.tsx index 55e72591..a3ba82b3 100644 --- a/src/pages/workspace/WorkspaceIssueDetail.tsx +++ b/src/pages/workspace/WorkspaceIssueDetail.tsx @@ -1,7 +1,7 @@ // WorkspaceIssueDetail.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'; @@ -19,21 +19,39 @@ import IcCalendar from '../../assets/icons/date-lg.svg'; import IcGoal from '../../assets/icons/goal.svg'; import { getStatusColor } from '../../utils/listItemUtils'; -import { statusLabelToCode } from '../../types/detailitem'; +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 CommentInput from '../../components/DetailView/Comment/CommentInput'; import { usePostComment } from '../../apis/comment/usePostComment'; import MultiSelectPropertyItem from '../../components/DetailView/MultiSelectPropertyItem'; -import type { SubmitHandleRef } from '../../components/DetailView/TextEditor/lexical-plugins/SubmitHandlePlugin'; +import { + EMPTY_EDITOR_STATE, + type SubmitHandleRef, +} from '../../components/DetailView/TextEditor/lexical-plugins/SubmitHandlePlugin'; import { useParams } from 'react-router-dom'; -import { useCreateGoal } from '../../apis/goal/usePostCreateGoalDetail'; import { mutationKey } from '../../constants/mutationKey'; import { useIsMutating } from '@tanstack/react-query'; +import { + PRIORITY_LABELS, + STATUS_LABELS, + type PriorityCode, + type StatusCode, +} from '../../types/listItem'; +import { useGetWorkspaceMembers } from '../../apis/setting/useGetWorkspaceMembers'; +import { useGetSimpleGoalList } from '../../apis/goal/useGetSimpleGoalList.ts'; +import { useCreateIssue } from '../../apis/issue/usePostCreateIssueDetail'; +import { useGetIssueDetail } from '../../apis/issue/useGetIssueDetail'; +import { useUpdateIssue } from '../../apis/issue/usePatchIssueDetail'; +import { useIssueDeadlinePatch } from '../../hooks/useIssueDeadlinePatch.ts'; +import type { CreateIssueDetailDto, UpdateIssueDetailDto } from '../../types/issue.ts'; +import { queryKey } from '../../constants/queryKey.ts'; +import queryClient from '../../utils/queryClient.ts'; +import { useHydrateIssueDetail } from '../../hooks/useHydrateIssueDetail.ts'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -49,34 +67,154 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => { 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 [goalId, setGoalId] = useState(null); // null 허용 const editorSubmitRef = useRef(null); // 텍스트에디터 컨텐츠 접근용 플래그 const isSubmittingRequestRef = useRef(false); // API 제출 중복 요청 가드 플래그 const teamId = Number(useParams<{ teamId: string }>().teamId); - /** - * @todo: 나중에 useCreateIssue로 제대로 연결 - */ - const { isPending } = useCreateGoal(teamId); + + // issueId를 useParams로부터 가져옴 + const { issueId: issueIdParam } = useParams<{ issueId: string }>(); + const numericIssueId = Number(issueIdParam); + + const { data: workspaceMembers } = useGetWorkspaceMembers(); + const { data: simpleGoals } = useGetSimpleGoalList(teamId); // 팀 목표 간단 조회 (select로 info만 나오도록 되어 있음) + const { mutate: submitIssue, isPending: isCreating } = useCreateIssue(teamId); + const { data: issueDetail } = useGetIssueDetail(numericIssueId, { + enabled: true, + }); + const { mutate: updateIssue, isPending: isUpdating } = useUpdateIssue(teamId, numericIssueId); + const isCreatingGlobal = useIsMutating({ mutationKey: [mutationKey.ISSUE_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(numericIssueId); // PATCH 가능 조건 - // issueId를 useParams로부터 가져옴 - const { issueId } = useParams<{ issueId: string }>(); + // 단일 선택 라벨 + const selectedStatusLabel = STATUS_LABELS[state]; + const selectedPriorityLabel = PRIORITY_LABELS[priority]; + + 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 } = useIssueDeadlinePatch({ + issueDetail, + isViewMode: isCompleted, + canPatch, + mutateUpdate: updateIssue, + }); const handleToggleMode = useToggleMode({ mode, setMode, type: 'issue', - id: Number(issueId), + id: Number(issueIdParam), 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 } : {}), + }; + + console.log('Request body:', basePayload); + + if (mode === 'create') { + // 생성 시에는 기존 로직 유지 (규칙 제약 없음) + const payload: CreateIssueDetailDto = { + ...basePayload, + deadline: { + ...(start ? { start: formatDateHyphen(start) } : {}), + ...(end ? { end: formatDateHyphen(end) } : {}), + }, + }; + + submitIssue(payload, { + onSuccess: ({ issueId }) => { + queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_LIST, String(teamId)] }); + queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_NAME, String(teamId)] }); + startTransition(() => handleToggleMode(issueId)); + }, + onSettled: () => { + isSubmittingRequestRef.current = false; + }, + }); + } else if (mode === 'edit') { + const patch = buildPatchForEditSubmit(selectedDate); + const payload = { ...basePayload, ...(patch ?? {}) } as UpdateIssueDetailDto; + + // 수정 시 goalId가 없으면 생략된 상태로 보냄 + if (goalId === null || goalId === undefined || goalId === -1) { + delete payload.goalId; // goalId가 null, undefined, -1이면 삭제 + } + + updateIssue(payload, { + onSuccess: () => { + if (Number.isFinite(numericIssueId)) { + queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_LIST, String(teamId)] }); + queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_NAME, String(teamId)] }); + queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_DETAIL, numericIssueId] }); + } + 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; @@ -95,24 +233,65 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => { 긴급: pr4, }; - // '담당자' 속성 아이콘 매핑 (나중에 API로부터 받아온 데이터로 대체 예정) - const managerIconMap = { - 담당자: IcProfile, - 없음: IcProfile, - 전채운: IcProfile, - 염주원: IcProfile, - 박유민: IcProfile, - 이가을: IcProfile, - 김선화: IcProfile, - 박진주: IcProfile, - }; + const goalIconMap = new Proxy( + {}, + { + get: () => IcGoal, + } + ) as Record; - const goalIconMap = { - 목표: IcGoal, - 없음: IcGoal, - '백호를 사용해서 다른 사람들과 협업해보기': IcGoal, - '기획 및 요구사항 분석': IcGoal, - }; + // 해당 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]); + + const goalOptions = useMemo( + () => ['없음', ...(simpleGoals ?? []).map((g) => g.title)], + [simpleGoals] + ); + + // title -> id 역매핑 + const goalTitleToId = useMemo(() => { + const info = simpleGoals ?? []; + return new Map(info.map((g) => [g.title, g.id] as const)); + }, [simpleGoals]); + + useHydrateIssueDetail({ + issueDetail, + issueId: numericIssueId, + editorRef: editorSubmitRef, + workspaceMembers, + simpleGoals, // 단일 목표 라벨/매핑용 간단 목록 + nameToId, // 멤버 이름 -> id 매핑 + setTitle, + setState, + setPriority, + setSelectedDate, + setManagersId, + setGoalId, + }); const bottomRef = useRef(null); const shouldScrollRef = useRef(false); @@ -131,12 +310,18 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => { {/* 상세페이지 메인 */}
{/* 상세페이지 좌측 영역 - 제목 & 상세설명 & 댓글 */} -
+
{/* 상세페이지 제목 */} { + setTitle(v); + // view 모드에서 즉시 PATCH + if (isCompleted && Number.isFinite(numericIssueId)) { + updateIssue({ title: v }); + } + }} isEditable={isEditable} /> @@ -170,6 +355,14 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => { const code = statusLabelToCode[label] ?? 'NONE'; return getStatusColor(code); }} + onSelect={(label) => { + const next = statusLabelToCode[label] ?? 'NONE'; + setState(next); + if (isCompleted && Number.isFinite(numericIssueId)) { + updateIssue({ state: next }); + } + }} + selected={selectedStatusLabel} />
@@ -179,6 +372,14 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => { defaultValue="우선순위" options={['없음', '긴급', '높음', '중간', '낮음']} iconMap={priorityIconMap} + onSelect={(label) => { + const next = priorityLabelToCode[label] ?? 'NONE'; + setPriority(next); + if (isCompleted && Number.isFinite(numericIssueId)) { + updateIssue({ priority: next }); + } + }} + selected={selectedPriorityLabel} />
@@ -186,8 +387,37 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => {
e.stopPropagation()}> { + // 1) '없음'만 선택된 경우만 비우기 + if (labels.length === 1 && labels[0] === '없음') { + setManagersId([]); + if (isCompleted && Number.isFinite(numericIssueId)) { + updateIssue({ 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(numericIssueId)) { + updateIssue({ managersId: ids }); + } + }} + selected={ + managersId.length === 0 + ? managersShowNoneLabel + ? ['없음'] + : [] // 비어있지만 '없음'을 선택했으면 '없음'을 내려줌 + : selectedManagerLabels + } />
@@ -208,7 +438,10 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => { {isOpen && content?.name === 'date' && ( setSelectedDate(date)} + onSelect={(date) => { + setSelectedDate(date); + handleSelectDateAndPatch(date); // view 모드 시 즉시 PATCH + }} /> )}
@@ -218,12 +451,27 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => {
e.stopPropagation()}> { + // '없음' 대응 (백엔드가 null 허용 전이라면 0으로) + if (label === '없음') { + setGoalId(null); + if (isCompleted && Number.isFinite(numericIssueId)) { + } + return; + } + + // title -> id 매핑 + const id = goalTitleToId.get(label); + if (typeof id === 'number') { + setGoalId(id); + if (isCompleted && Number.isFinite(numericIssueId)) { + updateIssue({ goalId: id }); + } + } + }} />
@@ -234,7 +482,7 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => { isTitleFilled={title.trim().length > 0} isCompleted={isCompleted} isSaving={isSaving} - onToggle={handleToggleMode} + onToggle={handleCompletion} />