+ {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) => {
{/* '기한' 항목명 - 날짜 설정하면 반영됨 */}