feat: 3-section dashboard, reference dual-monitor layout, and detail dock
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<string[]>([...DEFAULT_STATUS_FILTERS]);
|
||||
@@ -48,6 +67,11 @@ export default function DashboardPage() {
|
||||
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 [detailPopupOpen, setDetailPopupOpen] = useState(false);
|
||||
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>>>({});
|
||||
|
||||
@@ -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<string, any> }) =>
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
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<string, any> = {};
|
||||
const updateData: Record<string, unknown> = {};
|
||||
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<string, any> = {};
|
||||
const updateData: Record<string, unknown> = {};
|
||||
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 (
|
||||
<div className="flex h-screen items-center justify-center bg-slate-100">
|
||||
<div className="text-3xl text-gray-400">데이터를 불러오는 중...</div>
|
||||
<div className="flex h-screen items-center justify-center bg-[#eef4f1]">
|
||||
<div className="text-lg text-gray-400">데이터를 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen flex-col overflow-hidden bg-[#eef2f5]" style={{ fontSize: '18px' }}>
|
||||
<DashboardHeader
|
||||
quarter={QUARTER}
|
||||
stats={stats}
|
||||
activeFilters={activeFilters}
|
||||
issueFilterActive={issueFilterActive}
|
||||
onToggleAll={handleToggleAll}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
onToggleIssue={handleToggleIssue}
|
||||
onOpenDetailWindow={() => { openDetailWindow(); }}
|
||||
onOpenTaskManager={() => setShowTaskManager(true)}
|
||||
teamPanelOpen={teamPanelOpen}
|
||||
onToggleTeamPanel={() => {
|
||||
setTeamPanelOpen((open) => {
|
||||
if (open) {
|
||||
setActiveTeamProjectId(null);
|
||||
setShowAllTeamTasks(false);
|
||||
}
|
||||
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);
|
||||
<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]">
|
||||
<DashboardHeader
|
||||
quarter={QUARTER}
|
||||
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);
|
||||
}
|
||||
return !open;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<main className={`relative flex min-h-0 flex-1 overflow-hidden px-5 py-5 ${teamPanelOpen ? 'hidden' : ''}`}>
|
||||
{/* ── 좌측 라벨 컬럼 ── */}
|
||||
<div className="mr-4 flex w-16 shrink-0 flex-col overflow-hidden rounded-[2rem] bg-white shadow-[0_16px_40px_rgba(15,23,42,0.12)] ring-1 ring-slate-200/70">
|
||||
<div className="h-10 shrink-0 border-b border-slate-100 bg-slate-50" />
|
||||
<div className="flex flex-1 items-center justify-center border-b border-slate-100 bg-gradient-to-b from-slate-50 to-white">
|
||||
<span className="select-none text-sm font-black tracking-[0.35em] text-slate-800" style={{ writingMode: 'vertical-rl' }}>실행과제</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-center bg-gradient-to-b from-white to-slate-50" style={{ height: 300 }}>
|
||||
<span className="select-none text-sm font-black tracking-[0.35em] text-slate-800" style={{ writingMode: 'vertical-rl' }}>기반업무</span>
|
||||
</div>
|
||||
</div>
|
||||
{teamPanelOpen && (
|
||||
<TeamStatusPanel
|
||||
members={teamMembers}
|
||||
tasks={tasks}
|
||||
showAllTasks={showAllTeamTasks}
|
||||
activeProjectId={activeTeamProjectId}
|
||||
onToggleShowAll={() => setShowAllTeamTasks((v) => !v)}
|
||||
onProjectClick={setActiveTeamProjectId}
|
||||
onClose={() => {
|
||||
setTeamPanelOpen(false);
|
||||
setActiveTeamProjectId(null);
|
||||
setShowAllTeamTasks(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="grid h-full min-h-0 flex-1 grid-cols-4 gap-4 overflow-hidden">
|
||||
{COLUMN_STYLES.map(({ section, titleEn, headerStyle }) => (
|
||||
<DepartmentColumn
|
||||
key={section}
|
||||
title={section}
|
||||
titleEn={titleEn}
|
||||
tasks={filtered.filter((t) => 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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<DragOverlay dropAnimation={{ duration: 180, easing: 'ease' }}>
|
||||
{activeTask ? (
|
||||
<div className="rotate-1 rounded-[1.35rem] border border-white/80 bg-white px-5 py-4 opacity-90 shadow-[0_28px_56px_rgba(15,23,42,0.22)] ring-1 ring-slate-200/60">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="min-w-0 truncate text-2xl font-black leading-snug text-slate-900">{activeTask.title}</span>
|
||||
<span className={`mt-0.5 min-w-[4rem] shrink-0 text-right text-2xl font-black ${
|
||||
activeTask.progress >= 70 ? 'text-emerald-500' : activeTask.progress >= 40 ? 'text-blue-400' : 'text-orange-400'
|
||||
}`}>{activeTask.progress}%</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{activeTask.showProgress !== false && (
|
||||
<span className="board-gauge-value">{activeTask.progress}%</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</main>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</main>
|
||||
|
||||
{showTaskManager && (
|
||||
<TaskManager
|
||||
tasks={tasks}
|
||||
sectionOptions={sectionOptions}
|
||||
quarter={QUARTER}
|
||||
teamMembers={teamMembers}
|
||||
onClose={() => setShowTaskManager(false)}
|
||||
/>
|
||||
{showTaskManager && (
|
||||
<TaskManager
|
||||
tasks={tasks}
|
||||
sectionOptions={sectionOptions}
|
||||
quarter={QUARTER}
|
||||
teamMembers={teamMembers}
|
||||
onClose={() => setShowTaskManager(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!detailPopupOpen && (
|
||||
<aside className={`app-right ${showDockedDetail ? 'detail-open' : ''}`}>
|
||||
<TaskDetailShell taskId={selectedTaskId} />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user