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:
@@ -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<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> {
|
||||
if (isDetailWindowOpen()) {
|
||||
@@ -92,33 +125,55 @@ export async function openDetailWindow(): Promise<Window | null> {
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const { taskId: routeTaskId } = useParams<{ taskId?: string }>();
|
||||
const [taskId, setTaskId] = useState<string | null>(
|
||||
() => 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<TaskWithRelations>(`/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() {
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{isLoading ? (
|
||||
<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 ? (
|
||||
<WaitingScreen />
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user