diff --git a/frontend/src/components/common/TaskModal.tsx b/frontend/src/components/common/TaskModal.tsx index b9adaa3..fcd3c0b 100644 --- a/frontend/src/components/common/TaskModal.tsx +++ b/frontend/src/components/common/TaskModal.tsx @@ -131,8 +131,8 @@ export function TaskModal({ mode, task, defaultSection = 'HR', defaultQuarter = onChange={(e) => set('taskType', e.target.value)} className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition bg-white" > - - + + diff --git a/frontend/src/components/dashboard/DepartmentColumn.tsx b/frontend/src/components/dashboard/DepartmentColumn.tsx index 880ea3a..2e44326 100644 --- a/frontend/src/components/dashboard/DepartmentColumn.tsx +++ b/frontend/src/components/dashboard/DepartmentColumn.tsx @@ -1,15 +1,8 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState } from 'react'; import { createPortal } from 'react-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { - DndContext, - closestCenter, - PointerSensor, - useSensor, - useSensors, - type DragEndEvent, -} from '@dnd-kit/core'; -import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'; +import { useDroppable } from '@dnd-kit/core'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { apiClient } from '../../lib/apiClient'; import { SortableTaskCard } from './TaskCard'; import { ContextMenu } from '../common/ContextMenu'; @@ -22,6 +15,7 @@ interface DepartmentColumnProps { titleEn?: string; subtitle?: string; tasks: Task[]; + orderedIds: string[]; // DashboardPage에서 관리 headerBg: string; headerStyle?: React.CSSProperties; storageKey: string; @@ -99,7 +93,7 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP ); } -export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initialSubtitle = '', tasks, headerStyle, section, quarter, noHeader = false, onSelectTask, sectionOptions: externalSectionOptions }: DepartmentColumnProps) { +export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initialSubtitle = '', tasks, orderedIds, headerStyle, section, quarter, noHeader = false, onSelectTask, sectionOptions: externalSectionOptions }: DepartmentColumnProps) { const queryClient = useQueryClient(); // ── 컬럼 설정 API ───────────────────────────────────────── @@ -115,34 +109,19 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi onSuccess: () => queryClient.invalidateQueries({ queryKey: ['columns', section] }), }); - const title = colConfig?.title ?? initialTitle; - const titleEnState = colConfig?.titleEn ?? (titleEn ?? ''); - const subtitle = colConfig?.subtitle ?? initialSubtitle; + const title = colConfig?.title ?? initialTitle; + const titleEnState = colConfig?.titleEn ?? (titleEn ?? ''); + const subtitle = colConfig?.subtitle ?? initialSubtitle; const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; type: 'header' | 'list' } | null>(null); const [showAddModal, setShowAddModal] = useState(false); const [showHeaderModal, setShowHeaderModal] = useState(false); - // ── 드래그 순서 관리 (DB 저장) ──────────────────────────── - const [orderedIds, setOrderedIds] = useState([]); - const saveOrderTimer = useRef | null>(null); - - // DB에서 불러온 순서 반영 - useEffect(() => { - if (colConfig?.cardOrder) { - try { setOrderedIds(JSON.parse(colConfig.cardOrder)); } catch { /* ignore */ } - } - }, [colConfig?.cardOrder]); - - // tasks 목록이 바뀌면 새 항목을 순서 목록에 추가 - useEffect(() => { - const newIds = tasks.map((t) => t.id); - setOrderedIds((prev) => { - const merged = [...prev.filter((id) => newIds.includes(id)), ...newIds.filter((id) => !prev.includes(id))]; - return merged; - }); - }, [tasks]); + // ── useDroppable: 컬럼 드롭존 등록 ────────────────────── + const { setNodeRef: setProjectDropRef, isOver: isProjectOver } = useDroppable({ id: `drop::project::${section}` }); + const { setNodeRef: setRoutineDropRef, isOver: isRoutineOver } = useDroppable({ id: `drop::routine::${section}` }); + // ── 순서 적용 ───────────────────────────────────────────── const orderedTasks = [...tasks].sort((a, b) => { const ai = orderedIds.indexOf(a.id); const bi = orderedIds.indexOf(b.id); @@ -152,26 +131,6 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi return ai - bi; }); - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), - ); - - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - if (!over || active.id === over.id) return; - setOrderedIds((prev) => { - const oldIdx = prev.indexOf(active.id as string); - const newIdx = prev.indexOf(over.id as string); - const next = arrayMove(prev, oldIdx, newIdx); - // 300ms 디바운스 후 DB 저장 - if (saveOrderTimer.current) clearTimeout(saveOrderTimer.current); - saveOrderTimer.current = setTimeout(() => { - patchColumn.mutate({ cardOrder: JSON.stringify(next) }); - }, 300); - return next; - }); - }; - const saveTitle = (v: string) => patchColumn.mutate({ title: v }); const saveTitleEn = (v: string) => patchColumn.mutate({ titleEn: v }); const saveSubtitle = (v: string) => patchColumn.mutate({ subtitle: v }); @@ -182,8 +141,10 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi }); const handleListContextMenu = (e: React.MouseEvent) => { + // 카드 위에서 발생한 이벤트는 무시 (TaskCard의 자체 메뉴가 처리) + if ((e.target as HTMLElement).closest('[data-task-card]')) return; e.preventDefault(); - e.stopPropagation(); // 카드에서 올라온 이벤트가 아니면 여기서 처리 + e.stopPropagation(); setCtxMenu({ x: e.clientX, y: e.clientY, type: 'list' }); }; @@ -234,47 +195,50 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi )} - {/* 프로젝트 카드 목록 (스크롤 영역) */} -
- {(() => { - const projectTasks = orderedTasks.filter((t) => t.taskType !== '상시업무'); - return projectTasks.length === 0 ? ( -
- 해당 업무 없음 -
- ) : ( - + {/* 실행과제 카드 목록 (스크롤 영역) */} + {(() => { + const projectTasks = orderedTasks.filter((t) => t.taskType !== '상시업무' && t.taskType !== '기반업무'); + return ( +
+ {projectTasks.length === 0 ? ( +
+ 해당 업무 없음 +
+ ) : ( t.id)} strategy={verticalListSortingStrategy}> {projectTasks.map((task) => ( ))} - - ); - })()} -
+ )} +
+ ); + })()} {/* 기반업무 고정 영역 */} {(() => { - const routineTasks = orderedTasks.filter((t) => t.taskType === '상시업무'); + const routineTasks = orderedTasks.filter((t) => t.taskType === '상시업무' || t.taskType === '기반업무'); return ( -
+
{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 && (