import { useState, useEffect, useRef, useMemo } from 'react'; import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'; import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragOverlay, type DragStartEvent, type DragEndEvent, } from '@dnd-kit/core'; import { arrayMove } from '@dnd-kit/sortable'; import { apiClient } from '../lib/apiClient'; import { useTasks } from '../hooks/useTasks'; import { useBoardReferenceDate } from '../hooks/useBoardReferenceDate'; import { useTeamMembers } from '../hooks/useTeamMembers'; import { DashboardHeader } from '../components/dashboard/DashboardHeader'; import { TeamStatusPanel } from '../components/dashboard/TeamStatusPanel'; import { DepartmentColumn } from '../components/dashboard/DepartmentColumn'; import { HubColumn } from '../components/dashboard/HubColumn'; import { BoardConnectors } from '../components/dashboard/BoardConnectors'; import '../styles/dummy-board.css'; import { DonutGauge } from '../components/dashboard/DonutGauge'; import { TaskManager } from '../components/dashboard/TaskManager'; import { useSocket } from '../contexts/SocketContext'; import { sendTaskSelected, openDetailWindow, registerSyncProvider, isDetailWindowOpen, getWindowPlacementHint } from '../lib/dualMonitor'; import { TaskDetailShell } from '../pages/DetailPage'; import { isRoutineTask, isProjectTask, displayFlagsForTaskType } from '../lib/taskType'; import { hasVisibleIssue } from '../lib/taskIssues'; import { DEFAULT_STATUS_FILTERS, taskMatchesStatusFilters, toggleAllFilter, toggleCoreFilter, type CoreStatusFilter, } from '../lib/statusFilters'; import { SECTIONS, LEGACY_COLUMN_KEYS, taskBelongsToSection, type SectionKey, } from '../lib/sections'; import { BOARD_SLOTS, BOARD_SLOT_ORDER, getBoardSlot, slotSectionLabel, columnDisplayTitle, taskBelongsToBoardSlot, } from '../lib/boardLayout'; import { invalidateTaskCaches } from '../lib/taskQueryCache'; 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; } function sectionKeyForLabel(label: string): SectionKey | null { for (const s of SECTIONS) { if (label === s || taskBelongsToSection(label, s)) return s; } return null; } export default function DashboardPage() { const [activeFilters, setActiveFilters] = useState([...DEFAULT_STATUS_FILTERS]); const [filtersBeforeIssue, setFiltersBeforeIssue] = useState([...DEFAULT_STATUS_FILTERS]); const [issueFilterActive, setIssueFilterActive] = useState(false); const [showTaskManager, setShowTaskManager] = useState(false); const [teamPanelOpen, setTeamPanelOpen] = useState(false); const [showAllTeamTasks, setShowAllTeamTasks] = useState(false); const [activeTeamProjectId, setActiveTeamProjectId] = useState(null); const [activeTaskId, setActiveTaskId] = useState(null); const [selectedTaskId, setSelectedTaskId] = useState(null); const [detailStageId, setDetailStageId] = useState(null); const [detailPopupOpen, setDetailPopupOpen] = useState(false); const [placementBanner, setPlacementBanner] = useState(() => getWindowPlacementHint()); const [viewportWidth, setViewportWidth] = useState(() => typeof window !== 'undefined' ? window.innerWidth : 1920, ); const [columnOrders, setColumnOrders] = useState>({}); const saveTimers = useRef>>({}); const queryClient = useQueryClient(); const socket = useSocket(); const { referenceDate, setReferenceDate, quarter } = useBoardReferenceDate(); const { data: tasks = [], isLoading } = useTasks({ quarter }); const { data: teamMembers = [] } = useTeamMembers(); const { data: colConfigs } = useQuery({ queryKey: ['columns', 'all', ...SECTIONS], queryFn: async () => { const results = await Promise.all( 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, }); 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) => { const next = { ...prev }; BOARD_SLOTS.forEach((slot) => { const label = slotSectionLabel(slot); if (next[label]) return; if (!slot.sectionKey) return; const cfg = colConfigs.find((c) => c.key === slot.sectionKey); if (cfg?.cardOrder) { try { next[label] = JSON.parse(cfg.cardOrder); } catch { /* ignore */ } } }); return next; }); }, [colConfigs]); useEffect(() => { BOARD_SLOTS.forEach((slot) => { const label = slotSectionLabel(slot); const ids = tasks.filter((t) => taskBelongsToBoardSlot(t, slot)).map((t) => t.id); setColumnOrders((prev) => { const existing = prev[label] ?? []; const merged = [ ...existing.filter((id) => ids.includes(id)), ...ids.filter((id) => !existing.includes(id)), ]; if (JSON.stringify(merged) === JSON.stringify(existing)) return prev; return { ...prev, [label]: merged }; }); }); }, [tasks]); const patchColumn = useMutation({ mutationFn: ({ section, data }: { section: string; data: Record }) => apiClient.patch(`/columns/${encodeURIComponent(section)}`, data), }); const patchTask = useMutation({ mutationFn: ({ id, data }: { id: string; data: Record }) => apiClient.patch(`/tasks/${id}`, data), onSuccess: (_data, { id }) => invalidateTaskCaches(queryClient, id), }); useEffect(() => { if (!socket) return; const refreshList = () => queryClient.invalidateQueries({ queryKey: ['tasks'] }); const refreshTask = () => queryClient.invalidateQueries({ queryKey: ['task'] }); socket.on('tasks:refresh', refreshList); socket.on('task:updated', refreshTask); return () => { socket.off('tasks:refresh', refreshList); socket.off('task:updated', refreshTask); }; }, [socket, queryClient]); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); const handleDragStart = (event: DragStartEvent) => { setActiveTaskId(String(event.active.id)); }; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; setActiveTaskId(null); if (!over) return; const activeId = String(active.id); const overId = String(over.id); const draggedTask = tasks.find((t) => t.id === activeId); if (!draggedTask) return; const srcSlot = BOARD_SLOTS.find((s) => taskBelongsToBoardSlot(draggedTask, s)); const srcLabel = srcSlot ? slotSectionLabel(srcSlot) : (draggedTask.section ?? '인사관리'); if (overId.startsWith('drop::')) { const parts = overId.split('::'); const areaType = parts[1]; const targetSection = parts[2]; const updateData: Record = {}; if (areaType === 'routine' && targetSection === 'hub') { if (!isRoutineTask(draggedTask.taskType)) { updateData.taskType = '기반업무'; Object.assign(updateData, displayFlagsForTaskType('기반업무')); } if (Object.keys(updateData).length > 0) { patchTask.mutate({ id: activeId, data: updateData }); } return; } if (areaType === 'project') { if (targetSection !== srcLabel) updateData.section = targetSection; if (isRoutineTask(draggedTask.taskType)) { updateData.taskType = '실행과제'; Object.assign(updateData, displayFlagsForTaskType('실행과제')); } if (Object.keys(updateData).length > 0) { patchTask.mutate({ id: activeId, data: updateData }); } return; } } const overTask = tasks.find((t) => t.id === overId); if (!overTask) return; const dstSlot = BOARD_SLOTS.find((s) => taskBelongsToBoardSlot(overTask, s)); const dstLabel = dstSlot ? slotSectionLabel(dstSlot) : (overTask.section ?? '인사관리'); const typeChanged = isRoutineTask(draggedTask.taskType) !== isRoutineTask(overTask.taskType); if (dstLabel !== srcLabel || typeChanged) { const updateData: Record = {}; if (dstLabel !== srcLabel) updateData.section = dstLabel; if (typeChanged) { const newType = isRoutineTask(overTask.taskType) ? '기반업무' : '실행과제'; updateData.taskType = newType; Object.assign(updateData, displayFlagsForTaskType(newType)); } patchTask.mutate({ id: activeId, data: updateData }); if (dstLabel !== srcLabel) return; } const current = columnOrders[srcLabel] ?? tasks.filter((t) => t.section === srcLabel).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); setColumnOrders((prev) => ({ ...prev, [srcLabel]: newOrder })); const apiKey = sectionKeyForLabel(srcLabel); if (!apiKey) return; if (saveTimers.current[srcLabel]) clearTimeout(saveTimers.current[srcLabel]); saveTimers.current[srcLabel] = setTimeout(() => { patchColumn.mutate({ section: apiKey, data: { cardOrder: JSON.stringify(newOrder) } }); }, 300); }; const filtered = tasks.filter((t) => { if (issueFilterActive) return hasVisibleIssue(t); return taskMatchesStatusFilters(t, activeFilters); }); /** 4분면 부서 카드에 표시되는 실행과제만 (상단 현황판과 건수 일치) */ const boardProjectTasks = useMemo( () => filtered.filter( (t) => isProjectTask(t.taskType) && BOARD_SLOTS.some((slot) => taskBelongsToBoardSlot(t, slot)), ), [filtered], ); const stats = useMemo( () => ({ total: boardProjectTasks.length, inProgress: boardProjectTasks.filter((t) => t.status === 'IN_PROGRESS').length, review: boardProjectTasks.filter( (t) => t.status === 'REVIEW' || t.status === 'CANCELLED' || t.status === 'TODO', ).length, done: boardProjectTasks.filter((t) => t.status === 'DONE').length, issues: boardProjectTasks.filter((t) => hasVisibleIssue(t)).length, }), [boardProjectTasks], ); const routineTasks = useMemo( () => filtered.filter((t) => isRoutineTask(t.taskType)), [filtered], ); const handleToggleAll = () => { setIssueFilterActive(false); setActiveFilters((prev) => toggleAllFilter(prev)); }; const handleToggleStatus = (key: CoreStatusFilter) => { setIssueFilterActive(false); setActiveFilters((prev) => toggleCoreFilter(prev, key)); }; const handleToggleIssue = () => { if (issueFilterActive) { setIssueFilterActive(false); setActiveFilters([...filtersBeforeIssue]); return; } setFiltersBeforeIssue([...activeFilters]); setIssueFilterActive(true); }; const sectionOptions = BOARD_SLOTS.map((slot) => ({ value: slotSectionLabel(slot), label: columnDisplayTitle( slot, colConfigs?.find((c) => c.key === slot.sectionKey), ), })); const activeTask = tasks.find((t) => t.id === activeTaskId) ?? null; /** 초광역(단일 화면)에서만 인라인 우측 패널 — 일반은 팝업(듀얼 모니터) 전용 */ const ultraWideLayout = viewportWidth >= 3800; const showDockedDetail = ultraWideLayout && !!selectedTaskId && !detailPopupOpen; const detailDocked = showDockedDetail; const handleSelectTask = (taskId: string, stageId?: string | null) => { setSelectedTaskId(taskId); setDetailStageId(stageId ?? null); if (teamPanelOpen) setActiveTeamProjectId(taskId); void sendTaskSelected(taskId, () => setDetailPopupOpen(false)).then((popupOpened) => { if (popupOpened) { setDetailPopupOpen(true); } else if (!ultraWideLayout) { window.alert( '상세 창을 열 수 없습니다.\n\n' + '• 팝업 허용 (주소창 아이콘)\n' + '• 「모든 디스플레이에 대한 정보 보기」 권한 허용\n' + ' (IP 주소 접속 시 localhost와 별도로 한 번 더 필요)', ); } }); }; const handleOpenDetailWindow = () => { void openDetailWindow(() => setDetailPopupOpen(false)).then((win) => { setDetailPopupOpen(!!win); }); }; const renderDeptSlot = (slotId: typeof BOARD_SLOT_ORDER[number]) => { const slot = getBoardSlot(slotId); const label = slotSectionLabel(slot); return ( taskBelongsToBoardSlot(t, slot))} orderedIds={columnOrders[label] ?? []} quarter={quarter} onSelectTask={(t) => handleSelectTask(t.id)} sectionOptions={sectionOptions} teamMembers={teamMembers} /> ); }; if (isLoading) { return (
데이터를 불러오는 중...
); } return (
{placementBanner && (

{placementBanner}

)} setShowTaskManager(true)} teamPanelOpen={teamPanelOpen} onToggleTeamPanel={() => { setTeamPanelOpen((open) => { if (open) { setActiveTeamProjectId(null); setShowAllTeamTasks(false); } else if (selectedTaskId) { setActiveTeamProjectId(selectedTaskId); } return !open; }); }} /> {teamPanelOpen && ( setShowAllTeamTasks((v) => !v)} onProjectClick={setActiveTeamProjectId} onClose={() => { setTeamPanelOpen(false); setActiveTeamProjectId(null); setShowAllTeamTasks(false); }} /> )}
{renderDeptSlot('hrm')} {renderDeptSlot('hrd')} handleSelectTask(t.id)} onSelectRoutineMilestone={(taskId, milestoneId) => handleSelectTask(taskId, milestoneId)} /> {renderDeptSlot('ex')} {renderDeptSlot('ga')}
{activeTask && isProjectTask(activeTask.taskType) ? (
{activeTask.title}
{activeTask.showProgress !== false && (
)}
) : activeTask ? (
{activeTask.title}
) : null}
{showTaskManager && ( setShowTaskManager(false)} /> )}
{!detailPopupOpen && ultraWideLayout && ( )}
); }