fix: detail panel not opening after task selection

Open detail window synchronously to avoid popup blocking, persist selected task, and show API errors on the detail page.
This commit is contained in:
EENE Dashboard
2026-06-05 22:21:10 +09:00
parent 49fe4ca4a9
commit d53f82b044
2 changed files with 81 additions and 12 deletions

View File

@@ -5,6 +5,7 @@
const CHANNEL_NAME = 'eee_dashboard'; const CHANNEL_NAME = 'eee_dashboard';
const DETAIL_WINDOW_NAME = 'eene_detail'; const DETAIL_WINDOW_NAME = 'eene_detail';
const SELECTED_TASK_KEY = 'eee_selected_task';
type DualMonitorEvent = type DualMonitorEvent =
| { type: 'TASK_SELECTED'; taskId: string } | { type: 'TASK_SELECTED'; taskId: string }
@@ -83,6 +84,38 @@ export function isDetailWindowOpen(): boolean {
return !!detailWindow && !detailWindow.closed; 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<string, number> = {};
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<Window | null> { export async function openDetailWindow(): Promise<Window | null> {
if (isDetailWindowOpen()) { if (isDetailWindowOpen()) {
@@ -92,33 +125,55 @@ export async function openDetailWindow(): Promise<Window | null> {
} }
const detailUrl = `${window.location.origin}/detail`; 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 { try {
detailWindow?.focus(); detailWindow.focus();
} catch { } catch {
// popup-blocked 등 // 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; return detailWindow;
} }
/** 좌측 → 우측: 업무 선택 이벤트 전송 (창이 닫혀 있으면 열고 전송) */ /** 좌측 → 우측: 업무 선택 이벤트 전송 (창이 닫혀 있으면 열고 전송) */
export async function sendTaskSelected(taskId: string): Promise<void> { export async function sendTaskSelected(taskId: string): Promise<void> {
persistSelectedTask(taskId);
if (!isDetailWindowOpen()) { if (!isDetailWindowOpen()) {
await openDetailWindow(); const win = await openDetailWindow();
// 창이 로드될 때까지 잠시 대기 후 전송 if (!win) return;
setTimeout(() => { postTaskSelected(taskId);
getChannel().postMessage({ type: 'TASK_SELECTED', taskId } satisfies DualMonitorEvent); setTimeout(() => postTaskSelected(taskId), 500);
}, 800); setTimeout(() => postTaskSelected(taskId), 1500);
return; return;
} }
detailWindow!.focus(); detailWindow!.focus();
getChannel().postMessage({ type: 'TASK_SELECTED', taskId } satisfies DualMonitorEvent); postTaskSelected(taskId);
} }
/** 좌측 → 우측: 업무 선택 해제 */ /** 좌측 → 우측: 업무 선택 해제 */
export function sendTaskDeselected(): void { export function sendTaskDeselected(): void {
persistSelectedTask(null);
getChannel().postMessage({ type: 'TASK_DESELECTED' } satisfies DualMonitorEvent); getChannel().postMessage({ type: 'TASK_DESELECTED' } satisfies DualMonitorEvent);
} }

View File

@@ -1,7 +1,8 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient, getApiErrorMessage } from '../lib/apiClient'; import { apiClient, getApiErrorMessage } from '../lib/apiClient';
import { onDualMonitorEvent } from '../lib/dualMonitor'; import { onDualMonitorEvent, getPersistedTaskId } from '../lib/dualMonitor';
import { ContextMenu } from '../components/common/ContextMenu'; import { ContextMenu } from '../components/common/ContextMenu';
import { FeedbackModal, type FeedbackFormData } from '../components/detail/FeedbackModal'; import { FeedbackModal, type FeedbackFormData } from '../components/detail/FeedbackModal';
import { ResultPreview } from '../components/detail/ResultPreview'; import { ResultPreview } from '../components/detail/ResultPreview';
@@ -701,7 +702,14 @@ function DetailView({ task }: { task: TaskWithRelations }) {
} }
export default function DetailPage() { export default function DetailPage() {
const [taskId, setTaskId] = useState<string | null>(null); const { taskId: routeTaskId } = useParams<{ taskId?: string }>();
const [taskId, setTaskId] = useState<string | null>(
() => routeTaskId ?? getPersistedTaskId(),
);
useEffect(() => {
if (routeTaskId) setTaskId(routeTaskId);
}, [routeTaskId]);
useEffect(() => { useEffect(() => {
const unsub = onDualMonitorEvent((evt) => { const unsub = onDualMonitorEvent((evt) => {
@@ -711,7 +719,7 @@ export default function DetailPage() {
return unsub; return unsub;
}, []); }, []);
const { data: task, isLoading } = useQuery({ const { data: task, isLoading, isError, error } = useQuery({
queryKey: ['task', taskId], queryKey: ['task', taskId],
queryFn: async () => { queryFn: async () => {
const { data } = await apiClient.get<TaskWithRelations>(`/tasks/${taskId}`); const { data } = await apiClient.get<TaskWithRelations>(`/tasks/${taskId}`);
@@ -719,6 +727,7 @@ export default function DetailPage() {
}, },
enabled: !!taskId, enabled: !!taskId,
staleTime: 10_000, staleTime: 10_000,
retry: 2,
}); });
return ( return (
@@ -728,6 +737,11 @@ export default function DetailPage() {
<div className="flex min-h-0 flex-1 flex-col overflow-hidden"> <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{isLoading ? ( {isLoading ? (
<div className="flex h-full items-center justify-center text-xl text-slate-400"> ...</div> <div className="flex h-full items-center justify-center text-xl text-slate-400"> ...</div>
) : isError ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
<p className="text-xl font-bold text-red-500"> .</p>
<p className="text-base text-slate-500">{getApiErrorMessage(error, '서버 연결을 확인해 주세요.')}</p>
</div>
) : !task ? ( ) : !task ? (
<WaitingScreen /> <WaitingScreen />
) : ( ) : (