diff --git a/src/App.tsx b/src/App.tsx index 1173608..da6b581 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,11 +3,17 @@ import router from './routes'; import { QueryClientProvider } from '@tanstack/react-query'; import queryClient from './utils/queryClient.ts'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { ToastProvider } from './components/Toast/ToastProvider.tsx'; +import ToastViewport from './components/Toast/ToastViewport.tsx'; function App() { return ( - + + + + + {import.meta.env.DEV && } ); diff --git a/src/components/DetailView/DetailTitle.tsx b/src/components/DetailView/DetailTitle.tsx index 5b95331..b92cc21 100644 --- a/src/components/DetailView/DetailTitle.tsx +++ b/src/components/DetailView/DetailTitle.tsx @@ -2,17 +2,23 @@ // 상세페이지 제목 컴포넌트 import { useEffect, useRef } from 'react'; +import { useToast } from '../Toast/ToastProvider'; interface DetailTitleProps { defaultTitle: string; title: string; setTitle: (value: string) => void; - isEditable: boolean; // true일 때만 상세 설명 내용 입력 가능함 } +const MAX_TITLE = 20; // 최대 제목 글자 수 + const DetailTitle = ({ defaultTitle, title, setTitle, isEditable }: DetailTitleProps) => { const textarea = useRef(null); + const committedLenRef = useRef(title.length); // 마지막으로 커밋된(클램프 반영된) 길이 + const warnedRef = useRef(false); // 이번 글자수 초과 구간에서 토스트를 이미 띄웠는지 확인 + const composingRef = useRef(false); // 입력 방식 편집기(IME) 사용 중인지 + const { showToast } = useToast(); const handleResizeHeight = () => { if (textarea.current) { @@ -21,23 +27,71 @@ const DetailTitle = ({ defaultTitle, title, setTitle, isEditable }: DetailTitleP } }; - // 입력 시 + const clamp = (s: string) => (s.length > MAX_TITLE ? s.slice(0, MAX_TITLE) : s); + + // 길이 상태 평가 + (필요 시) 토스트 1회 + const evaluateLimitAndMaybeToast = (nextRaw: string) => { + const isOver = nextRaw.length > MAX_TITLE; + const wasUnderOrEqual = committedLenRef.current <= MAX_TITLE; + + // 20자 이하로 돌아오면 다음 초과 때 다시 토스트 허용 + if (!isOver) warnedRef.current = false; + + if (wasUnderOrEqual && isOver && !warnedRef.current) { + showToast({ + contents: '최대 20자까지 작성할 수 있습니다.', + key: 'titleMax', // 중복 합치기 + }); + warnedRef.current = true; + } + }; + + // onChange: 모든 입력 변경(키보드, 붙여넣기, 마우스 등)에서 호출됨 const handleChange = (e: React.ChangeEvent) => { - setTitle(e.target.value); + const nextRaw = e.target.value; + + // IME 조합 중에는 토스트/클램프를 지연하고, 조합 종료에서 한 번에 처리 + if (composingRef.current) { + setTitle(nextRaw); + handleResizeHeight(); + return; + } + + // 조합이 아니면 즉시 검사 및 토스트 + evaluateLimitAndMaybeToast(nextRaw); + + // 조합이 아니면 즉시 평가 -> 토스트 -> 클램프 -> 커밋 + evaluateLimitAndMaybeToast(nextRaw); + const next = clamp(nextRaw); + setTitle(next); + committedLenRef.current = next.length; + handleResizeHeight(); + }; + + // IME 조합 시작/종료 + const handleCompositionStart = () => { + composingRef.current = true; + }; + const handleCompositionEnd = (e: React.CompositionEvent) => { + composingRef.current = false; + const nextRaw = e.currentTarget.value; + + // 조합 종료 시 한 번만 평가 -> 토스트 -> 클램프 -> 커밋 + evaluateLimitAndMaybeToast(nextRaw); + const next = clamp(nextRaw); + setTitle(next); + committedLenRef.current = next.length; handleResizeHeight(); }; - // 초기 mount 및 title 업데이트 시 제목 textarea 높이 조절되게 useEffect(() => { handleResizeHeight(); }, [title]); - // 뷰포트 사이즈 변경 시에도 제목 textarea 높이 재조정 useEffect(() => { - window.addEventListener('resize', handleResizeHeight); - return () => { - window.removeEventListener('resize', handleResizeHeight); - }; + const r = () => handleResizeHeight(); + window.addEventListener('resize', r); + return () => window.removeEventListener('resize', r); }, []); return ( @@ -48,6 +102,8 @@ const DetailTitle = ({ defaultTitle, title, setTitle, isEditable }: DetailTitleP value={title} rows={1} onChange={handleChange} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); // 엔터 키를 눌러 직접 줄바꿈하는 것 방지 diff --git a/src/components/Toast/ToastProvider.tsx b/src/components/Toast/ToastProvider.tsx new file mode 100644 index 0000000..3d7069e --- /dev/null +++ b/src/components/Toast/ToastProvider.tsx @@ -0,0 +1,230 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; + +export const MAX_VISIBLE = 3; // 한번에 보일 최대 토스트 수 +export const DEFAULT_DURATION = 2000; // 자동 닫힘 시간(ms) +export const ANIMATION_DURATION = 400; // 입/퇴장 애니메이션 시간(ms) + +export type ShowToastArguments = { + title?: string; + contents: ReactNode; + key?: string; // 같은 key면 기존 토스트 갱신 + 타이머 리셋 + duration?: number; // 자동 닫힘 시간 +}; + +export type ToastItem = Required> & { + id: string; // 전역 고유 id (randomUUID 기반) + key?: string; + duration: number; + closing?: boolean; // 내려가는 애니메이션 중인지 +}; + +type ToastCtx = { + visible: ToastItem[]; // 현재 화면에 있는 토스트들 + showToast: (args: ShowToastArguments) => void; + dismissToast: (id: string) => void; +}; + +const ToastContext = createContext(null); + +export const useToast = () => { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast는 내에서 사용되어야 합니다.'); + return ctx; +}; + +// 전역 고유 id 생성기 +const nextToastId = (): string => { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID(); + } + return `t_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +}; + +// 각 토스트별 타이머(닫힘 트리거/제거 타이머)를 관리 +type Timers = { closeTimer?: number; removeTimer?: number }; + +export const ToastProvider = ({ children }: { children: ReactNode }) => { + const [visible, setVisible] = useState([]); + const [, setQueue] = useState([]); // 후순위 대기열(읽지 않아도 됨) + const timersRef = useRef>(new Map()); + + const clearTimers = useCallback((id: string) => { + const t = timersRef.current.get(id); + if (!t) return; + if (t.closeTimer) clearTimeout(t.closeTimer); + if (t.removeTimer) clearTimeout(t.removeTimer); + timersRef.current.delete(id); + }, []); + + // duration 이후 closing=true, 이후 ANIMATION_DURATION 지나면 실제 제거 + 큐 승격 + const startTimers = useCallback( + (toast: ToastItem) => { + clearTimers(toast.id); + + const closeTimer = window.setTimeout(() => { + // 내려가는 애니메이션 시작 + setVisible((prev) => prev.map((t) => (t.id === toast.id ? { ...t, closing: true } : t))); + + const removeTimer = window.setTimeout(() => { + // 화면에서 제거 + setVisible((prev) => prev.filter((t) => t.id !== toast.id)); + timersRef.current.delete(toast.id); + + // 큐 승격 + setQueue((q) => { + if (q.length === 0) return q; + const [next, ...rest] = q; + setVisible((prev) => { + // 이미 들어있으면 중복 추가 방지 + if (prev.some((p) => p.id === next.id)) return prev; + startTimers(next); + return [...prev, next]; + }); + return rest; + }); + }, ANIMATION_DURATION); + + const prev = timersRef.current.get(toast.id) ?? {}; + timersRef.current.set(toast.id, { ...prev, removeTimer }); + }, toast.duration); + + const prev = timersRef.current.get(toast.id) ?? {}; + timersRef.current.set(toast.id, { ...prev, closeTimer }); + }, + [clearTimers] + ); + + const dismissToast = useCallback( + (id: string) => { + // 즉시 closing -> ANIMATION_DURATION 뒤 제거 + clearTimers(id); + + // 이미 closing이면 중복 처리 방지 + setVisible((prev) => { + const target = prev.find((t) => t.id === id); + if (!target) return prev; + if (target.closing) return prev; + return prev.map((t) => (t.id === id ? { ...t, closing: true } : t)); + }); + + const removeTimer = window.setTimeout(() => { + setVisible((prev) => prev.filter((t) => t.id !== id)); + timersRef.current.delete(id); + + // 큐 승격 + setQueue((q) => { + if (q.length === 0) return q; + const [next, ...rest] = q; + setVisible((prev) => { + if (prev.some((p) => p.id === next.id)) return prev; + if (prev.length >= MAX_VISIBLE) return prev; + startTimers(next); + return [...prev, next]; + }); + return rest; + }); + }, ANIMATION_DURATION); + + const prev = timersRef.current.get(id) ?? {}; + timersRef.current.set(id, { ...prev, removeTimer }); + }, + [clearTimers, startTimers] + ); + + const showToast = useCallback( + (args: ShowToastArguments) => { + const item: ToastItem = { + id: nextToastId(), + title: args.title ?? '알림', + contents: args.contents, + key: args.key, + duration: args.duration ?? DEFAULT_DURATION, + closing: false, + }; + + // 1) key 중복합치기 (visible/queue) + if (item.key) { + let updated = false; + + setVisible((prev) => { + const idx = prev.findIndex((t) => t.key === item.key); + if (idx >= 0) { + const old = prev[idx]; + clearTimers(old.id); + const merged: ToastItem = { + ...old, + title: item.title, + contents: item.contents, + duration: item.duration, + closing: false, // 재활성화 + }; + startTimers(merged); + const clone = prev.slice(); + clone[idx] = merged; + updated = true; + return clone; + } + return prev; + }); + + if (!updated) { + setQueue((prev) => { + const idx = prev.findIndex((t) => t.key === item.key); + if (idx >= 0) { + const clone = prev.slice(); + clone[idx] = { + ...clone[idx], + title: item.title, + contents: item.contents, + duration: item.duration, + }; + updated = true; + return clone; + } + return prev; + }); + } + + if (updated) return; + } + + // 2) 빈 자리면 visible + 타이머 시작, 아니면 큐 쌓기 + setVisible((prev) => { + if (prev.length < MAX_VISIBLE) { + startTimers(item); + return [...prev, item]; + } + setQueue((q) => [...q, item]); + return prev; + }); + }, + [startTimers, clearTimers] + ); + + // 언마운트 시 모든 타이머 정리 + useEffect(() => { + return () => { + timersRef.current.forEach(({ closeTimer, removeTimer }) => { + if (closeTimer) clearTimeout(closeTimer); + if (removeTimer) clearTimeout(removeTimer); + }); + timersRef.current.clear(); + }; + }, []); + + const ctx = useMemo( + () => ({ visible, showToast, dismissToast }), + [visible, showToast, dismissToast] + ); + + return {children}; +}; diff --git a/src/components/Toast/ToastViewport.tsx b/src/components/Toast/ToastViewport.tsx new file mode 100644 index 0000000..fb603cf --- /dev/null +++ b/src/components/Toast/ToastViewport.tsx @@ -0,0 +1,70 @@ +import { createPortal } from 'react-dom'; +import { ANIMATION_DURATION, useToast } from './ToastProvider'; +import { useEffect, useMemo, useState } from 'react'; + +const ToastViewport = () => { + const { visible } = useToast(); + const [mountedIds, setMountedIds] = useState>(new Set()); + + // 새로 생긴 토스트 나타나는 애니메이션 트리거 + useEffect(() => { + const current = new Set(visible.map((t) => t.id)); + const news = [...current].filter((id) => !mountedIds.has(id)); + if (news.length) { + const r = requestAnimationFrame(() => { + setMountedIds((prev) => new Set([...prev, ...news])); + }); + return () => cancelAnimationFrame(r); + } + // 사라진 토스트 id는 정리(메모리 관리) + const gone = [...mountedIds].filter((id) => !current.has(id)); + if (gone.length) { + setMountedIds((prev) => { + const next = new Set(prev); + gone.forEach((id) => next.delete(id)); + return next; + }); + } + }, [visible, mountedIds]); + + const items = useMemo(() => visible, [visible]); + + return createPortal( +
+ {items.map((t) => { + const mounted = mountedIds.has(t.id); + const leaving = !!t.closing; + + // 기본 스타일 + 트랜지션 + const base = + 'pointer-events-auto flex flex-col gap-[1.6rem] p-[2.4rem] rounded-lg border bg-gray-200 border-gray-300 text-gray-600 transform transition-all ease-in-out'; + const motionClass = leaving + ? 'opacity-0 translate-y-3' // 토스트 사라지는 애니메이션 (아래로 사라짐) + : mounted + ? 'opacity-100 translate-y-0' // 토스트 나타남 완료/유지 + : 'opacity-0 translate-y-3'; // mount 직후 프레임 + + return ( +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > +

{t.title}

+
{t.contents}
+
+ ); + })} +
, + document.body + ); +}; + +export default ToastViewport; diff --git a/src/pages/external/ExternalDetail.tsx b/src/pages/external/ExternalDetail.tsx index dee708d..81b9f2d 100644 --- a/src/pages/external/ExternalDetail.tsx +++ b/src/pages/external/ExternalDetail.tsx @@ -1,7 +1,7 @@ // ExternalDetail.tsx // 외부 상세페이지 -import { useState, useRef, useMemo, startTransition } from 'react'; +import { useState, useRef, useMemo, startTransition, useEffect } from 'react'; import DetailHeader from '../../components/DetailView/DetailHeader'; import PropertyItem from '../../components/DetailView/PropertyItem'; import DetailTitle from '../../components/DetailView/DetailTitle'; @@ -26,7 +26,7 @@ import CalendarDropdown from '../../components/Calendar/CalendarDropdown'; import { useDropdownActions, useDropdownInfo } from '../../hooks/useDropdown'; import { formatDateDot, formatDateHyphen } from '../../utils/formatDate'; import { useToggleMode } from '../../hooks/useToggleMode'; -import { useParams } from 'react-router-dom'; +import { useBlocker, useParams } from 'react-router-dom'; import { useGetExternalLinks } from '../../apis/external/useGetExternalLinks.ts'; import CommentInput from '../../components/DetailView/Comment/CommentInput'; @@ -65,6 +65,9 @@ import { getGithubInstallationId, useGetGithubInstallationId, } from '../../apis/external/useGetGithubInstallationId.ts'; +import { useToast } from '../../components/Toast/ToastProvider.tsx'; +import { useModalActions, useModalInfo } from '../../hooks/useModal.ts'; +import Modal from '../../components/Modal/Modal.tsx'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -117,12 +120,21 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { useIsMutating({ mutationKey: [mutationKey.EXTERNAL_CREATE, teamId] }) > 0; const isSaving = isCreating || isUpdating || isCreatingGlobal || isSubmittingRequestRef.current; - const { isOpen, content } = useDropdownInfo(); // 작성 완료 여부 (view 모드일 때 true) - const { openDropdown } = useDropdownActions(); // 수정 가능 여부 (create 또는 edit 모드일 때 true) + const { isOpen: isDropdownOpen, content: dropdownContent } = useDropdownInfo(); // 현재 드롭다운의 열림 여부와 내용 가져옴 + const { openDropdown } = useDropdownActions(); + const { openModal, closeModal } = useModalActions(); + const { isOpen: isModalOpen, content: modalContent } = useModalInfo(); + const confirmedRef = useRef(false); + const prevOpenRef = useRef(false); + const suppressLeaveConfirmRef = useRef(false); + + const { showToast } = useToast(); // 우측 하단 토스트 + const canChangeExternal = mode === 'create'; // 외부 항목 편집 가능 여부 (create 모드일 때만 가능) const isCompleted = mode === 'view'; // 작성 완료 여부 (view 모드일 때 true) const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) const canPatch = Number.isFinite(numericExternalId); // PATCH 가능 조건 + const blocker = useBlocker(isEditable); // 편집하고 있는 상황에 화면 이동을 블로킹 const repoObj = useMemo( () => (Array.isArray(githubRepo) ? githubRepo[0] : githubRepo), @@ -168,6 +180,48 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { }, [managersId, workspaceMembers]); const [managersShowNoneLabel] = useState(false); + useEffect(() => { + if (!isEditable) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [isEditable]); + + // 라우팅이 막히면 모달 오픈 + useEffect(() => { + if (blocker.state === 'blocked') { + if (suppressLeaveConfirmRef.current) { + // 내부 모드전환/내부 네비게이션: 모달 없이 바로 진행 + suppressLeaveConfirmRef.current = false; + blocker.proceed(); + return; + } + if (!isModalOpen) { + openModal({ name: 'leaveConfirm' }); + } + } + }, [blocker.state, isModalOpen, openModal]); + + // 모달이 닫힐 때: 확인 누른 게 아니면 reset() + useEffect(() => { + if (prevOpenRef.current && !isModalOpen) { + if (blocker.state === 'blocked' && !confirmedRef.current) { + blocker.reset(); + } + confirmedRef.current = false; + } + prevOpenRef.current = isModalOpen; + }, [isModalOpen, blocker.state]); + + // 다른 화면으로 나갈 때 남아있던 모달이 따라오지 않도록 정리 + useEffect(() => { + return () => { + closeModal(); + }; + }, [closeModal]); + // deadline('기한' 속성) patch 훅 const { handleSelectDateAndPatch, buildPatchForEditSubmit } = useExternalDeadlinePatch({ externalDetail, @@ -212,18 +266,31 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { console.log('Request body:', basePayload); if (mode === 'create') { - // 1) GitHub 선택 시 필수값 검증 + // 1) 외부 툴 선택이 안 되었을 때 + if (!extServiceType) { + isSubmittingRequestRef.current = false; + showToast({ + contents: '반드시 외부 툴을 설정해야 합니다.', + key: 'extRequired', // 중복 합치기 + }); + return; // 생성 중단 + } + + // 2) GitHub 선택 시 필수값 검증 if (extServiceType === 'GITHUB') { if (repoLoading || installLoading) { isSubmittingRequestRef.current = false; - alert('GitHub 정보를 불러오는 중입니다. 잠시만요!'); + showToast({ contents: 'GitHub 정보를 불러오는 중입니다.', key: 'githubLoading' }); return; } - // 2) 값이 준비되지 않았으면 중단 + // 3) 값이 준비되지 않았으면 중단 if (!isGithubReady) { isSubmittingRequestRef.current = false; console.error('GitHub 연동 누락:', githubPayload); - alert('GitHub 연동 정보가 부족합니다. 설치/온보딩을 먼저 완료해 주세요.'); + showToast({ + contents: 'GitHub 연동 정보가 부족합니다. 설치/온보딩을 먼저 완료해 주세요.', + key: 'githubMissing', + }); return; } const { owner, repo, installationId } = githubPayload; @@ -234,6 +301,11 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { repo, installationId, }); + showToast({ + contents: 'GitHub 연동 정보가 부족합니다. 설치/온보딩을 먼저 완료해 주세요.', + key: 'githubMissing', + }); + return; } } @@ -258,6 +330,7 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { onSuccess: ({ externalId }) => { queryClient.invalidateQueries({ queryKey: [queryKey.EXTERNAL_LIST, String(teamId)] }); queryClient.invalidateQueries({ queryKey: [queryKey.EXTERNAL_NAME, String(teamId)] }); + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode(externalId)); }, onSettled: () => { @@ -283,6 +356,7 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { queryKey: [queryKey.EXTERNAL_DETAIL, numericExternalId], }); } + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode()); }, onSettled: () => (isSubmittingRequestRef.current = false), @@ -290,12 +364,16 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { } }; - // handleCompletion - 하단 작성 완료<-수정하기 버튼 클릭 시 실행 - // - create/edit → view: API 저장 후 모드 전환 - // - view → edit: API 호출 없이 모드 전환 + // handleCompletion - 하단 작성 완료<->수정하기 버튼 클릭 시 실행 + // - create/edit -> view: API 저장 후 모드 전환 + // - view -> edit: API 호출 없이 모드 전환 const handleCompletion = () => { if (!isCompleted) { // create 또는 edit 모드에서 view 모드로 전환하려는 시점 + if (mode === 'create' && !extServiceType) { + showToast({ contents: '반드시 외부 툴을 설정해야 합니다.', key: 'extRequired' }); + return; + } handleSubmit(); // 저장 성공 시 모드 전환 } else { handleToggleMode(); // 모드 전환 @@ -530,7 +608,7 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { {/* '기한' 항목명 - 날짜 설정하면 반영됨 */} {getDisplayText()} {/* 달력 드롭다운 오픈 */} - {isOpen && content?.name === 'date' && ( + {isDropdownOpen && dropdownContent?.name === 'date' && ( { @@ -571,13 +649,14 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { {/* (6) 외부 */} -
e.stopPropagation()}> +
e.stopPropagation()} className="relative"> { + if (!canChangeExternal) return; // 가드 const code = LABEL_TO_EXTERNAL_CODE[label]; setExtServiceType(code ?? null); if (code === 'GITHUB') { @@ -592,6 +671,24 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { } }} /> + + {/* view / edit 모드에서는 클릭 완전 차단 */} + {!canChangeExternal && ( +
e.preventDefault()} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + showToast({ + contents: '외부 툴은 생성 시 한 번만 설정 가능합니다.', + key: 'extLocked', + }); + }} + role="button" + aria-label="외부 툴은 생성 시 한 번만 설정 가능합니다." + /> + )}
@@ -606,6 +703,29 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => {
+ + {isModalOpen && modalContent?.name === 'leaveConfirm' && blocker?.state === 'blocked' && ( + + 작성을 그만두시겠습니까? +
+ 작성중인 내용은 저장되지 않습니다. + + } + buttonText="확인" + buttonColor="bg-primary-blue" + onClick={() => { + confirmedRef.current = true; // 확인 눌렀음 표시 + closeModal(); + setTimeout(() => { + // 2) 다음 틱에 이동(레이스 방지) + blocker.proceed(); + }, 0); + }} + /> + )}
); }; diff --git a/src/pages/goal/GoalDetail.tsx b/src/pages/goal/GoalDetail.tsx index 3fa98c9..b95a114 100644 --- a/src/pages/goal/GoalDetail.tsx +++ b/src/pages/goal/GoalDetail.tsx @@ -1,7 +1,7 @@ // GoalDetail.tsx // 목표 상세페이지 -import { useState, useRef, startTransition } from 'react'; +import { useState, useRef, startTransition, useEffect } from 'react'; import DetailHeader from '../../components/DetailView/DetailHeader'; import PropertyItem from '../../components/DetailView/PropertyItem'; import DetailTitle from '../../components/DetailView/DetailTitle'; @@ -31,7 +31,7 @@ import { } from '../../components/DetailView/TextEditor/lexical-plugins/SubmitHandlePlugin'; import type { CreateGoalDetailDto, UpdateGoalDetailDto } from '../../types/goal'; import { useCreateGoal } from '../../apis/goal/usePostCreateGoalDetail'; -import { useParams } from 'react-router-dom'; +import { useBlocker, useParams } from 'react-router-dom'; import { useIsMutating } from '@tanstack/react-query'; import { mutationKey } from '../../constants/mutationKey'; import MultiSelectPropertyItem from '../../components/DetailView/MultiSelectPropertyItem'; @@ -51,6 +51,8 @@ import { useUpdateGoal } from '../../apis/goal/usePatchGoalDetail'; import { useGoalDeadlinePatch } from '../../hooks/useGoalDeadlinePatch'; import { queryKey } from '../../constants/queryKey'; import queryClient from '../../utils/queryClient'; +import { useModalActions, useModalInfo } from '../../hooks/useModal'; +import Modal from '../../components/Modal/Modal'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -88,12 +90,18 @@ const GoalDetail = ({ initialMode }: GoalDetailProps) => { const isCreatingGlobal = useIsMutating({ mutationKey: [mutationKey.GOAL_CREATE, teamId] }) > 0; const isSaving = isCreating || isUpdating || isCreatingGlobal || isSubmittingRequestRef.current; - const { isOpen, content } = useDropdownInfo(); // 현재 드롭다운의 열림 여부와 내용 가져옴 + const { isOpen: isDropdownOpen, content: dropdownContent } = useDropdownInfo(); // 현재 드롭다운의 열림 여부와 내용 가져옴 const { openDropdown } = useDropdownActions(); + const { openModal, closeModal } = useModalActions(); + const { isOpen: isModalOpen, content: modalContent } = useModalInfo(); + const confirmedRef = useRef(false); + const prevOpenRef = useRef(false); + const suppressLeaveConfirmRef = useRef(false); const isCompleted = mode === 'view'; // 작성 완료 여부 (view 모드일 때 true) const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) const canPatch = Number.isFinite(numericGoalId); // PATCH 가능 조건 + const blocker = useBlocker(isEditable); // 편집하고 있는 상황에 화면 이동을 블로킹 // 단일 선택 라벨 const selectedStatusLabel = STATUS_LABELS[state]; @@ -113,6 +121,48 @@ const GoalDetail = ({ initialMode }: GoalDetailProps) => { const [managersShowNoneLabel] = useState(false); const [issuesShowNoneLabel, setIssuesShowNoneLabel] = useState(false); + useEffect(() => { + if (!isEditable) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [isEditable]); + + // 라우팅이 막히면 모달 오픈 + useEffect(() => { + if (blocker.state === 'blocked') { + if (suppressLeaveConfirmRef.current) { + // 내부 모드전환/내부 네비게이션: 모달 없이 바로 진행 + suppressLeaveConfirmRef.current = false; + blocker.proceed(); + return; + } + if (!isModalOpen) { + openModal({ name: 'leaveConfirm' }); + } + } + }, [blocker.state, isModalOpen, openModal]); + + // 모달이 닫힐 때: 확인 누른 게 아니면 reset() + useEffect(() => { + if (prevOpenRef.current && !isModalOpen) { + if (blocker.state === 'blocked' && !confirmedRef.current) { + blocker.reset(); + } + confirmedRef.current = false; + } + prevOpenRef.current = isModalOpen; + }, [isModalOpen, blocker.state]); + + // 다른 화면으로 나갈 때 남아있던 모달이 따라오지 않도록 정리 + useEffect(() => { + return () => { + closeModal(); + }; + }, [closeModal]); + // deadline('기한' 속성) patch 훅 const { handleSelectDateAndPatch, buildPatchForEditSubmit } = useGoalDeadlinePatch({ goalDetail, @@ -167,6 +217,7 @@ const GoalDetail = ({ initialMode }: GoalDetailProps) => { onSuccess: ({ goalId }) => { queryClient.invalidateQueries({ queryKey: [queryKey.GOAL_LIST, String(teamId)] }); queryClient.invalidateQueries({ queryKey: [queryKey.GOAL_NAME, String(teamId)] }); + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode(goalId)); }, onSettled: () => { @@ -184,6 +235,7 @@ const GoalDetail = ({ initialMode }: GoalDetailProps) => { queryClient.invalidateQueries({ queryKey: [queryKey.GOAL_NAME, String(teamId)] }); queryClient.invalidateQueries({ queryKey: [queryKey.GOAL_DETAIL, numericGoalId] }); } + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode()); }, onSettled: () => { @@ -218,7 +270,7 @@ const GoalDetail = ({ initialMode }: GoalDetailProps) => { 우선순위: pr3, 없음: pr0, 낮음: pr1, - 보통: pr2, + 중간: pr2, 높음: pr3, 긴급: pr4, }; @@ -354,7 +406,7 @@ const GoalDetail = ({ initialMode }: GoalDetailProps) => {
e.stopPropagation()}> { const next = priorityLabelToCode[label] ?? 'NONE'; @@ -417,7 +469,7 @@ const GoalDetail = ({ initialMode }: GoalDetailProps) => { {/* '기한' 항목명 - 날짜 설정하면 반영됨 */} {getDisplayText()} {/* 달력 드롭다운 오픈 */} - {isOpen && content?.name === 'date' && ( + {isDropdownOpen && dropdownContent?.name === 'date' && ( { @@ -474,6 +526,29 @@ const GoalDetail = ({ initialMode }: GoalDetailProps) => {
+ + {isModalOpen && modalContent?.name === 'leaveConfirm' && blocker?.state === 'blocked' && ( + + 작성을 그만두시겠습니까? +
+ 작성중인 내용은 저장되지 않습니다. + + } + buttonText="확인" + buttonColor="bg-primary-blue" + onClick={() => { + confirmedRef.current = true; // 확인 눌렀음 표시 + closeModal(); + setTimeout(() => { + // 2) 다음 틱에 이동(레이스 방지) + blocker.proceed(); + }, 0); + }} + /> + )}
); }; diff --git a/src/pages/issue/IssueDetail.tsx b/src/pages/issue/IssueDetail.tsx index a8f16a4..877e3d7 100644 --- a/src/pages/issue/IssueDetail.tsx +++ b/src/pages/issue/IssueDetail.tsx @@ -1,7 +1,7 @@ // IssueDetail.tsx // 이슈 상세페이지 -import { useState, useRef, useMemo, startTransition } from 'react'; +import { useState, useRef, useMemo, startTransition, useEffect } from 'react'; import DetailHeader from '../../components/DetailView/DetailHeader'; import PropertyItem from '../../components/DetailView/PropertyItem'; import DetailTitle from '../../components/DetailView/DetailTitle'; @@ -35,7 +35,7 @@ import { } 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 { useBlocker, useParams } from 'react-router-dom'; import { PRIORITY_LABELS, STATUS_LABELS, @@ -52,6 +52,8 @@ import type { CreateIssueDetailDto, UpdateIssueDetailDto } from '../../types/iss import queryClient from '../../utils/queryClient.ts'; import { queryKey } from '../../constants/queryKey.ts'; import { useHydrateIssueDetail } from '../../hooks/useHydrateIssueDetail.ts'; +import { useModalActions, useModalInfo } from '../../hooks/useModal.ts'; +import Modal from '../../components/Modal/Modal.tsx'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -92,12 +94,18 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { const isCreatingGlobal = useIsMutating({ mutationKey: [mutationKey.ISSUE_CREATE, teamId] }) > 0; const isSaving = isCreating || isUpdating || isCreatingGlobal || isSubmittingRequestRef.current; - const { isOpen, content } = useDropdownInfo(); // 작성 완료 여부 (view 모드일 때 true) - const { openDropdown } = useDropdownActions(); // 수정 가능 여부 (create 또는 edit 모드일 때 true) + const { isOpen: isDropdownOpen, content: dropdownContent } = useDropdownInfo(); // 현재 드롭다운의 열림 여부와 내용 가져옴 + const { openDropdown } = useDropdownActions(); + const { openModal, closeModal } = useModalActions(); + const { isOpen: isModalOpen, content: modalContent } = useModalInfo(); + const confirmedRef = useRef(false); + const prevOpenRef = useRef(false); + const suppressLeaveConfirmRef = useRef(false); const isCompleted = mode === 'view'; // 작성 완료 여부 (view 모드일 때 true) const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) const canPatch = Number.isFinite(numericIssueId); // PATCH 가능 조건 + const blocker = useBlocker(isEditable); // 편집하고 있는 상황에 화면 이동을 블로킹 // 단일 선택 라벨 const selectedStatusLabel = STATUS_LABELS[state]; @@ -116,6 +124,48 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { }, [managersId, workspaceMembers]); const [managersShowNoneLabel] = useState(false); + useEffect(() => { + if (!isEditable) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [isEditable]); + + // 라우팅이 막히면 모달 오픈 + useEffect(() => { + if (blocker.state === 'blocked') { + if (suppressLeaveConfirmRef.current) { + // 내부 모드전환/내부 네비게이션: 모달 없이 바로 진행 + suppressLeaveConfirmRef.current = false; + blocker.proceed(); + return; + } + if (!isModalOpen) { + openModal({ name: 'leaveConfirm' }); + } + } + }, [blocker.state, isModalOpen, openModal]); + + // 모달이 닫힐 때: 확인 누른 게 아니면 reset() + useEffect(() => { + if (prevOpenRef.current && !isModalOpen) { + if (blocker.state === 'blocked' && !confirmedRef.current) { + blocker.reset(); + } + confirmedRef.current = false; + } + prevOpenRef.current = isModalOpen; + }, [isModalOpen, blocker.state]); + + // 다른 화면으로 나갈 때 남아있던 모달이 따라오지 않도록 정리 + useEffect(() => { + return () => { + closeModal(); + }; + }, [closeModal]); + // deadline('기한' 속성) patch 훅 const { handleSelectDateAndPatch, buildPatchForEditSubmit } = useIssueDeadlinePatch({ issueDetail, @@ -172,6 +222,7 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { onSuccess: ({ issueId }) => { queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_LIST, String(teamId)] }); queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_NAME, String(teamId)] }); + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode(issueId)); }, onSettled: () => { @@ -194,6 +245,7 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_NAME, String(teamId)] }); queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_DETAIL, numericIssueId] }); } + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode()); }, onSettled: () => { @@ -435,7 +487,7 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { {/* '기한' 항목명 - 날짜 설정하면 반영됨 */} {getDisplayText()} {/* 달력 드롭다운 오픈 */} - {isOpen && content?.name === 'date' && ( + {isDropdownOpen && dropdownContent?.name === 'date' && ( { @@ -487,6 +539,29 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => {
+ + {isModalOpen && modalContent?.name === 'leaveConfirm' && blocker?.state === 'blocked' && ( + + 작성을 그만두시겠습니까? +
+ 작성중인 내용은 저장되지 않습니다. + + } + buttonText="확인" + buttonColor="bg-primary-blue" + onClick={() => { + confirmedRef.current = true; // 확인 눌렀음 표시 + closeModal(); + setTimeout(() => { + // 2) 다음 틱에 이동(레이스 방지) + blocker.proceed(); + }, 0); + }} + /> + )}
); }; diff --git a/src/pages/workspace/WorkspaceExternalDetail.tsx b/src/pages/workspace/WorkspaceExternalDetail.tsx index 8832e73..e42e8b9 100644 --- a/src/pages/workspace/WorkspaceExternalDetail.tsx +++ b/src/pages/workspace/WorkspaceExternalDetail.tsx @@ -1,7 +1,7 @@ // WorkspaceExternalDetail.tsx // 워크스페이스 전체 팀 - 외부 상세페이지 -import { useState, useRef, useMemo, startTransition } from 'react'; +import { useState, useRef, useMemo, startTransition, useEffect } from 'react'; import WorkspaceDetailHeader from '../../components/DetailView/WorkspaceDetailHeader'; import PropertyItem from '../../components/DetailView/PropertyItem'; import DetailTitle from '../../components/DetailView/DetailTitle'; @@ -26,7 +26,7 @@ import CalendarDropdown from '../../components/Calendar/CalendarDropdown'; import { useDropdownActions, useDropdownInfo } from '../../hooks/useDropdown'; import { formatDateDot, formatDateHyphen } from '../../utils/formatDate'; import { useToggleMode } from '../../hooks/useToggleMode'; -import { useParams } from 'react-router-dom'; +import { useBlocker, useParams } from 'react-router-dom'; import { useGetExternalLinks } from '../../apis/external/useGetExternalLinks.ts'; import { usePostComment } from '../../apis/comment/usePostComment'; @@ -65,6 +65,9 @@ import type { CreateExternalDetailDto, UpdateExternalDetailDto } from '../../typ import queryClient from '../../utils/queryClient.ts'; import { queryKey } from '../../constants/queryKey.ts'; import { useHydrateExternalDetail } from '../../hooks/useHydrateExternalDetail.ts'; +import { useModalActions, useModalInfo } from '../../hooks/useModal.ts'; +import { useToast } from '../../components/Toast/ToastProvider.tsx'; +import Modal from '../../components/Modal/Modal.tsx'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -117,12 +120,21 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) useIsMutating({ mutationKey: [mutationKey.EXTERNAL_CREATE, teamId] }) > 0; const isSaving = isCreating || isUpdating || isCreatingGlobal || isSubmittingRequestRef.current; - const { isOpen, content } = useDropdownInfo(); // 작성 완료 여부 (view 모드일 때 true) - const { openDropdown } = useDropdownActions(); // 수정 가능 여부 (create 또는 edit 모드일 때 true) + const { isOpen: isDropdownOpen, content: dropdownContent } = useDropdownInfo(); // 현재 드롭다운의 열림 여부와 내용 가져옴 + const { openDropdown } = useDropdownActions(); + const { openModal, closeModal } = useModalActions(); + const { isOpen: isModalOpen, content: modalContent } = useModalInfo(); + const confirmedRef = useRef(false); + const prevOpenRef = useRef(false); + const suppressLeaveConfirmRef = useRef(false); + + const { showToast } = useToast(); // 우측 하단 토스트 + const canChangeExternal = mode === 'create'; // 외부 항목 편집 가능 여부 (create 모드일 때만 가능) const isCompleted = mode === 'view'; // 작성 완료 여부 (view 모드일 때 true) const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) const canPatch = Number.isFinite(numericExternalId); // PATCH 가능 조건 + const blocker = useBlocker(isEditable); // 편집하고 있는 상황에 화면 이동을 블로킹 const repoObj = useMemo( () => (Array.isArray(githubRepo) ? githubRepo[0] : githubRepo), @@ -168,6 +180,48 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) }, [managersId, workspaceMembers]); const [managersShowNoneLabel] = useState(false); + useEffect(() => { + if (!isEditable) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [isEditable]); + + // 라우팅이 막히면 모달 오픈 + useEffect(() => { + if (blocker.state === 'blocked') { + if (suppressLeaveConfirmRef.current) { + // 내부 모드전환/내부 네비게이션: 모달 없이 바로 진행 + suppressLeaveConfirmRef.current = false; + blocker.proceed(); + return; + } + if (!isModalOpen) { + openModal({ name: 'leaveConfirm' }); + } + } + }, [blocker.state, isModalOpen, openModal]); + + // 모달이 닫힐 때: 확인 누른 게 아니면 reset() + useEffect(() => { + if (prevOpenRef.current && !isModalOpen) { + if (blocker.state === 'blocked' && !confirmedRef.current) { + blocker.reset(); + } + confirmedRef.current = false; + } + prevOpenRef.current = isModalOpen; + }, [isModalOpen, blocker.state]); + + // 다른 화면으로 나갈 때 남아있던 모달이 따라오지 않도록 정리 + useEffect(() => { + return () => { + closeModal(); + }; + }, [closeModal]); + // deadline('기한' 속성) patch 훅 const { handleSelectDateAndPatch, buildPatchForEditSubmit } = useExternalDeadlinePatch({ externalDetail, @@ -212,18 +266,32 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) console.log('Request body:', basePayload); if (mode === 'create') { - // 1) GitHub 선택 시 필수값 검증 + // 1) 외부 툴 선택이 안 되었을 때 + if (!extServiceType) { + isSubmittingRequestRef.current = false; + showToast({ + contents: '반드시 외부 툴을 설정해야 합니다.', + key: 'extRequired', // 중복 합치기 + }); + return; // 생성 중단 + } + + // 2) GitHub 선택 시 필수값 검증 if (extServiceType === 'GITHUB') { if (repoLoading || installLoading) { isSubmittingRequestRef.current = false; - alert('GitHub 정보를 불러오는 중입니다. 잠시만요!'); + showToast({ contents: 'GitHub 정보를 불러오는 중입니다.', key: 'githubLoading' }); return; } - // 2) 값이 준비되지 않았으면 중단 + + // 3) 값이 준비되지 않았으면 중단 if (!isGithubReady) { isSubmittingRequestRef.current = false; console.error('GitHub 연동 누락:', githubPayload); - alert('GitHub 연동 정보가 부족합니다. 설치/온보딩을 먼저 완료해 주세요.'); + showToast({ + contents: 'GitHub 연동 정보가 부족합니다. 설치/온보딩을 먼저 완료해 주세요.', + key: 'githubMissing', + }); return; } const { owner, repo, installationId } = githubPayload; @@ -234,6 +302,11 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) repo, installationId, }); + showToast({ + contents: 'GitHub 연동 정보가 부족합니다. 설치/온보딩을 먼저 완료해 주세요.', + key: 'githubMissing', + }); + return; } } @@ -258,6 +331,7 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) onSuccess: ({ externalId }) => { queryClient.invalidateQueries({ queryKey: [queryKey.EXTERNAL_LIST, String(teamId)] }); queryClient.invalidateQueries({ queryKey: [queryKey.EXTERNAL_NAME, String(teamId)] }); + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode(externalId)); }, onSettled: () => { @@ -283,6 +357,7 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) queryKey: [queryKey.EXTERNAL_DETAIL, numericExternalId], }); } + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode()); }, onSettled: () => (isSubmittingRequestRef.current = false), @@ -296,6 +371,10 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) const handleCompletion = () => { if (!isCompleted) { // create 또는 edit 모드에서 view 모드로 전환하려는 시점 + if (mode === 'create' && !extServiceType) { + showToast({ contents: '반드시 외부 툴을 설정해야 합니다.', key: 'extRequired' }); + return; + } handleSubmit(); // 저장 성공 시 모드 전환 } else { handleToggleMode(); // 모드 전환 @@ -530,7 +609,7 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) {/* '기한' 항목명 - 날짜 설정하면 반영됨 */} {getDisplayText()} {/* 달력 드롭다운 오픈 */} - {isOpen && content?.name === 'date' && ( + {isDropdownOpen && dropdownContent?.name === 'date' && ( { @@ -571,13 +650,14 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) {/* (6) 외부 */} -
e.stopPropagation()}> +
e.stopPropagation()} className="relative"> { + if (!canChangeExternal) return; // 가드 const code = LABEL_TO_EXTERNAL_CODE[label]; setExtServiceType(code ?? null); if (code === 'GITHUB') { @@ -592,6 +672,24 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) } }} /> + + {/* view / edit 모드에서는 클릭 완전 차단 */} + {!canChangeExternal && ( +
e.preventDefault()} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + showToast({ + contents: '외부 툴은 생성 시 한 번만 설정 가능합니다.', + key: 'extLocked', + }); + }} + role="button" + aria-label="외부 툴은 생성 시 한 번만 설정 가능합니다." + /> + )}
@@ -606,6 +704,29 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps)
+ + {isModalOpen && modalContent?.name === 'leaveConfirm' && blocker?.state === 'blocked' && ( + + 작성을 그만두시겠습니까? +
+ 작성중인 내용은 저장되지 않습니다. + + } + buttonText="확인" + buttonColor="bg-primary-blue" + onClick={() => { + confirmedRef.current = true; // 확인 눌렀음 표시 + closeModal(); + setTimeout(() => { + // 2) 다음 틱에 이동(레이스 방지) + blocker.proceed(); + }, 0); + }} + /> + )}
); }; diff --git a/src/pages/workspace/WorkspaceGoalDetail.tsx b/src/pages/workspace/WorkspaceGoalDetail.tsx index a32382e..c0a8e42 100644 --- a/src/pages/workspace/WorkspaceGoalDetail.tsx +++ b/src/pages/workspace/WorkspaceGoalDetail.tsx @@ -1,7 +1,7 @@ // WorkspaceGoalDetail.tsx // 워크스페이스 전체 팀 - 목표 상세페이지 -import { useState, useRef, useMemo, startTransition } from 'react'; +import { useState, useRef, useMemo, startTransition, useEffect } from 'react'; import WorkspaceDetailHeader from '../../components/DetailView/WorkspaceDetailHeader'; import PropertyItem from '../../components/DetailView/PropertyItem'; import DetailTitle from '../../components/DetailView/DetailTitle'; @@ -38,7 +38,7 @@ import { EMPTY_EDITOR_STATE, type SubmitHandleRef, } from '../../components/DetailView/TextEditor/lexical-plugins/SubmitHandlePlugin'; -import { useParams } from 'react-router-dom'; +import { useBlocker, useParams } from 'react-router-dom'; import { useGetWorkspaceMembers } from '../../apis/setting/useGetWorkspaceMembers'; import { useGetSimpleIssueList } from '../../apis/issue/useGetSimpleIssueList'; import { useCreateGoal } from '../../apis/goal/usePostCreateGoalDetail'; @@ -51,6 +51,8 @@ import { useUpdateGoal } from '../../apis/goal/usePatchGoalDetail'; import { useGoalDeadlinePatch } from '../../hooks/useGoalDeadlinePatch'; import queryClient from '../../utils/queryClient'; import { queryKey } from '../../constants/queryKey'; +import { useModalActions, useModalInfo } from '../../hooks/useModal'; +import Modal from '../../components/Modal/Modal'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -88,12 +90,18 @@ const WorkspaceGoalDetail = ({ initialMode }: WorkspaceGoalDetailProps) => { const isCreatingGlobal = useIsMutating({ mutationKey: [mutationKey.GOAL_CREATE, teamId] }) > 0; const isSaving = isCreating || isUpdating || isCreatingGlobal || isSubmittingRequestRef.current; - const { isOpen, content } = useDropdownInfo(); // 현재 드롭다운의 열림 여부와 내용 가져옴 + const { isOpen: isDropdownOpen, content: dropdownContent } = useDropdownInfo(); // 현재 드롭다운의 열림 여부와 내용 가져옴 const { openDropdown } = useDropdownActions(); + const { openModal, closeModal } = useModalActions(); + const { isOpen: isModalOpen, content: modalContent } = useModalInfo(); + const confirmedRef = useRef(false); + const prevOpenRef = useRef(false); + const suppressLeaveConfirmRef = useRef(false); const isCompleted = mode === 'view'; // 작성 완료 여부 (view 모드일 때 true) const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) const canPatch = Number.isFinite(numericGoalId); // PATCH 가능 조건 + const blocker = useBlocker(isEditable); // 편집하고 있는 상황에 화면 이동을 블로킹 // 단일 선택 라벨 const selectedStatusLabel = STATUS_LABELS[state]; @@ -113,6 +121,48 @@ const WorkspaceGoalDetail = ({ initialMode }: WorkspaceGoalDetailProps) => { const [managersShowNoneLabel] = useState(false); const [issuesShowNoneLabel, setIssuesShowNoneLabel] = useState(false); + useEffect(() => { + if (!isEditable) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [isEditable]); + + // 라우팅이 막히면 모달 오픈 + useEffect(() => { + if (blocker.state === 'blocked') { + if (suppressLeaveConfirmRef.current) { + // 내부 모드전환/내부 네비게이션: 모달 없이 바로 진행 + suppressLeaveConfirmRef.current = false; + blocker.proceed(); + return; + } + if (!isModalOpen) { + openModal({ name: 'leaveConfirm' }); + } + } + }, [blocker.state, isModalOpen, openModal]); + + // 모달이 닫힐 때: 확인 누른 게 아니면 reset() + useEffect(() => { + if (prevOpenRef.current && !isModalOpen) { + if (blocker.state === 'blocked' && !confirmedRef.current) { + blocker.reset(); + } + confirmedRef.current = false; + } + prevOpenRef.current = isModalOpen; + }, [isModalOpen, blocker.state]); + + // 다른 화면으로 나갈 때 남아있던 모달이 따라오지 않도록 정리 + useEffect(() => { + return () => { + closeModal(); + }; + }, [closeModal]); + // deadline('기한' 속성) patch 훅 const { handleSelectDateAndPatch, buildPatchForEditSubmit } = useGoalDeadlinePatch({ goalDetail, @@ -167,6 +217,7 @@ const WorkspaceGoalDetail = ({ initialMode }: WorkspaceGoalDetailProps) => { onSuccess: ({ goalId }) => { queryClient.invalidateQueries({ queryKey: [queryKey.GOAL_LIST, String(teamId)] }); queryClient.invalidateQueries({ queryKey: [queryKey.GOAL_NAME, String(teamId)] }); + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode(goalId)); }, onSettled: () => { @@ -184,6 +235,7 @@ const WorkspaceGoalDetail = ({ initialMode }: WorkspaceGoalDetailProps) => { queryClient.invalidateQueries({ queryKey: [queryKey.GOAL_NAME, String(teamId)] }); queryClient.invalidateQueries({ queryKey: [queryKey.GOAL_DETAIL, numericGoalId] }); } + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode()); }, onSettled: () => { @@ -423,7 +475,7 @@ const WorkspaceGoalDetail = ({ initialMode }: WorkspaceGoalDetailProps) => { {/* '기한' 항목명 - 날짜 설정하면 반영됨 */} {getDisplayText()} {/* 달력 드롭다운 오픈 */} - {isOpen && content?.name === 'date' && ( + {isDropdownOpen && dropdownContent?.name === 'date' && ( { @@ -443,6 +495,7 @@ const WorkspaceGoalDetail = ({ initialMode }: WorkspaceGoalDetailProps) => { onChange={(labels) => { if (labels.includes('없음')) { setIssuesId([]); + setIssuesShowNoneLabel(true); if (isCompleted && Number.isFinite(numericGoalId)) { updateGoal({ issuesId: [] }); } @@ -479,6 +532,29 @@ const WorkspaceGoalDetail = ({ initialMode }: WorkspaceGoalDetailProps) => {
+ + {isModalOpen && modalContent?.name === 'leaveConfirm' && blocker?.state === 'blocked' && ( + + 작성을 그만두시겠습니까? +
+ 작성중인 내용은 저장되지 않습니다. + + } + buttonText="확인" + buttonColor="bg-primary-blue" + onClick={() => { + confirmedRef.current = true; // 확인 눌렀음 표시 + closeModal(); + setTimeout(() => { + // 2) 다음 틱에 이동(레이스 방지) + blocker.proceed(); + }, 0); + }} + /> + )}
); }; diff --git a/src/pages/workspace/WorkspaceIssueDetail.tsx b/src/pages/workspace/WorkspaceIssueDetail.tsx index a3ba82b..9be35d6 100644 --- a/src/pages/workspace/WorkspaceIssueDetail.tsx +++ b/src/pages/workspace/WorkspaceIssueDetail.tsx @@ -1,7 +1,7 @@ // WorkspaceIssueDetail.tsx // 워크스페이스 전체 팀 - 이슈 상세페이지 -import { useState, useRef, useMemo, startTransition } from 'react'; +import { useState, useRef, useMemo, startTransition, useEffect } from 'react'; import WorkspaceDetailHeader from '../../components/DetailView/WorkspaceDetailHeader'; import PropertyItem from '../../components/DetailView/PropertyItem'; import DetailTitle from '../../components/DetailView/DetailTitle'; @@ -33,7 +33,7 @@ import { EMPTY_EDITOR_STATE, type SubmitHandleRef, } from '../../components/DetailView/TextEditor/lexical-plugins/SubmitHandlePlugin'; -import { useParams } from 'react-router-dom'; +import { useBlocker, useParams } from 'react-router-dom'; import { mutationKey } from '../../constants/mutationKey'; import { useIsMutating } from '@tanstack/react-query'; import { @@ -52,6 +52,8 @@ import type { CreateIssueDetailDto, UpdateIssueDetailDto } from '../../types/iss import { queryKey } from '../../constants/queryKey.ts'; import queryClient from '../../utils/queryClient.ts'; import { useHydrateIssueDetail } from '../../hooks/useHydrateIssueDetail.ts'; +import { useModalActions, useModalInfo } from '../../hooks/useModal.ts'; +import Modal from '../../components/Modal/Modal.tsx'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -92,12 +94,18 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => { const isCreatingGlobal = useIsMutating({ mutationKey: [mutationKey.ISSUE_CREATE, teamId] }) > 0; const isSaving = isCreating || isUpdating || isCreatingGlobal || isSubmittingRequestRef.current; - const { isOpen, content } = useDropdownInfo(); // 작성 완료 여부 (view 모드일 때 true) - const { openDropdown } = useDropdownActions(); // 수정 가능 여부 (create 또는 edit 모드일 때 true) + const { isOpen: isDropdownOpen, content: dropdownContent } = useDropdownInfo(); // 현재 드롭다운의 열림 여부와 내용 가져옴 + const { openDropdown } = useDropdownActions(); + const { openModal, closeModal } = useModalActions(); + const { isOpen: isModalOpen, content: modalContent } = useModalInfo(); + const confirmedRef = useRef(false); + const prevOpenRef = useRef(false); + const suppressLeaveConfirmRef = useRef(false); const isCompleted = mode === 'view'; // 작성 완료 여부 (view 모드일 때 true) const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) const canPatch = Number.isFinite(numericIssueId); // PATCH 가능 조건 + const blocker = useBlocker(isEditable); // 편집하고 있는 상황에 화면 이동을 블로킹 // 단일 선택 라벨 const selectedStatusLabel = STATUS_LABELS[state]; @@ -116,6 +124,48 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => { }, [managersId, workspaceMembers]); const [managersShowNoneLabel] = useState(false); + useEffect(() => { + if (!isEditable) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [isEditable]); + + // 라우팅이 막히면 모달 오픈 + useEffect(() => { + if (blocker.state === 'blocked') { + if (suppressLeaveConfirmRef.current) { + // 내부 모드전환/내부 네비게이션: 모달 없이 바로 진행 + suppressLeaveConfirmRef.current = false; + blocker.proceed(); + return; + } + if (!isModalOpen) { + openModal({ name: 'leaveConfirm' }); + } + } + }, [blocker.state, isModalOpen, openModal]); + + // 모달이 닫힐 때: 확인 누른 게 아니면 reset() + useEffect(() => { + if (prevOpenRef.current && !isModalOpen) { + if (blocker.state === 'blocked' && !confirmedRef.current) { + blocker.reset(); + } + confirmedRef.current = false; + } + prevOpenRef.current = isModalOpen; + }, [isModalOpen, blocker.state]); + + // 다른 화면으로 나갈 때 남아있던 모달이 따라오지 않도록 정리 + useEffect(() => { + return () => { + closeModal(); + }; + }, [closeModal]); + // deadline('기한' 속성) patch 훅 const { handleSelectDateAndPatch, buildPatchForEditSubmit } = useIssueDeadlinePatch({ issueDetail, @@ -172,6 +222,7 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => { onSuccess: ({ issueId }) => { queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_LIST, String(teamId)] }); queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_NAME, String(teamId)] }); + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode(issueId)); }, onSettled: () => { @@ -194,6 +245,7 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => { queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_NAME, String(teamId)] }); queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_DETAIL, numericIssueId] }); } + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode()); }, onSettled: () => { @@ -435,7 +487,7 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => { {/* '기한' 항목명 - 날짜 설정하면 반영됨 */} {getDisplayText()} {/* 달력 드롭다운 오픈 */} - {isOpen && content?.name === 'date' && ( + {isDropdownOpen && dropdownContent?.name === 'date' && ( { @@ -487,6 +539,29 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => {
+ + {isModalOpen && modalContent?.name === 'leaveConfirm' && blocker?.state === 'blocked' && ( + + 작성을 그만두시겠습니까? +
+ 작성중인 내용은 저장되지 않습니다. + + } + buttonText="확인" + buttonColor="bg-primary-blue" + onClick={() => { + confirmedRef.current = true; // 확인 눌렀음 표시 + closeModal(); + setTimeout(() => { + // 2) 다음 틱에 이동(레이스 방지) + blocker.proceed(); + }, 0); + }} + /> + )}
); };