+
{routineTasks.length === 0 ? (
기반업무 없음
) : (
-
- t.id)} strategy={verticalListSortingStrategy}>
- {routineTasks.map((task) => (
-
- ))}
-
-
+
t.id)} strategy={verticalListSortingStrategy}>
+ {routineTasks.map((task) => (
+
+ ))}
+
)}
diff --git a/frontend/src/components/dashboard/TaskCard.tsx b/frontend/src/components/dashboard/TaskCard.tsx
index d70947c..5befeea 100644
--- a/frontend/src/components/dashboard/TaskCard.tsx
+++ b/frontend/src/components/dashboard/TaskCard.tsx
@@ -170,6 +170,7 @@ export function TaskCard({
style={dragStyle}
{...dragAttributes}
{...dragListeners}
+ data-task-card="true"
className="mb-3 cursor-grab select-none overflow-hidden rounded-[1.35rem] border border-white/80 bg-white px-5 py-4 shadow-[0_10px_28px_rgba(15,23,42,0.08)] ring-1 ring-slate-200/60 transition-all hover:-translate-y-0.5 hover:shadow-[0_18px_34px_rgba(15,23,42,0.14)] active:cursor-grabbing"
onPointerDown={(e) => {
// 우클릭(button !== 0)을 dnd-kit에 전달하지 않음
diff --git a/frontend/src/components/dashboard/TaskManager.tsx b/frontend/src/components/dashboard/TaskManager.tsx
index f0c49b3..0f0d6b6 100644
--- a/frontend/src/components/dashboard/TaskManager.tsx
+++ b/frontend/src/components/dashboard/TaskManager.tsx
@@ -47,9 +47,17 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
});
+ // 기반업무 = 상시업무(구), 실행과제 = 프로젝트(구) — 둘 다 매칭
+ const matchType = (taskType: string | null | undefined, filter: string) => {
+ if (filter === '전체') return true;
+ if (filter === '실행과제') return taskType === '실행과제' || taskType === '프로젝트';
+ if (filter === '기반업무') return taskType === '기반업무' || taskType === '상시업무';
+ return taskType === filter;
+ };
+
const filtered = tasks.filter((t) => {
if (filterSection !== '전체' && t.section !== filterSection) return false;
- if (filterType !== '전체' && t.taskType !== filterType) return false;
+ if (!matchType(t.taskType, filterType)) return false;
return true;
});
@@ -120,7 +128,7 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan
{/* 유형 필터 */}
- {['전체', '프로젝트', '상시업무'].map((t) => (
+ {['전체', '실행과제', '기반업무'].map((t) => (
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx
index 6d3a127..a50ee50 100644
--- a/frontend/src/pages/DashboardPage.tsx
+++ b/frontend/src/pages/DashboardPage.tsx
@@ -1,5 +1,16 @@
-import { useState, useEffect } from 'react';
-import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { useState, useEffect, useRef } 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 { DashboardHeader } from '../components/dashboard/DashboardHeader';
@@ -9,15 +20,81 @@ import { useSocket } from '../contexts/SocketContext';
import { sendTaskSelected, openDetailWindow } from '../lib/dualMonitor';
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;
export default function DashboardPage() {
const [activeStatus, setActiveStatus] = useState('전체');
const [showTaskManager, setShowTaskManager] = useState(false);
+ const [activeTaskId, setActiveTaskId] = useState
(null);
+ const [columnOrders, setColumnOrders] = useState>({});
+ const saveTimers = useRef>>({});
+
const queryClient = useQueryClient();
const socket = useSocket();
const { data: tasks = [], isLoading } = useTasks({ quarter: QUARTER });
+ const { data: colConfigs } = useQuery({
+ queryKey: ['columns', 'all'],
+ queryFn: async () => {
+ const results = await Promise.all(
+ SECTIONS.map((s) =>
+ apiClient.get(`/columns/${encodeURIComponent(s)}`).then((r) => ({ key: s, ...r.data })),
+ ),
+ );
+ return results;
+ },
+ staleTime: 0,
+ });
+
+ // colConfig의 cardOrder로 초기 순서 설정
+ useEffect(() => {
+ 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 */ }
+ }
+ });
+ return next;
+ });
+ }, [colConfigs]);
+
+ // 새 태스크 추가 시 순서 목록에 병합
+ useEffect(() => {
+ SECTIONS.forEach((section) => {
+ const ids = tasks.filter((t) => t.section === section).map((t) => t.id);
+ setColumnOrders((prev) => {
+ const existing = prev[section] ?? [];
+ 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 };
+ });
+ });
+ }, [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: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
+ });
+
useEffect(() => {
if (!socket) return;
const refresh = () => queryClient.invalidateQueries({ queryKey: ['tasks'] });
@@ -26,6 +103,72 @@ export default function DashboardPage() {
return () => { socket.off('tasks:refresh', refresh); socket.off('task:updated', refresh); };
}, [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 srcSection = 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 updateData: Record = {};
+ if (targetSection !== srcSection) updateData.section = targetSection;
+ if (areaType === 'routine' && draggedTask.taskType !== '상시업무' && draggedTask.taskType !== '기반업무') {
+ updateData.taskType = '기반업무';
+ } else if (areaType === 'project' && (draggedTask.taskType === '상시업무' || draggedTask.taskType === '기반업무')) {
+ updateData.taskType = '실행과제';
+ }
+ 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 dstSection = overTask.section ?? '';
+
+ if (dstSection !== srcSection) {
+ // 컬럼 간 이동 — 섹션 변경
+ patchTask.mutate({ id: activeId, data: { section: dstSection } });
+ 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);
+ if (oldIdx === -1 || newIdx === -1 || oldIdx === newIdx) return;
+
+ const newOrder = arrayMove(current, oldIdx, newIdx);
+ setColumnOrders((prev) => ({ ...prev, [srcSection]: newOrder }));
+
+ if (saveTimers.current[srcSection]) clearTimeout(saveTimers.current[srcSection]);
+ saveTimers.current[srcSection] = setTimeout(() => {
+ patchColumn.mutate({ section: srcSection, data: { cardOrder: JSON.stringify(newOrder) } });
+ }, 300);
+ };
+
const stats = {
total: tasks.length,
inProgress: tasks.filter((t) => t.status === 'IN_PROGRESS').length,
@@ -42,28 +185,13 @@ export default function DashboardPage() {
return t.status === activeStatus;
});
- const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'] as const;
-
- const sec1Tasks = filtered.filter((t) => t.section === '인사관리');
- const sec2Tasks = filtered.filter((t) => t.section === '학습성장');
- const sec3Tasks = filtered.filter((t) => t.section === '운영지원');
- const sec4Tasks = filtered.filter((t) => t.section === '전산관리');
- const { data: colConfigs } = useQuery({
- queryKey: ['columns', 'all'],
- queryFn: async () => {
- const results = await Promise.all(
- SECTIONS.map((s) => apiClient.get(`/columns/${encodeURIComponent(s)}`).then((r) => ({ key: s, ...r.data }))),
- );
- return results;
- },
- staleTime: 0,
- });
-
const sectionOptions = SECTIONS.map((s) => ({
value: s,
label: colConfigs?.find((c) => c.key === s)?.title ?? s,
}));
+ const activeTask = tasks.find((t) => t.id === activeTaskId) ?? null;
+
if (isLoading) {
return (
@@ -73,10 +201,7 @@ export default function DashboardPage() {
}
return (
-
+
-
- {/* ── 맨 왼쪽 구분 라벨 컬럼 ── */}
+ {/* ── 좌측 라벨 컬럼 ── */}
- {/* 헤더 높이 맞춤 (DepartmentColumn 헤더 h-10) */}
- {/* 실행과제 영역 (flex-1) */}
- 실행과제
+ 실행과제
- {/* 기반업무 영역 (고정 300px) */}
- 기반업무
+ 기반업무
-
- sendTaskSelected(t.id)}
- sectionOptions={sectionOptions}
- />
- sendTaskSelected(t.id)}
- sectionOptions={sectionOptions}
- />
- sendTaskSelected(t.id)}
- sectionOptions={sectionOptions}
- />
- sendTaskSelected(t.id)}
- sectionOptions={sectionOptions}
- />
-
+
+
+ {COLUMN_STYLES.map(({ section, titleEn, headerStyle }) => (
+ t.section === section)}
+ orderedIds={columnOrders[section] ?? []}
+ headerBg=""
+ headerStyle={headerStyle}
+ storageKey={`col_${section}`}
+ section={section}
+ quarter={QUARTER}
+ onSelectTask={(t) => sendTaskSelected(t.id)}
+ sectionOptions={sectionOptions}
+ />
+ ))}
+
+
+ {activeTask ? (
+
+
+ {activeTask.title}
+ = 70 ? 'text-emerald-500' : activeTask.progress >= 40 ? 'text-blue-400' : 'text-orange-400'
+ }`}>{activeTask.progress}%
+
+
+ ) : null}
+
+
{showTaskManager && (