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/useGetIssueDetail.ts b/src/apis/issue/useGetIssueDetail.ts new file mode 100644 index 00000000..aafb65d0 --- /dev/null +++ b/src/apis/issue/useGetIssueDetail.ts @@ -0,0 +1,38 @@ +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/issue/IssueDetail.tsx + * - pages/workspace/WorkspaceIssueDetail.tsx + */ +const getIssueDetail = async (issueId: number): Promise => { + try { + const { data } = await axiosInstance.get(`/api/issues/${issueId}`); + 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 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, // ← create 경로 등에서 NaN/0이면 쿼리 미실행 + retry: (failureCount, error: any) => { + if (error?.response?.status === 404) return false; // 404면 재시도 안함 + return failureCount < 2; + }, + }); +}; 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 new file mode 100644 index 00000000..4d9794cc --- /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/workspace/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/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 4a36b67e..a8f16a4b 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,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 { 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 { + 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 - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -49,34 +67,154 @@ 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(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(); // 작성 완료 여부 (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: 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 } : {}), + }; + + 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 IssueDetail = ({ initialMode }: IssueDetailProps) => { 긴급: 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); @@ -136,7 +315,13 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { { + setTitle(v); + // view 모드에서 즉시 PATCH + if (isCompleted && Number.isFinite(numericIssueId)) { + updateIssue({ title: v }); + } + }} isEditable={isEditable} /> @@ -170,6 +355,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} /> @@ -179,6 +372,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} /> @@ -186,8 +387,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 + } />
@@ -208,7 +438,10 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { {isOpen && content?.name === 'date' && ( setSelectedDate(date)} + onSelect={(date) => { + setSelectedDate(date); + handleSelectDateAndPatch(date); // view 모드 시 즉시 PATCH + }} /> )} @@ -218,12 +451,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 }); + } + } + }} />
@@ -234,7 +482,7 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { isTitleFilled={title.trim().length > 0} isCompleted={isCompleted} isSaving={isSaving} - onToggle={handleToggleMode} + onToggle={handleCompletion} /> 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} /> diff --git a/src/types/issue.ts b/src/types/issue.ts index 1d558b5f..bee75320 100644 --- a/src/types/issue.ts +++ b/src/types/issue.ts @@ -1,10 +1,12 @@ -import type { CursorBasedResponse } from './common'; +import type { ResponseCommentListDto } from './comment'; +import type { CommonResponse, CursorBasedResponse } from './common'; +import type { SimpleGoal } from './goal'; /* 임시 구조, api 명세서 완성 시 변경 필요하면 수정 예정 */ export type Deadline = { - start: string; - end: string; + start?: string; + end?: string; }; export type Goal = { @@ -54,3 +56,56 @@ 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; + 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;