feat: quarter board theme, hub column, and team panel UX

Apply preview-style 4-dept layout with center hub, PM/assignee team status linking, task type label updates, and remove task keywords.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
EENE Dashboard
2026-06-08 22:09:46 +09:00
parent 525a4fc1f2
commit cf72281c6d
28 changed files with 4743 additions and 314 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useMemo } from 'react';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import {
DndContext,
@@ -17,11 +17,14 @@ 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 { DonutGauge } from '../components/dashboard/DonutGauge';
import { TaskManager } from '../components/dashboard/TaskManager';
import { useSocket } from '../contexts/SocketContext';
import { sendTaskSelected, openDetailWindow, registerSyncProvider, isDetailWindowOpen } from '../lib/dualMonitor';
import { TaskDetailShell } from '../pages/DetailPage';
import { isRoutineTask, displayFlagsForTaskType } from '../lib/taskType';
import { isRoutineTask, isProjectTask, displayFlagsForTaskType } from '../lib/taskType';
import {
DEFAULT_STATUS_FILTERS,
taskMatchesStatusFilters,
@@ -31,12 +34,17 @@ import {
} from '../lib/statusFilters';
import {
SECTIONS,
COLUMN_META,
LEGACY_COLUMN_KEYS,
taskBelongsToSection,
normalizeSection,
type SectionKey,
} from '../lib/sections';
import {
BOARD_SLOTS,
BOARD_SLOT_ORDER,
getBoardSlot,
slotSectionLabel,
taskBelongsToSlot,
} from '../lib/boardLayout';
const QUARTER = '2026-Q2';
@@ -58,6 +66,13 @@ function mergeCardOrders(primary: string | null | undefined, extras: (string | n
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]);
@@ -122,9 +137,17 @@ export default function DashboardPage() {
if (!colConfigs) return;
setColumnOrders((prev) => {
const next = { ...prev };
colConfigs.forEach((c) => {
if (c.cardOrder && !next[c.key]) {
try { next[c.key] = JSON.parse(c.cardOrder); } catch { /* ignore */ }
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;
@@ -132,16 +155,17 @@ export default function DashboardPage() {
}, [colConfigs]);
useEffect(() => {
SECTIONS.forEach((section) => {
const ids = tasks.filter((t) => taskBelongsToSection(t.section, section)).map((t) => t.id);
BOARD_SLOTS.forEach((slot) => {
const label = slotSectionLabel(slot);
const ids = tasks.filter((t) => taskBelongsToSlot(t.section, slot)).map((t) => t.id);
setColumnOrders((prev) => {
const existing = prev[section] ?? [];
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, [section]: merged };
return { ...prev, [label]: merged };
});
});
}, [tasks]);
@@ -182,58 +206,74 @@ export default function DashboardPage() {
const draggedTask = tasks.find((t) => t.id === activeId);
if (!draggedTask) return;
const srcSection = normalizeSection(draggedTask.section) ?? '인사관리';
const srcSlot = BOARD_SLOTS.find((s) => taskBelongsToSlot(draggedTask.section, s));
const srcLabel = srcSlot ? slotSectionLabel(srcSlot) : (draggedTask.section ?? '인사관리');
if (overId.startsWith('drop::')) {
const parts = overId.split('::');
const areaType = parts[1];
const targetSection = parts[2] as SectionKey;
const targetSection = parts[2];
const updateData: Record<string, unknown> = {};
if (targetSection !== srcSection) updateData.section = targetSection;
if (areaType === 'routine' && !isRoutineTask(draggedTask.taskType)) {
updateData.taskType = '기반업무';
Object.assign(updateData, displayFlagsForTaskType('기반업무'));
} else if (areaType === 'project' && isRoutineTask(draggedTask.taskType)) {
updateData.taskType = '실행과제';
Object.assign(updateData, displayFlagsForTaskType('실행과제'));
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 (Object.keys(updateData).length > 0) {
patchTask.mutate({ id: activeId, data: updateData });
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;
}
return;
}
const overTask = tasks.find((t) => t.id === overId);
if (!overTask) return;
const dstSection = normalizeSection(overTask.section) ?? '인사관리';
const dstSlot = BOARD_SLOTS.find((s) => taskBelongsToSlot(overTask.section, s));
const dstLabel = dstSlot ? slotSectionLabel(dstSlot) : (overTask.section ?? '인사관리');
const typeChanged = isRoutineTask(draggedTask.taskType) !== isRoutineTask(overTask.taskType);
if (dstSection !== srcSection || typeChanged) {
if (dstLabel !== srcLabel || typeChanged) {
const updateData: Record<string, unknown> = {};
if (dstSection !== srcSection) updateData.section = dstSection;
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 (dstSection !== srcSection) return;
if (dstLabel !== srcLabel) return;
}
const current = columnOrders[srcSection]
?? tasks.filter((t) => taskBelongsToSection(t.section, srcSection)).map((t) => t.id);
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, [srcSection]: newOrder }));
setColumnOrders((prev) => ({ ...prev, [srcLabel]: newOrder }));
if (saveTimers.current[srcSection]) clearTimeout(saveTimers.current[srcSection]);
saveTimers.current[srcSection] = setTimeout(() => {
patchColumn.mutate({ section: srcSection, data: { cardOrder: JSON.stringify(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);
};
@@ -250,6 +290,11 @@ export default function DashboardPage() {
return taskMatchesStatusFilters(t, activeFilters);
});
const routineTasks = useMemo(
() => filtered.filter((t) => isRoutineTask(t.taskType)),
[filtered],
);
const handleToggleAll = () => {
setIssueFilterActive(false);
setActiveFilters((prev) => toggleAllFilter(prev));
@@ -270,20 +315,21 @@ export default function DashboardPage() {
setIssueFilterActive(true);
};
const sectionOptions = SECTIONS.map((s) => ({
value: s,
label: colConfigs?.find((c) => c.key === s)?.title ?? COLUMN_META[s].displayTitle,
const sectionOptions = BOARD_SLOTS.map((slot) => ({
value: slotSectionLabel(slot),
label: colConfigs?.find((c) => c.key === slot.sectionKey)?.title
?? slot.defaultTitle,
}));
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);
if (teamPanelOpen) setActiveTeamProjectId(taskId);
void sendTaskSelected(taskId, () => setDetailPopupOpen(false)).then(() => {
setDetailPopupOpen(isDetailWindowOpen());
});
@@ -295,9 +341,26 @@ export default function DashboardPage() {
});
};
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) => taskBelongsToSlot(t.section, 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-[#eef4f1]">
<div className="flex h-screen items-center justify-center bg-[#e9eef2]">
<div className="text-lg text-gray-400"> ...</div>
</div>
);
@@ -307,7 +370,7 @@ export default function DashboardPage() {
<div
className={`app-shell ${detailDocked ? 'detail-docked' : ''} ${detailPopupOpen ? 'dual-mode-parent' : ''}`}
>
<div className="app-main relative flex h-screen min-w-0 flex-col overflow-hidden bg-[#eef4f1]">
<div className="app-main relative flex h-screen min-w-0 flex-col overflow-hidden bg-[#e9eef2]">
<DashboardHeader
quarter={QUARTER}
stats={stats}
@@ -324,6 +387,8 @@ export default function DashboardPage() {
if (open) {
setActiveTeamProjectId(null);
setShowAllTeamTasks(false);
} else if (selectedTaskId) {
setActiveTeamProjectId(selectedTaskId);
}
return !open;
});
@@ -347,51 +412,48 @@ export default function DashboardPage() {
)}
<main className={`board-main ${teamPanelOpen ? 'hidden' : ''}`}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="board-grid">
{SECTIONS.map((section) => {
const meta = COLUMN_META[section];
return (
<DepartmentColumn
key={section}
title={meta.displayTitle}
titleEn={meta.titleEn}
accent={meta.accent}
tasks={filtered.filter((t) => taskBelongsToSection(t.section, section))}
orderedIds={columnOrders[section] ?? []}
section={section}
quarter={QUARTER}
onSelectTask={(t) => handleSelectTask(t.id)}
sectionOptions={sectionOptions}
teamMembers={teamMembers}
/>
);
})}
</div>
<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}
onSelectRoutine={(t) => handleSelectTask(t.id)}
/>
{renderDeptSlot('ex')}
{renderDeptSlot('ga')}
<BoardConnectors enabled={!teamPanelOpen} />
</div>
<DragOverlay dropAnimation={{ duration: 180, easing: 'ease' }}>
{activeTask ? (
<div className="board-project-card board-project-card--overlay">
<div className="board-project-top">
<div className="board-project-main">
<div className="board-project-title-row">
<span className="board-status-dot board-status-dot--ongoing" aria-hidden />
<span className="board-project-title">{activeTask.title}</span>
<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>
{activeTask.showProgress !== false && (
<span className="board-gauge-value">{activeTask.progress}%</span>
)}
</article>
) : activeTask ? (
<div className="board-routine-item board-project-card--overlay">
<span className="board-project-title">{activeTask.title}</span>
</div>
</div>
) : null}
</DragOverlay>
</DndContext>
) : null}
</DragOverlay>
</DndContext>
</div>
</main>
{showTaskManager && (