feat: cross-column DnD, fix context menu, rename task types to 실행과제/기반업무

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
EENE Dashboard
2026-06-02 09:11:11 +09:00
parent f07874e8fe
commit 67144d6a30
5 changed files with 249 additions and 174 deletions

View File

@@ -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"
>
<option value="상시업무"></option>
<option value="프로젝트"></option>
<option value="기반업무"></option>
<option value="실행과제"></option>
</select>
</div>
</div>

View File

@@ -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<string[]>([]);
const saveOrderTimer = useRef<ReturnType<typeof setTimeout> | 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
</div>
)}
{/* 프로젝트 카드 목록 (스크롤 영역) */}
<div
className="min-h-0 flex-1 overflow-y-auto bg-gradient-to-b from-slate-50/80 to-white/70 p-4"
onContextMenu={handleListContextMenu}
>
{(() => {
const projectTasks = orderedTasks.filter((t) => t.taskType !== '상시업무');
return projectTasks.length === 0 ? (
<div className="flex h-40 items-center justify-center text-2xl text-slate-300">
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
{/* 실행과제 카드 목록 (스크롤 영역) */}
{(() => {
const projectTasks = orderedTasks.filter((t) => t.taskType !== '상시업무' && t.taskType !== '기반업무');
return (
<div
ref={setProjectDropRef}
className={`min-h-0 flex-1 overflow-y-auto bg-gradient-to-b from-slate-50/80 to-white/70 p-4 transition-colors ${isProjectOver ? 'bg-blue-50/60' : ''}`}
onContextMenu={handleListContextMenu}
>
{projectTasks.length === 0 ? (
<div className="flex h-40 items-center justify-center text-2xl text-slate-300">
</div>
) : (
<SortableContext items={projectTasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
{projectTasks.map((task) => (
<SortableTaskCard key={task.id} task={task} sectionOptions={sectionOptions} onSelect={onSelectTask} />
))}
</SortableContext>
</DndContext>
);
})()}
</div>
)}
</div>
);
})()}
{/* 기반업무 고정 영역 */}
{(() => {
const routineTasks = orderedTasks.filter((t) => t.taskType === '상시업무');
const routineTasks = orderedTasks.filter((t) => t.taskType === '상시업무' || t.taskType === '기반업무');
return (
<div className="shrink-0 border-t border-slate-200/80 bg-white/75" style={{ height: 300 }}>
<div
ref={setRoutineDropRef}
className={`shrink-0 border-t border-slate-200/80 bg-white/75 transition-colors ${isRoutineOver ? 'bg-amber-50/60' : ''}`}
style={{ height: 300 }}
>
<div className="h-full overflow-y-auto p-4">
{routineTasks.length === 0 ? (
<div className="flex h-full items-center justify-center text-base text-slate-300">
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={routineTasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
{routineTasks.map((task) => (
<SortableTaskCard key={task.id} task={task} sectionOptions={sectionOptions} onSelect={onSelectTask} />
))}
</SortableContext>
</DndContext>
<SortableContext items={routineTasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
{routineTasks.map((task) => (
<SortableTaskCard key={task.id} task={task} sectionOptions={sectionOptions} onSelect={onSelectTask} />
))}
</SortableContext>
)}
</div>
</div>

View File

@@ -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에 전달하지 않음

View File

@@ -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
<div className="w-px h-5 bg-gray-200" />
{/* 유형 필터 */}
<div className="flex gap-1">
{['전체', '프로젝트', '상시업무'].map((t) => (
{['전체', '실행과제', '기반업무'].map((t) => (
<button key={t} onClick={() => setFilterType(t)}
className={`text-xs font-semibold px-3 py-1.5 rounded-lg transition-colors ${
filterType === t ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
@@ -162,9 +170,9 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan
<td className="px-4 py-3 text-gray-600 font-medium whitespace-nowrap">{task.section ?? '-'}</td>
<td className="px-4 py-3">
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${
task.taskType === '상시업무' ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700'
(task.taskType === '상시업무' || task.taskType === '기반업무') ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700'
}`}>
{task.taskType ?? '프로젝트'}
{task.taskType === '상시업무' ? '기반업무' : task.taskType === '프로젝트' ? '실행과제' : (task.taskType ?? '실행과제')}
</span>
</td>
<td className="px-4 py-3 font-semibold text-gray-800 max-w-[200px] truncate">{task.title}</td>
@@ -208,8 +216,8 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan
{/* 하단 요약 */}
<div className="px-6 py-3 border-t border-gray-100 shrink-0 flex items-center gap-4 text-xs text-gray-400">
<span> <strong className="text-gray-700">{filtered.length}</strong></span>
<span> <strong className="text-blue-600">{filtered.filter(t => t.taskType !== '상시업무').length}</strong></span>
<span> <strong className="text-amber-600">{filtered.filter(t => t.taskType === '상시업무').length}</strong></span>
<span> <strong className="text-blue-600">{filtered.filter(t => t.taskType !== '상시업무' && t.taskType !== '기반업무').length}</strong></span>
<span> <strong className="text-amber-600">{filtered.filter(t => t.taskType === '상시업무' || t.taskType === '기반업무').length}</strong></span>
</div>
</div>

View File

@@ -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<string | null>(null);
const [columnOrders, setColumnOrders] = useState<Record<string, string[]>>({});
const saveTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
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<string, string> }) =>
apiClient.patch(`/columns/${encodeURIComponent(section)}`, data),
});
const patchTask = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, any> }) =>
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<string, any> = {};
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 (
<div className="flex h-screen items-center justify-center bg-slate-100">
@@ -73,10 +201,7 @@ export default function DashboardPage() {
}
return (
<div
className="relative flex h-screen flex-col overflow-hidden bg-[#eef2f5]"
style={{ fontSize: '18px' }}
>
<div className="relative flex h-screen flex-col overflow-hidden bg-[#eef2f5]" style={{ fontSize: '18px' }}>
<DashboardHeader
quarter={QUARTER}
stats={stats}
@@ -87,78 +212,55 @@ export default function DashboardPage() {
/>
<main className="relative flex min-h-0 flex-1 overflow-hidden px-5 py-5">
{/* ── 맨 왼쪽 구분 라벨 컬럼 ── */}
{/* ── 좌측 라벨 컬럼 ── */}
<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">
{/* 헤더 높이 맞춤 (DepartmentColumn 헤더 h-10) */}
<div className="h-10 shrink-0 border-b border-slate-100 bg-slate-50" />
{/* 실행과제 영역 (flex-1) */}
<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>
<span className="select-none text-sm font-black tracking-[0.35em] text-slate-800" style={{ writingMode: 'vertical-rl' }}></span>
</div>
{/* 기반업무 영역 (고정 300px) */}
<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>
<span className="select-none text-sm font-black tracking-[0.35em] text-slate-800" style={{ writingMode: 'vertical-rl' }}></span>
</div>
</div>
<div className="grid h-full min-h-0 flex-1 grid-cols-4 gap-4 overflow-hidden">
<DepartmentColumn
title="인사관리"
titleEn="HR Management"
tasks={sec1Tasks}
headerBg=""
headerStyle={{ background: 'linear-gradient(120deg, #2a4a8a 0%, #3461b8 50%, #3d72d0 100%)' }}
storageKey="col_sec1"
section="인사관리"
quarter={QUARTER}
onSelectTask={(t) => sendTaskSelected(t.id)}
sectionOptions={sectionOptions}
/>
<DepartmentColumn
title="학습성장"
titleEn="Learning & Growth"
tasks={sec2Tasks}
headerBg=""
headerStyle={{ background: 'linear-gradient(120deg, #5b2d8a 0%, #7340b8 50%, #8a52d0 100%)' }}
storageKey="col_sec2"
section="학습성장"
quarter={QUARTER}
onSelectTask={(t) => sendTaskSelected(t.id)}
sectionOptions={sectionOptions}
/>
<DepartmentColumn
title="운영지원"
titleEn="Operations"
tasks={sec3Tasks}
headerBg=""
headerStyle={{ background: 'linear-gradient(120deg, #0d6080 0%, #0d7a9a 50%, #0e92b8 100%)' }}
storageKey="col_sec3"
section="운영지원"
quarter={QUARTER}
onSelectTask={(t) => sendTaskSelected(t.id)}
sectionOptions={sectionOptions}
/>
<DepartmentColumn
title="전산관리"
titleEn="IT Management"
tasks={sec4Tasks}
headerBg=""
headerStyle={{ background: 'linear-gradient(120deg, #0a6040 0%, #0d8050 50%, #10a060 100%)' }}
storageKey="col_sec4"
section="전산관리"
quarter={QUARTER}
onSelectTask={(t) => sendTaskSelected(t.id)}
sectionOptions={sectionOptions}
/>
</div>
<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}
/>
))}
</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>
</div>
</div>
) : null}
</DragOverlay>
</DndContext>
</main>
{showTaskManager && (