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 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user