diff --git a/backend/package.json b/backend/package.json index 9f05cdb..4e86608 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,7 +15,8 @@ "db:import-hr": "tsx scripts/import-hr-data.ts", "db:sync-remote": "tsx scripts/sync-from-remote.ts", "db:push-remote": "tsx scripts/sync-to-remote.ts", - "db:push-photos": "tsx scripts/sync-to-remote.ts --photos-only" + "db:push-photos": "tsx scripts/sync-to-remote.ts --photos-only", + "db:migrate-sections": "tsx scripts/migrate-sections.ts" }, "dependencies": { "@prisma/client": "^6.0.0", diff --git a/backend/prisma/mapHrProjects.ts b/backend/prisma/mapHrProjects.ts index def3639..2a8023a 100644 --- a/backend/prisma/mapHrProjects.ts +++ b/backend/prisma/mapHrProjects.ts @@ -59,8 +59,7 @@ export interface MappedTask { const SECTION_MAP: Record = { 인사관리: '인사관리', 성장지원: '학습성장', - 운영지원: '운영지원', - 전산관리: '전산관리', + 운영관리: '운영관리', }; const STATUS_MAP: Record = { diff --git a/backend/scripts/migrate-sections.ts b/backend/scripts/migrate-sections.ts new file mode 100644 index 0000000..2ed6ebb --- /dev/null +++ b/backend/scripts/migrate-sections.ts @@ -0,0 +1,66 @@ +/** + * 전산관리·운영지원 → 운영관리 부문 통합 (1회 실행) + * 사용: npm run db:migrate-sections + */ +import 'dotenv/config'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +const MERGE_INTO = '운영관리'; +const FROM = ['전산관리', '운영지원'] as const; + +async function mergeCardOrder(intoKey: string, fromKeys: readonly string[]) { + const target = await prisma.columnConfig.findUnique({ where: { key: intoKey } }); + const orders: string[] = []; + if (target?.cardOrder) { + try { + orders.push(...JSON.parse(target.cardOrder)); + } catch { + /* ignore */ + } + } + for (const from of fromKeys) { + const src = await prisma.columnConfig.findUnique({ where: { key: from } }); + if (!src?.cardOrder) continue; + try { + const ids = JSON.parse(src.cardOrder) as string[]; + for (const id of ids) { + if (!orders.includes(id)) orders.push(id); + } + } catch { + /* ignore */ + } + } + if (orders.length === 0 && !target) return; + await prisma.columnConfig.upsert({ + where: { key: intoKey }, + update: { cardOrder: JSON.stringify(orders) }, + create: { + key: intoKey, + title: '운영관리', + titleEn: 'GA', + subtitle: '', + cardOrder: JSON.stringify(orders), + }, + }); +} + +async function main() { + for (const from of FROM) { + const { count } = await prisma.task.updateMany({ + where: { section: from }, + data: { section: MERGE_INTO }, + }); + if (count > 0) console.log(` ✓ ${from} → ${MERGE_INTO}: ${count} tasks`); + } + await mergeCardOrder(MERGE_INTO, FROM); + console.log('✅ Section migration done.'); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/backend/scripts/sync-from-remote.ts b/backend/scripts/sync-from-remote.ts index f081b6e..9fb2658 100644 --- a/backend/scripts/sync-from-remote.ts +++ b/backend/scripts/sync-from-remote.ts @@ -9,7 +9,14 @@ import { PrismaClient, type Priority, type TaskStatus } from '@prisma/client'; const prisma = new PrismaClient(); const SOURCE = (process.env.SOURCE_API_URL || 'https://eene-dashboard-backend.onrender.com').replace(/\/$/, ''); -const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리']; +const SECTIONS = ['인사관리', '학습성장', '운영관리']; + +function normalizeSection(section: string | null | undefined): string | null { + if (!section) return null; + if (section === '전산관리' || section === '운영지원') return '운영관리'; + if (section === '성장지원') return '학습성장'; + return section; +} type RemoteUser = { id: string; name: string; department?: string | null }; type RemoteTask = { @@ -295,7 +302,7 @@ async function main() { priority: remote.priority, quarter: remote.quarter, category: remote.category ?? null, - section: remote.section ?? null, + section: normalizeSection(remote.section), tag: remote.tag ?? null, taskType: remote.taskType ?? null, progress: remote.progress ?? 0, diff --git a/backend/scripts/sync-to-remote.ts b/backend/scripts/sync-to-remote.ts index 209e080..58610bf 100644 --- a/backend/scripts/sync-to-remote.ts +++ b/backend/scripts/sync-to-remote.ts @@ -10,7 +10,7 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); const TARGET = (process.env.TARGET_API_URL || 'https://eene-dashboard-backend.onrender.com').replace(/\/$/, ''); -const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리']; +const SECTIONS = ['인사관리', '학습성장', '운영관리']; const PHOTOS_ONLY = process.argv.includes('--photos-only'); const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || path.join(__dirname, '../../uploads')); const TEAM_DIR = path.join(UPLOAD_DIR, 'team'); diff --git a/frontend/src/components/dashboard/DepartmentColumn.tsx b/frontend/src/components/dashboard/DepartmentColumn.tsx index 8de0f87..15c443b 100644 --- a/frontend/src/components/dashboard/DepartmentColumn.tsx +++ b/frontend/src/components/dashboard/DepartmentColumn.tsx @@ -5,6 +5,7 @@ import { useDroppable } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { apiClient, getApiErrorMessage } from '../../lib/apiClient'; import { isProjectTask, isRoutineTask } from '../../lib/taskType'; +import { COLUMN_META, type SectionKey } from '../../lib/sections'; import { SortableTaskCard } from './TaskCard'; import { ContextMenu } from '../common/ContextMenu'; import { TaskModal } from '../common/TaskModal'; @@ -15,22 +16,16 @@ import type { Task, TeamMember } from '../../types'; interface DepartmentColumnProps { title: string; titleEn?: string; - subtitle?: string; + accent?: string; tasks: Task[]; - orderedIds: string[]; // DashboardPage에서 관리 - headerBg: string; - headerStyle?: React.CSSProperties; - storageKey: string; - section: string; + orderedIds: string[]; + section: SectionKey; quarter: string; - noHeader?: boolean; - headerAlign?: 'left' | 'right'; onSelectTask?: (task: Task) => void; sectionOptions?: { value: string; label: string }[]; teamMembers?: TeamMember[]; } -// ── 헤더 편집 팝업 ────────────────────────────────────────── interface HeaderModalProps { title: string; titleEn: string; @@ -73,7 +68,7 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP value={draftTitleEn} onChange={(e) => setDraftTitleEn(e.target.value)} className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition" - placeholder="Human Resources" + placeholder="HRM" />
@@ -82,7 +77,6 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP value={draftSubtitle} onChange={(e) => setDraftSubtitle(e.target.value)} className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition" - placeholder="부제목 입력" />
@@ -92,14 +86,25 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP
, - document.body + document.body, ); } -export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initialSubtitle = '', tasks, orderedIds, headerStyle, section, quarter, noHeader = false, onSelectTask, sectionOptions: externalSectionOptions, teamMembers = [] }: DepartmentColumnProps) { +export function DepartmentColumn({ + title: initialTitle, + titleEn, + accent, + tasks, + orderedIds, + section, + quarter, + onSelectTask, + sectionOptions: externalSectionOptions, + teamMembers = [], +}: DepartmentColumnProps) { const queryClient = useQueryClient(); + const meta = COLUMN_META[section]; - // ── 컬럼 설정 API ───────────────────────────────────────── const { data: colConfig } = useQuery({ queryKey: ['columns', section], queryFn: () => apiClient.get(`/columns/${encodeURIComponent(section)}`).then((r) => r.data), @@ -112,9 +117,10 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi onSuccess: () => queryClient.invalidateQueries({ queryKey: ['columns', section] }), }); - const title = colConfig?.title ?? initialTitle; - const titleEnState = colConfig?.titleEn ?? (titleEn ?? ''); - const subtitle = colConfig?.subtitle ?? initialSubtitle; + const title = colConfig?.title ?? initialTitle; + const titleEnState = colConfig?.titleEn ?? (titleEn ?? meta.titleEn); + const subtitle = colConfig?.subtitle ?? ''; + const accentColor = accent ?? meta.accent; const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; type: 'header' | 'list' } | null>(null); const [cardMenu, setCardMenu] = useState<{ x: number; y: number; task: Task } | null>(null); @@ -123,11 +129,9 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi const [showHeaderModal, setShowHeaderModal] = useState(false); const [editingTask, setEditingTask] = useState(null); - // ── useDroppable: 컬럼 드롭존 등록 ────────────────────── const { setNodeRef: setProjectDropRef, isOver: isProjectOver } = useDroppable({ id: `drop::project::${section}` }); const { setNodeRef: setRoutineDropRef, isOver: isRoutineOver } = useDroppable({ id: `drop::routine::${section}` }); - // ── 순서 적용 ───────────────────────────────────────────── const orderedTasks = [...tasks].sort((a, b) => { const ai = orderedIds.indexOf(a.id); const bi = orderedIds.indexOf(b.id); @@ -137,9 +141,10 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi return ai - bi; }); - const saveTitle = (v: string) => patchColumn.mutate({ title: v }); - const saveTitleEn = (v: string) => patchColumn.mutate({ titleEn: v }); - const saveSubtitle = (v: string) => patchColumn.mutate({ subtitle: v }); + const projectTasks = orderedTasks.filter((t) => isProjectTask(t.taskType)); + const routineTasks = orderedTasks.filter((t) => isRoutineTask(t.taskType)); + + const patchColumnField = (field: string, value: string) => patchColumn.mutate({ [field]: value }); const create = useMutation({ mutationFn: (data: Partial) => apiClient.post('/tasks', data), @@ -157,7 +162,6 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }), }); - // capture 단계에서 카드 우클릭 처리 — dnd-kit보다 먼저 실행됨 const handleColumnContextMenuCapture = (e: React.MouseEvent) => { const card = (e.target as HTMLElement).closest('[data-task-id]'); if (!card) return; @@ -181,12 +185,11 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi const sectionOptions = externalSectionOptions ?? [{ value: section, label: title }]; - const displayTitle = title.replace(/\s*부문$/, ''); - const handleAdd = async (data: TaskFormData) => { try { await create.mutateAsync({ ...taskFormToApiPayload(data), + section, priority: 'MEDIUM', } as Partial); setShowAddModal(false); @@ -197,10 +200,7 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi const handleEdit = (data: TaskFormData) => { if (!editingTask) return; - patch.mutate({ - id: editingTask.id, - data: taskFormToApiPayload(data), - }); + patch.mutate({ id: editingTask.id, data: taskFormToApiPayload(data) }); setShowEditModal(false); setEditingTask(null); }; @@ -214,79 +214,84 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi return ( <>
- {/* 컬럼 헤더 (noHeader 시 숨김) */} - {!noHeader && ( -
{ e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY, type: 'header' }); }} - > - {displayTitle} - {titleEnState && ( - {titleEnState} - )} - - {tasks.length}건 - -
- )} - - {/* 실행과제 카드 목록 (스크롤 영역) */} - {(() => { - const projectTasks = orderedTasks.filter((t) => isProjectTask(t.taskType)); - return ( -
- {projectTasks.length === 0 ? ( -
- 해당 업무 없음 -
- ) : ( - t.id)} strategy={verticalListSortingStrategy}> - {projectTasks.map((task) => ( - - ))} - +
{ e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY, type: 'header' }); }} + > +
+
+

+ {title.replace(/\s*부문$/, '')} +

+ {titleEnState && ( + + {titleEnState} + )}
- ); - })()} + {subtitle &&

{subtitle}

} +
+
+ {tasks.length} + +
+
- {/* 기반업무 고정 영역 */} - {(() => { - const routineTasks = orderedTasks.filter((t) => isRoutineTask(t.taskType)); - return ( -
-
- {routineTasks.length === 0 ? ( -
- 기반업무 없음 -
- ) : ( - t.id)} strategy={verticalListSortingStrategy}> - {routineTasks.map((task) => ( - - ))} - - )} -
-
- ); - })()} +
+ {projectTasks.length === 0 ? ( +
해당 업무 없음
+ ) : ( + t.id)} strategy={verticalListSortingStrategy}> + {projectTasks.map((task) => ( + + ))} + + )} +
+ +
+
+ {routineTasks.length === 0 ? ( +
+ ) : ( + t.id)} strategy={verticalListSortingStrategy}> + {routineTasks.map((task) => ( + + ))} + + )} +
+
- {/* 카드 우클릭 메뉴 (추가/수정/삭제) */} {cardMenu && ( )} - {/* 빈 영역/헤더 우클릭 메뉴 */} {ctxMenu && ( )} - {/* 추가 모달 */} {showAddModal && ( )} - {/* 헤더 편집 모달 */} {showHeaderModal && ( { saveTitle(t); saveTitleEn(te); saveSubtitle(s); setShowHeaderModal(false); }} + onSave={(t, te, s) => { + patchColumnField('title', t); + patchColumnField('titleEn', te); + patchColumnField('subtitle', s); + setShowHeaderModal(false); + }} onClose={() => setShowHeaderModal(false)} /> )} diff --git a/frontend/src/components/dashboard/TaskCard.tsx b/frontend/src/components/dashboard/TaskCard.tsx index d65071c..9782496 100644 --- a/frontend/src/components/dashboard/TaskCard.tsx +++ b/frontend/src/components/dashboard/TaskCard.tsx @@ -1,23 +1,16 @@ +import { useRef } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import type { DraggableAttributes } from '@dnd-kit/core'; import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities'; import type { Task } from '../../types'; -const STATUS_STYLE: Record = { - IN_PROGRESS: 'bg-blue-500 text-white shadow-blue-500/20', - REVIEW: 'bg-amber-400 text-white shadow-amber-400/20', - TODO: 'bg-slate-200 text-slate-600 shadow-slate-300/20', - DONE: 'bg-emerald-500 text-white shadow-emerald-500/20', - CANCELLED: 'bg-slate-200 text-slate-400 shadow-slate-300/20', -}; - -const STATUS_LABEL: Record = { - IN_PROGRESS: '진행', - REVIEW: '보류', - TODO: '대기', - DONE: '완료', - CANCELLED: '취소', +const STATUS_DOT: Record = { + IN_PROGRESS: 'ongoing', + REVIEW: 'hold', + TODO: 'hold', + CANCELLED: 'hold', + DONE: 'done', }; function fmtDate(iso: string | null | undefined): string { @@ -26,21 +19,99 @@ function fmtDate(iso: string | null | undefined): string { return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`; } +function fmtDateRange(task: Task): string { + if (!task.showDate || (!task.startDate && !task.dueDate)) return ''; + const start = task.startDate ? fmtDate(task.startDate) : '?'; + const end = task.dueDate ? fmtDate(task.dueDate) : '?'; + return `${start} ~ ${end}`; +} + +function firstDescriptionLine(text: string | null | undefined): string { + if (!text) return ''; + const line = text.split('\n').map((l) => l.replace(/^[•·\-]\s*/, '').trim()).find(Boolean); + return line ?? ''; +} + +function statusDotClass(status: string): string { + return STATUS_DOT[status] ?? 'hold'; +} + +function SemiCircleGauge({ value }: { value: number }) { + const p = Math.min(100, Math.max(0, value)); + const stroke = 6.75; + const w = 88; + const h = 56; + const cx = 44; + const r = 32; + /** arc 좌·우 끝 = 숫자 세로 중앙 (100%도 여유 있게) */ + const cy = 46; + const arcLen = Math.PI * r; + const dash = (p / 100) * arcLen; + const path = `M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`; + + return ( +
+ + + + + {p}% +
+ ); +} + type SectionOption = { value: string; label: string }; export function SortableTaskCard({ task, + variant = 'project', onSelect, }: { task: Task; + variant?: 'project' | 'routine'; sectionOptions?: SectionOption[]; onSelect?: (task: Task) => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id }); + const pointerStart = useRef<{ x: number; y: number } | null>(null); + + const handlePointerDown = (e: React.PointerEvent) => { + if (e.button !== 0) return; + pointerStart.current = { x: e.clientX, y: e.clientY }; + listeners?.onPointerDown?.(e); + }; + + const handlePointerUp = (e: React.PointerEvent) => { + if (e.button !== 0 || !pointerStart.current) return; + const dx = e.clientX - pointerStart.current.x; + const dy = e.clientY - pointerStart.current.y; + pointerStart.current = null; + if (!isDragging && Math.hypot(dx, dy) < 8) { + onSelect?.(task); + } + }; return ( { if (!isDragging) onSelect?.(task); }} + onPointerDown={handlePointerDown} + onPointerUp={handlePointerUp} /> ); } export function TaskCard({ task, + variant = 'project', dragRef, dragStyle, dragAttributes, dragListeners, - onCardClick, + onPointerDown, + onPointerUp, }: { task: Task; + variant?: 'project' | 'routine'; dragRef?: (node: HTMLElement | null) => void; dragStyle?: React.CSSProperties; dragAttributes?: DraggableAttributes; dragListeners?: SyntheticListenerMap; - onCardClick?: () => void; + onPointerDown?: (e: React.PointerEvent) => void; + onPointerUp?: (e: React.PointerEvent) => void; }) { + const dragHandlers = { + onPointerDown: (e: React.PointerEvent) => { + onPointerDown?.(e); + }, + onPointerUp: (e: React.PointerEvent) => { + onPointerUp?.(e); + }, + onKeyDown: dragListeners?.onKeyDown as React.KeyboardEventHandler | undefined, + }; + + const dotClass = statusDotClass(task.status); + + if (variant === 'routine') { + return ( +
+ + {task.title} +
+ ); + } + + const dateRange = fmtDateRange(task); + const descLine = task.showDescription ? firstDescriptionLine(task.description) : ''; + const showProgress = task.showProgress !== false; + return (
{ - if (e.button !== 0) return; - dragListeners?.onPointerDown?.(e); - }} - onKeyDown={dragListeners?.onKeyDown as React.KeyboardEventHandler | undefined} - onClick={onCardClick} + className="board-project-card" + {...dragHandlers} > -
- - {task.title} - - {task.showProgress !== false && ( - = 70 ? 'text-emerald-500' : - task.progress >= 40 ? 'text-blue-400' : 'text-orange-400' - }`}> - {task.progress}% - - )} -
- -
- - {task.showDate && (task.startDate || task.dueDate) - ? `${task.startDate ? fmtDate(task.startDate) : '?'} ~ ${task.dueDate ? fmtDate(task.dueDate) : '?'}` - : ''} - - {task.showStatus && ( - - {STATUS_LABEL[task.status]} - - )} -
- - {task.keywords && ( -
- {task.keywords.split(',').map((kw, i) => ( - - {kw.trim()} - - ))} +
+
+
+ + {task.title} +
+ {dateRange &&

{dateRange}

}
- )} + {showProgress && } +
- {task.showDescription && task.description && ( -
{task.description}
+ {descLine && ( +

• {descLine}

)} {task.showIssue && task.issueNote && ( -
- - {task.issueNote} -
+

▶ {task.issueNote}

)}
); diff --git a/frontend/src/components/dashboard/TaskManager.tsx b/frontend/src/components/dashboard/TaskManager.tsx index b1cdc6b..2ce85bc 100644 --- a/frontend/src/components/dashboard/TaskManager.tsx +++ b/frontend/src/components/dashboard/TaskManager.tsx @@ -19,7 +19,7 @@ const STATUS_STYLE: Record = { CANCELLED: 'bg-gray-100 text-gray-400', }; -const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'] as const; +import { SECTIONS, formatSectionDisplay } from '../../lib/sections'; interface TaskManagerProps { tasks: Task[]; @@ -153,7 +153,7 @@ export function TaskManager({ tasks, sectionOptions, quarter, teamMembers = [], 업무 없음 ) : filtered.map((task) => ( - {task.section ?? '-'} + {formatSectionDisplay(task.section)} | null = null; +let syncProvider: (() => string | null) | null = null; interface ScreenDetailed { left: number; @@ -35,20 +39,22 @@ interface WindowWithScreenDetails extends Window { getScreenDetails?: () => Promise; } -/** 클릭 직후 동기적으로 쓸 기본 창 위치 (await 없음) */ -function buildSyncWindowFeatures(): string { - const left = window.screenX + window.outerWidth; - const top = window.screenY; - let width = window.screen.availWidth - left; - if (width < 800) { - width = 1280; - } - const height = window.screen.availHeight; +interface WindowPlacement { + left: number; + top: number; + width: number; + height: number; +} + +function placementToFeatures({ left, top, width, height }: WindowPlacement): string { return `left=${left},top=${top},width=${width},height=${height},menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=yes`; } -/** 우측 모니터 좌표·크기 계산 (열린 뒤 위치 조정용) */ -export async function getRightMonitorWindowFeatures(): Promise { +/** + * 참고 사이트(fr)와 동일한 좌표 계산 + * — getScreenDetails await 후 window.open (권한 요청 + 우측 모니터 배치) + */ +async function resolveDetailWindowPlacement(): Promise { let left = window.screenX + window.outerWidth; let top = window.screenY; let width = window.screen.availWidth; @@ -81,7 +87,12 @@ export async function getRightMonitorWindowFeatures(): Promise { console.warn('Window Management API failed or denied, using fallback', err); } - return `left=${left},top=${top},width=${width},height=${height},menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=yes`; + return { left, top, width, height }; +} + +/** 우측 모니터 좌표·크기 (열린 뒤 moveTo 보정용) */ +export async function getRightMonitorWindowFeatures(): Promise { + return placementToFeatures(await resolveDetailWindowPlacement()); } function getChannel(): BroadcastChannel { @@ -91,30 +102,50 @@ function getChannel(): BroadcastChannel { return channel; } -/** 상세 창이 열려 있는지 확인 */ +function stopClosePoll() { + if (closePollTimer) { + clearInterval(closePollTimer); + closePollTimer = null; + } +} + +function startClosePoll(onClose: () => void) { + stopClosePoll(); + closePollTimer = setInterval(() => { + if (detailWindow && detailWindow.closed) { + detailWindow = null; + dualModeActive = false; + stopClosePoll(); + onClose(); + } + }, 500); +} + export function isDetailWindowOpen(): boolean { return !!detailWindow && !detailWindow.closed; } +export function isDualModeActive(): boolean { + return dualModeActive && isDetailWindowOpen(); +} + 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; +export function registerSyncProvider(fn: () => string | null): () => void { + syncProvider = fn; + return () => { + if (syncProvider === fn) syncProvider = null; + }; } + function applyWindowPlacement(win: Window, left: number, top: number, width: number, height: number) { try { win.moveTo(left, top); @@ -134,10 +165,9 @@ function scheduleTaskSelected(taskId: string) { setTimeout(() => postTaskSelected(taskId), 1500); } -/** 상세 창 열기 — 반드시 사용자 클릭 직후 동기 호출 */ -function openDetailWindowSync(): Window | null { - const detailUrl = `${window.location.origin}/detail`; - const features = buildSyncWindowFeatures(); +function openDetailWindowWithPlacement(placement: WindowPlacement): Window | null { + const detailUrl = `${window.location.origin}/detail?view=detail`; + const features = placementToFeatures(placement); detailWindow = window.open(detailUrl, DETAIL_WINDOW_NAME, features); if (!detailWindow) { @@ -151,78 +181,121 @@ function openDetailWindowSync(): Window | null { // ignore } - // 창을 연 뒤 비동기로 위치만 보정 (팝업 차단과 무관) - void getRightMonitorWindowFeatures().then((f) => { - const { left, top, width, height } = parseWindowFeatures(f); - if (detailWindow && !detailWindow.closed && left != null && top != null && width && height) { - applyWindowPlacement(detailWindow, left, top, width, height); - } - }); - + applyWindowPlacement(detailWindow, placement.left, placement.top, placement.width, placement.height); return detailWindow; } -/** 상세 창 토글 — 열려 있으면 닫고, 닫혀 있으면 오른쪽 모니터에 열기 */ -export function openDetailWindow(): Window | null { - if (isDetailWindowOpen()) { - detailWindow!.close(); - detailWindow = null; - return null; - } +/** 참고 사이트: getScreenDetails(await) → window.open */ +async function openDetailWindowPlaced(onPopupClosed?: () => void): Promise { + const placement = await resolveDetailWindowPlacement(); + const win = openDetailWindowWithPlacement(placement); + if (!win) return null; - const win = openDetailWindowSync(); - const savedTaskId = getPersistedTaskId(); - if (win && savedTaskId) { - scheduleTaskSelected(savedTaskId); - } + startClosePoll(() => onPopupClosed?.()); return win; } -/** 좌측 → 우측: 업무 선택 이벤트 전송 (창이 닫혀 있으면 열고 전송) */ -export function sendTaskSelected(taskId: string): void { +/** 듀얼뷰 토글 */ +export async function openDetailWindow(onPopupClosed?: () => void): Promise { + if (isDetailWindowOpen()) { + detailWindow!.close(); + detailWindow = null; + dualModeActive = false; + stopClosePoll(); + onPopupClosed?.(); + return null; + } + + dualModeActive = true; + const win = await openDetailWindowPlaced(onPopupClosed); + if (!win) { + dualModeActive = false; + return null; + } + + const savedTaskId = getPersistedTaskId(); + if (savedTaskId) scheduleTaskSelected(savedTaskId); + return win; +} + +/** 업무 선택 — 참고 사이트와 같이 배치 계산(await) 후 팝업 열기 */ +export async function sendTaskSelected(taskId: string, onPopupClosed?: () => void): Promise { persistSelectedTask(taskId); if (!isDetailWindowOpen()) { - const win = openDetailWindowSync(); - if (!win) return; + dualModeActive = true; + const win = await openDetailWindowPlaced(onPopupClosed); + if (!win) { + dualModeActive = false; + return; + } scheduleTaskSelected(taskId); return; } + scheduleTaskSelected(taskId); + try { detailWindow!.focus(); } catch { // ignore } - scheduleTaskSelected(taskId); + + void resolveDetailWindowPlacement().then((placement) => { + if (detailWindow && !detailWindow.closed) { + applyWindowPlacement(detailWindow, placement.left, placement.top, placement.width, placement.height); + } + }); } -/** 좌측 → 우측: 업무 선택 해제 */ export function sendTaskDeselected(): void { persistSelectedTask(null); getChannel().postMessage({ type: 'TASK_DESELECTED' } satisfies DualMonitorEvent); } -/** 이벤트 수신 리스너 등록 */ +export function requestDetailSync(): void { + getChannel().postMessage({ type: 'REQUEST_SYNC' } satisfies DualMonitorEvent); +} + +function respondToSyncRequest() { + const id = syncProvider?.() ?? getPersistedTaskId(); + if (id) postTaskSelected(id); + else getChannel().postMessage({ type: 'TASK_DESELECTED' } satisfies DualMonitorEvent); +} + export function onDualMonitorEvent( handler: (event: DualMonitorEvent) => void, + options?: { isPopupView?: boolean }, ): () => void { const ch = getChannel(); - const listener = (e: MessageEvent) => handler(e.data); + const isPopupView = options?.isPopupView ?? false; + + const listener = (e: MessageEvent) => { + const evt = e.data; + if (evt.type === 'REQUEST_SYNC') { + if (!isPopupView) respondToSyncRequest(); + return; + } + handler(evt); + }; + ch.addEventListener('message', listener); + + if (isPopupView) { + requestDetailSync(); + } + return () => ch.removeEventListener('message', listener); } -/** 채널 종료 */ export function closeChannel(): void { + stopClosePoll(); channel?.close(); channel = null; } -/** 웹 링크를 우측 모니터 새 창에서 열기 */ export function openLinkOnRightMonitor(url: string, windowName: string): Window | null { - const features = buildSyncWindowFeatures(); - const win = window.open(url, windowName, features); + const win = window.open(url, windowName, 'noopener,noreferrer,width=1280,height=900'); try { win?.focus(); } catch { @@ -230,10 +303,9 @@ export function openLinkOnRightMonitor(url: string, windowName: string): Window } if (win) { - void getRightMonitorWindowFeatures().then((f) => { - const { left, top, width, height } = parseWindowFeatures(f); - if (win && !win.closed && left != null && top != null && width && height) { - applyWindowPlacement(win, left, top, width, height); + void resolveDetailWindowPlacement().then((placement) => { + if (win && !win.closed) { + applyWindowPlacement(win, placement.left, placement.top, placement.width, placement.height); } }); } diff --git a/frontend/src/lib/sections.ts b/frontend/src/lib/sections.ts new file mode 100644 index 0000000..d2947b4 --- /dev/null +++ b/frontend/src/lib/sections.ts @@ -0,0 +1,68 @@ +/** 대시보드 부문 (3열) */ +export const SECTIONS = ['인사관리', '학습성장', '운영관리'] as const; + +export type SectionKey = (typeof SECTIONS)[number]; + +/** DB·레거시 section 값 → 표준 부문 */ +export const SECTION_ALIASES: Record = { + 인사관리: ['인사관리'], + 학습성장: ['학습성장', '성장지원'], + 운영관리: ['운영관리', '운영지원', '전산관리'], +}; + +/** 컬럼 설정·cardOrder 조회 시 함께 병합할 레거시 키 */ +export const LEGACY_COLUMN_KEYS: Record = { + 인사관리: [], + 학습성장: [], + 운영관리: ['운영지원', '전산관리'], +}; + +export function normalizeSection(section: string | null | undefined): SectionKey | null { + if (!section) return null; + for (const key of SECTIONS) { + if (SECTION_ALIASES[key].includes(section)) return key; + } + return null; +} + +export function taskBelongsToSection( + taskSection: string | null | undefined, + columnSection: SectionKey, +): boolean { + return normalizeSection(taskSection) === columnSection; +} + +/** 화면·듀얼모니터 상세에 표시할 부문명 */ +export function formatSectionDisplay(section: string | null | undefined): string { + const key = normalizeSection(section); + if (key) return key; + return section?.trim() || '—'; +} + +export function canonicalSection(section: string | null | undefined): SectionKey { + return normalizeSection(section) ?? '인사관리'; +} + +export const COLUMN_META: Record< + SectionKey, + { titleEn: string; accent: string; displayTitle: string; routineBg: string } +> = { + 인사관리: { + titleEn: 'HRM', + accent: '#07412e', + displayTitle: '인사관리', + routineBg: 'linear-gradient(180deg, #dce8e3 0%, #e8f0ec 100%)', + }, + 학습성장: { + titleEn: 'HRD', + accent: '#29724f', + displayTitle: '성장지원', + routineBg: 'linear-gradient(180deg, #d8ebe3 0%, #e6f2ec 100%)', + }, + 운영관리: { + titleEn: 'GA', + accent: '#36816d', + displayTitle: '운영관리', + routineBg: 'linear-gradient(180deg, #d4ece4 0%, #e4f3ed 100%)', + }, +}; diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 86bf9e2..bb3577f 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -19,7 +19,8 @@ import { TeamStatusPanel } from '../components/dashboard/TeamStatusPanel'; import { DepartmentColumn } from '../components/dashboard/DepartmentColumn'; import { TaskManager } from '../components/dashboard/TaskManager'; import { useSocket } from '../contexts/SocketContext'; -import { sendTaskSelected, openDetailWindow } from '../lib/dualMonitor'; +import { sendTaskSelected, openDetailWindow, registerSyncProvider, isDetailWindowOpen } from '../lib/dualMonitor'; +import { TaskDetailShell } from '../pages/DetailPage'; import { isRoutineTask, displayFlagsForTaskType } from '../lib/taskType'; import { DEFAULT_STATUS_FILTERS, @@ -28,16 +29,34 @@ import { toggleCoreFilter, type CoreStatusFilter, } from '../lib/statusFilters'; +import { + SECTIONS, + COLUMN_META, + LEGACY_COLUMN_KEYS, + taskBelongsToSection, + normalizeSection, + type SectionKey, +} from '../lib/sections'; const QUARTER = '2026-Q2'; -const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'] as const; -const COLUMN_STYLES = [ - { section: '인사관리', titleEn: 'HR Management', headerStyle: { background: 'linear-gradient(120deg,#2a4a8a 0%,#3461b8 50%,#3d72d0 100%)' } }, - { section: '학습성장', titleEn: 'Learning & Growth', headerStyle: { background: 'linear-gradient(120deg,#5b2d8a 0%,#7340b8 50%,#8a52d0 100%)' } }, - { section: '운영지원', titleEn: 'Operations', headerStyle: { background: 'linear-gradient(120deg,#0d6080 0%,#0d7a9a 50%,#0e92b8 100%)' } }, - { section: '전산관리', titleEn: 'IT Management', headerStyle: { background: 'linear-gradient(120deg,#0a6040 0%,#0d8050 50%,#10a060 100%)' } }, -] as const; +function mergeCardOrders(primary: string | null | undefined, extras: (string | null | undefined)[]): string[] { + const merged: string[] = []; + const push = (raw: string | null | undefined) => { + if (!raw) return; + try { + const ids = JSON.parse(raw) as string[]; + for (const id of ids) { + if (!merged.includes(id)) merged.push(id); + } + } catch { + /* ignore */ + } + }; + push(primary); + extras.forEach(push); + return merged; +} export default function DashboardPage() { const [activeFilters, setActiveFilters] = useState([...DEFAULT_STATUS_FILTERS]); @@ -48,6 +67,11 @@ export default function DashboardPage() { const [showAllTeamTasks, setShowAllTeamTasks] = useState(false); const [activeTeamProjectId, setActiveTeamProjectId] = useState(null); const [activeTaskId, setActiveTaskId] = useState(null); + const [selectedTaskId, setSelectedTaskId] = useState(null); + const [detailPopupOpen, setDetailPopupOpen] = useState(false); + const [viewportWidth, setViewportWidth] = useState(() => + typeof window !== 'undefined' ? window.innerWidth : 1920, + ); const [columnOrders, setColumnOrders] = useState>({}); const saveTimers = useRef>>({}); @@ -58,19 +82,42 @@ export default function DashboardPage() { const { data: teamMembers = [] } = useTeamMembers(); const { data: colConfigs } = useQuery({ - queryKey: ['columns', 'all'], + queryKey: ['columns', 'all', ...SECTIONS], queryFn: async () => { const results = await Promise.all( - SECTIONS.map((s) => - apiClient.get(`/columns/${encodeURIComponent(s)}`).then((r) => ({ key: s, ...r.data })), - ), + SECTIONS.map(async (s) => { + const main = await apiClient.get(`/columns/${encodeURIComponent(s)}`).then((r) => r.data); + const legacy = await Promise.all( + LEGACY_COLUMN_KEYS[s].map((key) => + apiClient.get(`/columns/${encodeURIComponent(key)}`).then((r) => r.data).catch(() => null), + ), + ); + const cardOrder = JSON.stringify( + mergeCardOrders(main.cardOrder, legacy.map((l) => l?.cardOrder)), + ); + return { key: s, ...main, cardOrder }; + }), ); return results; }, staleTime: 0, }); - // colConfig의 cardOrder로 초기 순서 설정 + useEffect(() => { + const onResize = () => setViewportWidth(window.innerWidth); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + useEffect(() => registerSyncProvider(() => selectedTaskId), [selectedTaskId]); + + useEffect(() => { + const timer = setInterval(() => { + setDetailPopupOpen(isDetailWindowOpen()); + }, 300); + return () => clearInterval(timer); + }, []); + useEffect(() => { if (!colConfigs) return; setColumnOrders((prev) => { @@ -84,10 +131,9 @@ export default function DashboardPage() { }); }, [colConfigs]); - // 새 태스크 추가 시 순서 목록에 병합 useEffect(() => { SECTIONS.forEach((section) => { - const ids = tasks.filter((t) => t.section === section).map((t) => t.id); + const ids = tasks.filter((t) => taskBelongsToSection(t.section, section)).map((t) => t.id); setColumnOrders((prev) => { const existing = prev[section] ?? []; const merged = [ @@ -106,7 +152,7 @@ export default function DashboardPage() { }); const patchTask = useMutation({ - mutationFn: ({ id, data }: { id: string; data: Record }) => + mutationFn: ({ id, data }: { id: string; data: Record }) => apiClient.patch(`/tasks/${id}`, data), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }), }); @@ -131,21 +177,19 @@ export default function DashboardPage() { if (!over) return; const activeId = String(active.id); - const overId = String(over.id); + const overId = String(over.id); const draggedTask = tasks.find((t) => t.id === activeId); if (!draggedTask) return; - const srcSection = draggedTask.section ?? ''; + const srcSection = normalizeSection(draggedTask.section) ?? '인사관리'; - // ── 드롭 대상이 컬럼 드롭존인 경우 ────────────────────────── if (overId.startsWith('drop::')) { - // 형식: "drop::project::섹션명" 또는 "drop::routine::섹션명" const parts = overId.split('::'); - const areaType = parts[1]; // 'project' | 'routine' - const targetSection = parts[2]; + const areaType = parts[1]; + const targetSection = parts[2] as SectionKey; - const updateData: Record = {}; + const updateData: Record = {}; if (targetSection !== srcSection) updateData.section = targetSection; if (areaType === 'routine' && !isRoutineTask(draggedTask.taskType)) { updateData.taskType = '기반업무'; @@ -160,15 +204,14 @@ export default function DashboardPage() { return; } - // ── 드롭 대상이 다른 카드인 경우 ───────────────────────────── const overTask = tasks.find((t) => t.id === overId); if (!overTask) return; - const dstSection = overTask.section ?? ''; + const dstSection = normalizeSection(overTask.section) ?? '인사관리'; const typeChanged = isRoutineTask(draggedTask.taskType) !== isRoutineTask(overTask.taskType); if (dstSection !== srcSection || typeChanged) { - const updateData: Record = {}; + const updateData: Record = {}; if (dstSection !== srcSection) updateData.section = dstSection; if (typeChanged) { const newType = isRoutineTask(overTask.taskType) ? '기반업무' : '실행과제'; @@ -179,10 +222,10 @@ export default function DashboardPage() { if (dstSection !== srcSection) return; } - // ── 같은 컬럼 내 순서 변경 ─────────────────────────────────── - const current = columnOrders[srcSection] ?? tasks.filter((t) => t.section === srcSection).map((t) => t.id); - const oldIdx = current.indexOf(activeId); - const newIdx = current.indexOf(overId); + const current = columnOrders[srcSection] + ?? tasks.filter((t) => taskBelongsToSection(t.section, srcSection)).map((t) => t.id); + const oldIdx = current.indexOf(activeId); + const newIdx = current.indexOf(overId); if (oldIdx === -1 || newIdx === -1 || oldIdx === newIdx) return; const newOrder = arrayMove(current, oldIdx, newIdx); @@ -195,11 +238,11 @@ export default function DashboardPage() { }; const stats = { - total: tasks.length, + total: tasks.length, inProgress: tasks.filter((t) => t.status === 'IN_PROGRESS').length, - review: tasks.filter((t) => t.status === 'REVIEW' || t.status === 'CANCELLED' || t.status === 'TODO').length, - done: tasks.filter((t) => t.status === 'DONE').length, - issues: tasks.filter((t) => !!t.issueNote).length, + review: tasks.filter((t) => t.status === 'REVIEW' || t.status === 'CANCELLED' || t.status === 'TODO').length, + done: tasks.filter((t) => t.status === 'DONE').length, + issues: tasks.filter((t) => !!t.issueNote).length, }; const filtered = tasks.filter((t) => { @@ -229,120 +272,143 @@ export default function DashboardPage() { const sectionOptions = SECTIONS.map((s) => ({ value: s, - label: colConfigs?.find((c) => c.key === s)?.title ?? s, + label: colConfigs?.find((c) => c.key === s)?.title ?? COLUMN_META[s].displayTitle, })); const activeTask = tasks.find((t) => t.id === activeTaskId) ?? null; + const ultraWideLayout = viewportWidth >= 3800; + /** 팝업이 열려 있으면 우측 도킹 숨김 — 팝업 차단 시 같은 페이지 도킹 fallback */ + const showDockedDetail = !!selectedTaskId && !detailPopupOpen; + const detailDocked = showDockedDetail && ultraWideLayout; + + const handleSelectTask = (taskId: string) => { + setSelectedTaskId(taskId); + void sendTaskSelected(taskId, () => setDetailPopupOpen(false)).then(() => { + setDetailPopupOpen(isDetailWindowOpen()); + }); + }; + + const handleOpenDetailWindow = () => { + void openDetailWindow(() => setDetailPopupOpen(false)).then(() => { + setDetailPopupOpen(isDetailWindowOpen()); + }); + }; + if (isLoading) { return ( -
-
데이터를 불러오는 중...
+
+
데이터를 불러오는 중...
); } return ( -
- { openDetailWindow(); }} - onOpenTaskManager={() => setShowTaskManager(true)} - teamPanelOpen={teamPanelOpen} - onToggleTeamPanel={() => { - setTeamPanelOpen((open) => { - if (open) { - setActiveTeamProjectId(null); - setShowAllTeamTasks(false); - } - return !open; - }); - }} - /> - - {teamPanelOpen && ( - setShowAllTeamTasks((v) => !v)} - onProjectClick={setActiveTeamProjectId} - onClose={() => { - setTeamPanelOpen(false); - setActiveTeamProjectId(null); - setShowAllTeamTasks(false); +
+
+ setShowTaskManager(true)} + teamPanelOpen={teamPanelOpen} + onToggleTeamPanel={() => { + setTeamPanelOpen((open) => { + if (open) { + setActiveTeamProjectId(null); + setShowAllTeamTasks(false); + } + return !open; + }); }} /> - )} -
- {/* ── 좌측 라벨 컬럼 ── */} -
-
-
- 실행과제 -
-
- 기반업무 -
-
+ {teamPanelOpen && ( + setShowAllTeamTasks((v) => !v)} + onProjectClick={setActiveTeamProjectId} + onClose={() => { + setTeamPanelOpen(false); + setActiveTeamProjectId(null); + setShowAllTeamTasks(false); + }} + /> + )} - -
- {COLUMN_STYLES.map(({ section, titleEn, headerStyle }) => ( - t.section === section)} - orderedIds={columnOrders[section] ?? []} - headerBg="" - headerStyle={headerStyle} - storageKey={`col_${section}`} - section={section} - quarter={QUARTER} - onSelectTask={(t) => sendTaskSelected(t.id)} - sectionOptions={sectionOptions} - teamMembers={teamMembers} - /> - ))} -
+
+ +
+ {SECTIONS.map((section) => { + const meta = COLUMN_META[section]; + return ( + taskBelongsToSection(t.section, section))} + orderedIds={columnOrders[section] ?? []} + section={section} + quarter={QUARTER} + onSelectTask={(t) => handleSelectTask(t.id)} + sectionOptions={sectionOptions} + teamMembers={teamMembers} + /> + ); + })} +
- - {activeTask ? ( -
-
- {activeTask.title} - = 70 ? 'text-emerald-500' : activeTask.progress >= 40 ? 'text-blue-400' : 'text-orange-400' - }`}>{activeTask.progress}% + + {activeTask ? ( +
+
+
+
+ + {activeTask.title} +
+
+ {activeTask.showProgress !== false && ( + {activeTask.progress}% + )} +
-
- ) : null} - - -
+ ) : null} + +
+
- {showTaskManager && ( - setShowTaskManager(false)} - /> + {showTaskManager && ( + setShowTaskManager(false)} + /> + )} +
+ + {!detailPopupOpen && ( + )}
); diff --git a/frontend/src/pages/DetailPage.tsx b/frontend/src/pages/DetailPage.tsx index 881a230..a3aae07 100644 --- a/frontend/src/pages/DetailPage.tsx +++ b/frontend/src/pages/DetailPage.tsx @@ -14,6 +14,7 @@ import { } from '../components/detail/StageModal'; import { sortFilesByOrder } from '../lib/fileDisplay'; import { useAuth } from '../contexts/AuthContext'; +import { formatSectionDisplay } from '../lib/sections'; import type { Task, Milestone, FileRecord, TaskDetail } from '../types'; const STATUS_CONFIG: Record = { @@ -204,7 +205,7 @@ function DetailHeader({ task }: { task: Task }) { 부서{' '} - {task.section ?? '—'} + {formatSectionDisplay(task.section)} {status.label}
@@ -704,24 +705,7 @@ function DetailView({ task }: { task: TaskWithRelations }) { ); } -export default function DetailPage() { - const { taskId: routeTaskId } = useParams<{ taskId?: string }>(); - const [taskId, setTaskId] = useState( - () => routeTaskId ?? getPersistedTaskId(), - ); - - useEffect(() => { - if (routeTaskId) setTaskId(routeTaskId); - }, [routeTaskId]); - - useEffect(() => { - const unsub = onDualMonitorEvent((evt) => { - if (evt.type === 'TASK_SELECTED') setTaskId(evt.taskId); - if (evt.type === 'TASK_DESELECTED') setTaskId(null); - }); - return unsub; - }, []); - +export function TaskDetailShell({ taskId }: { taskId: string | null }) { const { data: task, isLoading, isError, error } = useQuery({ queryKey: ['task', taskId], queryFn: async () => { @@ -734,7 +718,7 @@ export default function DetailPage() { }); return ( -
+
{task && }
@@ -756,3 +740,40 @@ export default function DetailPage() {
); } + +export default function DetailPage() { + const { taskId: routeTaskId } = useParams<{ taskId?: string }>(); + const isPopupView = + typeof window !== 'undefined' && + (window.location.search.includes('view=detail') || window.name === 'eene_detail'); + const [taskId, setTaskId] = useState( + () => routeTaskId ?? getPersistedTaskId(), + ); + + useEffect(() => { + if (routeTaskId) setTaskId(routeTaskId); + }, [routeTaskId]); + + useEffect(() => { + const unsub = onDualMonitorEvent( + (evt) => { + if (evt.type === 'TASK_SELECTED') setTaskId(evt.taskId); + if (evt.type === 'TASK_DESELECTED') setTaskId(null); + }, + { isPopupView }, + ); + return unsub; + }, [isPopupView]); + + if (isPopupView) { + return ( +
+ +
+ ); + } + + return ; +}