Files
eene_dashboard/frontend/src/pages/DashboardPage.tsx
EENE Dashboard b3f2da203b EENE Dashboard upload to Gitea
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 16:59:34 +09:00

534 lines
19 KiB
TypeScript

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<string[]>([...DEFAULT_STATUS_FILTERS]);
const [filtersBeforeIssue, setFiltersBeforeIssue] = useState<string[]>([...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<string | null>(null);
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [detailStageId, setDetailStageId] = useState<string | null>(null);
const [detailPopupOpen, setDetailPopupOpen] = useState(false);
const [placementBanner, setPlacementBanner] = useState<string | null>(() => getWindowPlacementHint());
const [viewportWidth, setViewportWidth] = useState(() =>
typeof window !== 'undefined' ? window.innerWidth : 1920,
);
const [columnOrders, setColumnOrders] = useState<Record<string, string[]>>({});
const saveTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
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<string, string> }) =>
apiClient.patch(`/columns/${encodeURIComponent(section)}`, data),
});
const patchTask = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
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<string, unknown> = {};
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<string, unknown> = {};
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 (
<DepartmentColumn
key={slot.id}
slot={slot}
tasks={filtered.filter((t) => taskBelongsToBoardSlot(t, slot))}
orderedIds={columnOrders[label] ?? []}
quarter={quarter}
onSelectTask={(t) => handleSelectTask(t.id)}
sectionOptions={sectionOptions}
teamMembers={teamMembers}
/>
);
};
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-white">
<div className="text-lg text-gray-400"> ...</div>
</div>
);
}
return (
<div
className={`app-shell dummy-board-page dummy-board-page--2slots ${detailDocked ? 'detail-docked' : ''} ${detailPopupOpen ? 'dual-mode-parent' : ''}`}
>
<div className="app-main relative flex h-screen min-w-0 flex-col overflow-hidden">
{placementBanner && (
<div className="flex shrink-0 items-start justify-between gap-3 border-b border-amber-200 bg-amber-50 px-4 py-2.5 text-sm text-amber-950">
<p className="whitespace-pre-line leading-relaxed">{placementBanner}</p>
<button
type="button"
className="shrink-0 rounded-lg px-2 py-1 text-amber-800 hover:bg-amber-100"
onClick={() => setPlacementBanner(null)}
aria-label="닫기"
>
</button>
</div>
)}
<DashboardHeader
quarter={quarter}
referenceDate={referenceDate}
onReferenceDateChange={setReferenceDate}
stats={stats}
activeFilters={activeFilters}
issueFilterActive={issueFilterActive}
onToggleAll={handleToggleAll}
onToggleStatus={handleToggleStatus}
onToggleIssue={handleToggleIssue}
onOpenDetailWindow={handleOpenDetailWindow}
onOpenTaskManager={() => setShowTaskManager(true)}
teamPanelOpen={teamPanelOpen}
onToggleTeamPanel={() => {
setTeamPanelOpen((open) => {
if (open) {
setActiveTeamProjectId(null);
setShowAllTeamTasks(false);
} else if (selectedTaskId) {
setActiveTeamProjectId(selectedTaskId);
}
return !open;
});
}}
/>
{teamPanelOpen && (
<TeamStatusPanel
members={teamMembers}
tasks={tasks}
showAllTasks={showAllTeamTasks}
activeProjectId={activeTeamProjectId}
onToggleShowAll={() => setShowAllTeamTasks((v) => !v)}
onProjectClick={setActiveTeamProjectId}
onClose={() => {
setTeamPanelOpen(false);
setActiveTeamProjectId(null);
setShowAllTeamTasks(false);
}}
/>
)}
<main className={`board-main dummy-board-main ${teamPanelOpen ? 'hidden' : ''}`}>
<div className="page-content">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="board-layout">
{renderDeptSlot('hrm')}
{renderDeptSlot('hrd')}
<HubColumn
routineTasks={routineTasks}
quarter={quarter}
referenceDate={referenceDate}
onSelectRoutine={(t) => handleSelectTask(t.id)}
onSelectRoutineMilestone={(taskId, milestoneId) => handleSelectTask(taskId, milestoneId)}
/>
{renderDeptSlot('ex')}
{renderDeptSlot('ga')}
<BoardConnectors enabled={!teamPanelOpen} style="reference" />
</div>
<DragOverlay dropAnimation={{ duration: 180, easing: 'ease' }}>
{activeTask && isProjectTask(activeTask.taskType) ? (
<article className="project-sub-card board-project-card--overlay">
<div className="project-sub-body">
<div className="project-fields">
<div className="project-sub-title">{activeTask.title}</div>
</div>
{activeTask.showProgress !== false && (
<div className="progress-col">
<DonutGauge task={activeTask} />
</div>
)}
</div>
</article>
) : activeTask ? (
<div className="board-routine-item board-project-card--overlay">
<span className="board-project-title">{activeTask.title}</span>
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
</main>
{showTaskManager && (
<TaskManager
tasks={tasks}
sectionOptions={sectionOptions}
quarter={quarter}
teamMembers={teamMembers}
onClose={() => setShowTaskManager(false)}
/>
)}
</div>
{!detailPopupOpen && ultraWideLayout && (
<aside className={`app-right ${showDockedDetail ? 'detail-open' : ''}`}>
<TaskDetailShell taskId={selectedTaskId} initialStageId={detailStageId} />
</aside>
)}
</div>
);
}