diff --git a/frontend/src/lib/dualMonitor.ts b/frontend/src/lib/dualMonitor.ts index 5a4ffc7..e4730bb 100644 --- a/frontend/src/lib/dualMonitor.ts +++ b/frontend/src/lib/dualMonitor.ts @@ -5,6 +5,7 @@ const CHANNEL_NAME = 'eee_dashboard'; const DETAIL_WINDOW_NAME = 'eene_detail'; +const SELECTED_TASK_KEY = 'eee_selected_task'; type DualMonitorEvent = | { type: 'TASK_SELECTED'; taskId: string } @@ -83,6 +84,38 @@ export function isDetailWindowOpen(): boolean { return !!detailWindow && !detailWindow.closed; } +function persistSelectedTask(taskId: string | null) { + if (taskId) sessionStorage.setItem(SELECTED_TASK_KEY, taskId); + else sessionStorage.removeItem(SELECTED_TASK_KEY); +} + +/** 상세 페이지 초기 로드용 (BroadcastChannel 유실 대비) */ +export function getPersistedTaskId(): string | null { + return sessionStorage.getItem(SELECTED_TASK_KEY); +} + +function parseWindowFeatures(features: string) { + const out: Record = {}; + for (const part of features.split(',')) { + const [key, value] = part.split('='); + if (key && value != null) out[key.trim()] = Number(value); + } + return out; +} + +function applyWindowPlacement(win: Window, left: number, top: number, width: number, height: number) { + try { + win.moveTo(left, top); + win.resizeTo(width, height); + } catch { + // 브라우저 정책으로 실패할 수 있음 + } +} + +function postTaskSelected(taskId: string) { + getChannel().postMessage({ type: 'TASK_SELECTED', taskId } satisfies DualMonitorEvent); +} + /** 상세 창 토글 — 열려 있으면 닫고, 닫혀 있으면 오른쪽 모니터에 열기 */ export async function openDetailWindow(): Promise { if (isDetailWindowOpen()) { @@ -92,33 +125,55 @@ export async function openDetailWindow(): Promise { } const detailUrl = `${window.location.origin}/detail`; - const features = await getRightMonitorWindowFeatures(); + // 사용자 클릭 직후 동기적으로 열어야 팝업 차단을 피할 수 있음 + detailWindow = window.open(detailUrl, DETAIL_WINDOW_NAME, 'noopener,noreferrer,width=1280,height=900'); + if (!detailWindow) { + alert('팝업이 차단되었습니다. 브라우저에서 이 사이트의 팝업을 허용해 주세요.'); + return null; + } - detailWindow = window.open(detailUrl, DETAIL_WINDOW_NAME, features); try { - detailWindow?.focus(); + detailWindow.focus(); } catch { // popup-blocked 등 } + + void getRightMonitorWindowFeatures().then((features) => { + const { left, top, width, height } = parseWindowFeatures(features); + if (detailWindow && !detailWindow.closed && left != null && top != null && width && height) { + applyWindowPlacement(detailWindow, left, top, width, height); + } + }); + + const savedTaskId = getPersistedTaskId(); + if (savedTaskId) { + postTaskSelected(savedTaskId); + setTimeout(() => postTaskSelected(savedTaskId), 500); + } + return detailWindow; } /** 좌측 → 우측: 업무 선택 이벤트 전송 (창이 닫혀 있으면 열고 전송) */ export async function sendTaskSelected(taskId: string): Promise { + persistSelectedTask(taskId); + if (!isDetailWindowOpen()) { - await openDetailWindow(); - // 창이 로드될 때까지 잠시 대기 후 전송 - setTimeout(() => { - getChannel().postMessage({ type: 'TASK_SELECTED', taskId } satisfies DualMonitorEvent); - }, 800); + const win = await openDetailWindow(); + if (!win) return; + postTaskSelected(taskId); + setTimeout(() => postTaskSelected(taskId), 500); + setTimeout(() => postTaskSelected(taskId), 1500); return; } + detailWindow!.focus(); - getChannel().postMessage({ type: 'TASK_SELECTED', taskId } satisfies DualMonitorEvent); + postTaskSelected(taskId); } /** 좌측 → 우측: 업무 선택 해제 */ export function sendTaskDeselected(): void { + persistSelectedTask(null); getChannel().postMessage({ type: 'TASK_DESELECTED' } satisfies DualMonitorEvent); } diff --git a/frontend/src/pages/DetailPage.tsx b/frontend/src/pages/DetailPage.tsx index 63b450f..c8c6cce 100644 --- a/frontend/src/pages/DetailPage.tsx +++ b/frontend/src/pages/DetailPage.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useMemo } from 'react'; +import { useParams } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { apiClient, getApiErrorMessage } from '../lib/apiClient'; -import { onDualMonitorEvent } from '../lib/dualMonitor'; +import { onDualMonitorEvent, getPersistedTaskId } from '../lib/dualMonitor'; import { ContextMenu } from '../components/common/ContextMenu'; import { FeedbackModal, type FeedbackFormData } from '../components/detail/FeedbackModal'; import { ResultPreview } from '../components/detail/ResultPreview'; @@ -701,7 +702,14 @@ function DetailView({ task }: { task: TaskWithRelations }) { } export default function DetailPage() { - const [taskId, setTaskId] = useState(null); + const { taskId: routeTaskId } = useParams<{ taskId?: string }>(); + const [taskId, setTaskId] = useState( + () => routeTaskId ?? getPersistedTaskId(), + ); + + useEffect(() => { + if (routeTaskId) setTaskId(routeTaskId); + }, [routeTaskId]); useEffect(() => { const unsub = onDualMonitorEvent((evt) => { @@ -711,7 +719,7 @@ export default function DetailPage() { return unsub; }, []); - const { data: task, isLoading } = useQuery({ + const { data: task, isLoading, isError, error } = useQuery({ queryKey: ['task', taskId], queryFn: async () => { const { data } = await apiClient.get(`/tasks/${taskId}`); @@ -719,6 +727,7 @@ export default function DetailPage() { }, enabled: !!taskId, staleTime: 10_000, + retry: 2, }); return ( @@ -728,6 +737,11 @@ export default function DetailPage() {
{isLoading ? (
불러오는 중...
+ ) : isError ? ( +
+

상세 정보를 불러오지 못했습니다.

+

{getApiErrorMessage(error, '서버 연결을 확인해 주세요.')}

+
) : !task ? ( ) : (