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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user