From 395680ea20e00f1e828d40c95f90ffa5ea9b7317 Mon Sep 17 00:00:00 2001 From: EENE Dashboard Date: Tue, 2 Jun 2026 13:01:27 +0900 Subject: [PATCH] fix: column-level context menu capture for all departments Co-authored-by: Cursor --- frontend/src/components/common/TaskModal.tsx | 11 +- .../components/dashboard/DepartmentColumn.tsx | 91 ++++++++++- .../src/components/dashboard/TaskCard.tsx | 148 ++---------------- .../src/components/dashboard/TaskManager.tsx | 14 +- frontend/src/lib/taskType.ts | 16 ++ frontend/src/pages/DashboardPage.tsx | 18 ++- 6 files changed, 139 insertions(+), 159 deletions(-) create mode 100644 frontend/src/lib/taskType.ts diff --git a/frontend/src/components/common/TaskModal.tsx b/frontend/src/components/common/TaskModal.tsx index dffe256..61b7e48 100644 --- a/frontend/src/components/common/TaskModal.tsx +++ b/frontend/src/components/common/TaskModal.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { createPortal } from 'react-dom'; import type { Task } from '../../types'; +import { normalizeTaskType } from '../../lib/taskType'; const STATUS_OPTIONS = [ { value: 'TODO', label: '대기' }, @@ -44,18 +45,12 @@ export function TaskModal({ mode, task, defaultSection = 'HR', defaultQuarter = return new Date(iso).toISOString().slice(0, 10); }; - // 기존 DB값(프로젝트/상시업무)을 새 값으로 정규화 - const normalizeTaskType = (t: string | null | undefined) => { - if (!t || t === '프로젝트') return '실행과제'; - if (t === '상시업무') return '기반업무'; - return t; // 이미 '실행과제' 또는 '기반업무' - }; - + // DB값(프로젝트/상시업무) → 새 값(실행과제/기반업무) 정규화 const [form, setForm] = useState({ title: task?.title ?? '', section: task?.section ?? defaultSection, tag: task?.tag ?? '', - taskType: normalizeTaskType(task?.taskType), + taskType: task?.taskType ? normalizeTaskType(task.taskType) : '실행과제', status: task?.status ?? 'TODO', progress: task?.progress ?? 0, description: task?.description ?? '', diff --git a/frontend/src/components/dashboard/DepartmentColumn.tsx b/frontend/src/components/dashboard/DepartmentColumn.tsx index 2e44326..4643396 100644 --- a/frontend/src/components/dashboard/DepartmentColumn.tsx +++ b/frontend/src/components/dashboard/DepartmentColumn.tsx @@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useDroppable } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { apiClient } from '../../lib/apiClient'; +import { isProjectTask, isRoutineTask } from '../../lib/taskType'; import { SortableTaskCard } from './TaskCard'; import { ContextMenu } from '../common/ContextMenu'; import { TaskModal } from '../common/TaskModal'; @@ -114,8 +115,11 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi const subtitle = colConfig?.subtitle ?? initialSubtitle; const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; type: 'header' | 'list' } | null>(null); + const [cardMenu, setCardMenu] = useState<{ x: number; y: number; task: Task } | null>(null); const [showAddModal, setShowAddModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); const [showHeaderModal, setShowHeaderModal] = useState(false); + const [editingTask, setEditingTask] = useState(null); // ── useDroppable: 컬럼 드롭존 등록 ────────────────────── const { setNodeRef: setProjectDropRef, isOver: isProjectOver } = useDroppable({ id: `drop::project::${section}` }); @@ -140,11 +144,36 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }), }); + const patch = useMutation({ + mutationFn: ({ id, data }: { id: string; data: Record }) => + apiClient.patch(`/tasks/${id}`, data), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }), + }); + + const remove = useMutation({ + mutationFn: (id: string) => apiClient.delete(`/tasks/${id}`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }), + }); + + // capture 단계에서 카드 우클릭 처리 — dnd-kit보다 먼저 실행됨 + const handleColumnContextMenuCapture = (e: React.MouseEvent) => { + const card = (e.target as HTMLElement).closest('[data-task-id]'); + if (!card) return; + e.preventDefault(); + e.stopPropagation(); + const taskId = card.getAttribute('data-task-id'); + const task = tasks.find((t) => t.id === taskId); + if (task) { + setCtxMenu(null); + setCardMenu({ x: e.clientX, y: e.clientY, task }); + } + }; + const handleListContextMenu = (e: React.MouseEvent) => { - // 카드 위에서 발생한 이벤트는 무시 (TaskCard의 자체 메뉴가 처리) if ((e.target as HTMLElement).closest('[data-task-card]')) return; e.preventDefault(); e.stopPropagation(); + setCardMenu(null); setCtxMenu({ x: e.clientX, y: e.clientY, type: 'list' }); }; @@ -175,9 +204,36 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi setShowAddModal(false); }; + const handleEdit = (data: TaskFormData) => { + if (!editingTask) return; + patch.mutate({ + id: editingTask.id, + data: { + title: data.title, section: data.section || null, + taskType: data.taskType || null, status: data.status, progress: data.progress, + description: data.description || null, issueNote: data.issueNote || null, + startDate: data.startDate || null, dueDate: data.dueDate || null, + showDate: data.showDate, showDescription: data.showDescription, + showStatus: data.showStatus, showIssue: data.showIssue, + keywords: data.keywords || null, + }, + }); + setShowEditModal(false); + setEditingTask(null); + }; + + const handleDeleteCard = (task: Task) => { + if (window.confirm(`"${task.title}" 업무를 삭제하시겠습니까?`)) { + remove.mutate(task.id); + } + }; + return ( <> -
+
{/* 컬럼 헤더 (noHeader 시 숨김) */} {!noHeader && (
{ - const projectTasks = orderedTasks.filter((t) => t.taskType !== '상시업무' && t.taskType !== '기반업무'); + const projectTasks = orderedTasks.filter((t) => isProjectTask(t.taskType)); return (
{ - const routineTasks = orderedTasks.filter((t) => t.taskType === '상시업무' || t.taskType === '기반업무'); + const routineTasks = orderedTasks.filter((t) => isRoutineTask(t.taskType)); return (
{routineTasks.length === 0 ? ( @@ -246,7 +303,21 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi })()}
- {/* 컨텍스트 메뉴 */} + {/* 카드 우클릭 메뉴 (추가/수정/삭제) */} + {cardMenu && ( + setCardMenu(null)} + items={[ + { icon: '✚', label: '업무 추가', onClick: () => { setShowAddModal(true); setCardMenu(null); } }, + { icon: '✏', label: '업무 수정', onClick: () => { setEditingTask(cardMenu.task); setShowEditModal(true); setCardMenu(null); } }, + { icon: '🗑', label: '업무 삭제', onClick: () => { handleDeleteCard(cardMenu.task); setCardMenu(null); }, danger: true }, + ]} + /> + )} + + {/* 빈 영역/헤더 우클릭 메뉴 */} {ctxMenu && ( )} + {showEditModal && editingTask && ( + { setShowEditModal(false); setEditingTask(null); }} + /> + )} + {/* 헤더 편집 모달 */} {showHeaderModal && ( = { @@ -34,145 +28,38 @@ function fmtDate(iso: string | null | undefined): string { type SectionOption = { value: string; label: string }; -// ─── 드래그 + 컨텍스트 메뉴 래퍼 ─────────────────────────── -// 컨텍스트 메뉴를 dnd-kit dragListeners 밖의 wrapper div에서 처리해 -// 어떤 컬럼에서도 우클릭이 일관되게 동작하도록 함 export function SortableTaskCard({ task, - sectionOptions, onSelect, }: { task: Task; sectionOptions?: SectionOption[]; onSelect?: (task: Task) => void; }) { - const queryClient = useQueryClient(); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id }); - const dragStyle: React.CSSProperties = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.35 : 1, - }; - - // ── mutations ────────────────────────────────────────────── - const create = useMutation({ - mutationFn: (data: Record) => apiClient.post('/tasks', data), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }), - }); - - const patch = useMutation({ - mutationFn: (data: Record) => apiClient.patch(`/tasks/${task.id}`, data), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }), - }); - - const remove = useMutation({ - mutationFn: () => apiClient.delete(`/tasks/${task.id}`), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }), - }); - - // ── 상태 ─────────────────────────────────────────────────── - const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null); - const [modalMode, setModalMode] = useState<'add' | 'edit' | null>(null); - - // ── 우클릭: wrapper div에서 처리 (dnd-kit 간섭 없음) ─────── - const handleContextMenu = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setCtxMenu({ x: e.clientX, y: e.clientY }); - }; - - const handleAdd = (data: TaskFormData) => { - create.mutate({ - title: data.title, section: data.section || null, - taskType: data.taskType || null, status: data.status, progress: data.progress, - description: data.description || null, issueNote: data.issueNote || null, - startDate: data.startDate || null, dueDate: data.dueDate || null, - showDate: data.showDate, showDescription: data.showDescription, - showStatus: data.showStatus, showIssue: data.showIssue, - keywords: data.keywords || null, - quarter: data.quarter, priority: 'MEDIUM', creatorId: 'system', - }); - setModalMode(null); - }; - - const handleEdit = (data: TaskFormData) => { - patch.mutate({ - title: data.title, section: data.section || null, - taskType: data.taskType || null, status: data.status, progress: data.progress, - description: data.description || null, issueNote: data.issueNote || null, - startDate: data.startDate || null, dueDate: data.dueDate || null, - showDate: data.showDate, showDescription: data.showDescription, - showStatus: data.showStatus, showIssue: data.showIssue, - keywords: data.keywords || null, - }); - setModalMode(null); - }; - - const handleDelete = () => { - if (window.confirm(`"${task.title}" 업무를 삭제하시겠습니까?`)) remove.mutate(); - }; - return ( - <> - {/* wrapper div — dnd-kit 외부에서 onContextMenu 처리 */} -
- { if (!isDragging) onSelect?.(task); }} - /> -
- - {ctxMenu && ( - setCtxMenu(null)} - items={[ - { icon: '✚', label: '업무 추가', onClick: () => setModalMode('add') }, - { icon: '✏', label: '업무 수정', onClick: () => setModalMode('edit') }, - { icon: '🗑', label: '업무 삭제', onClick: handleDelete, danger: true }, - ]} - /> - )} - - {modalMode === 'add' && ( - setModalMode(null)} - /> - )} - {modalMode === 'edit' && ( - setModalMode(null)} - /> - )} - + { if (!isDragging) onSelect?.(task); }} + /> ); } -// ─── 순수 표시 컴포넌트 (드래그 핸들만 담당) ──────────────── export function TaskCard({ task, dragRef, dragStyle, dragAttributes, dragListeners, - sectionOptions: _sectionOptions, onCardClick, }: { task: Task; @@ -180,7 +67,6 @@ export function TaskCard({ dragStyle?: React.CSSProperties; dragAttributes?: DraggableAttributes; dragListeners?: SyntheticListenerMap; - sectionOptions?: SectionOption[]; onCardClick?: () => void; }) { return ( @@ -188,16 +74,16 @@ export function TaskCard({ ref={dragRef} style={dragStyle} {...dragAttributes} - {...dragListeners} data-task-card="true" + data-task-id={task.id} 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) => { - if (e.button !== 0) return; // 우클릭은 dnd-kit에 전달하지 않음 + if (e.button !== 0) return; dragListeners?.onPointerDown?.(e); }} + onKeyDown={dragListeners?.onKeyDown as React.KeyboardEventHandler | undefined} onClick={onCardClick} > - {/* ── 제목 + 진행률 ── */}
{task.title} @@ -210,7 +96,6 @@ export function TaskCard({
- {/* ── 기간 + 상태 ── */}
{task.showDate && (task.startDate || task.dueDate) @@ -224,7 +109,6 @@ export function TaskCard({ )}
- {/* ── 키워드 ── */} {task.keywords && (
{task.keywords.split(',').map((kw, i) => ( @@ -235,12 +119,10 @@ export function TaskCard({
)} - {/* ── 내용 ── */} {task.showDescription && task.description && (
{task.description}
)} - {/* ── 이슈 ── */} {task.showIssue && task.issueNote && (
diff --git a/frontend/src/components/dashboard/TaskManager.tsx b/frontend/src/components/dashboard/TaskManager.tsx index 0f0d6b6..84bd5fa 100644 --- a/frontend/src/components/dashboard/TaskManager.tsx +++ b/frontend/src/components/dashboard/TaskManager.tsx @@ -5,6 +5,7 @@ import { apiClient } from '../../lib/apiClient'; import { TaskModal } from '../common/TaskModal'; import type { TaskFormData } from '../common/TaskModal'; import type { Task } from '../../types'; +import { isProjectTask, isRoutineTask } from '../../lib/taskType'; const STATUS_LABEL: Record = { IN_PROGRESS: '진행', REVIEW: '보류', TODO: '대기', DONE: '완료', CANCELLED: '취소', @@ -47,11 +48,10 @@ 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 === '상시업무'; + if (filter === '실행과제') return isProjectTask(taskType); + if (filter === '기반업무') return isRoutineTask(taskType); return taskType === filter; }; @@ -170,9 +170,9 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan {task.section ?? '-'} - {task.taskType === '상시업무' ? '기반업무' : task.taskType === '프로젝트' ? '실행과제' : (task.taskType ?? '실행과제')} + {isRoutineTask(task.taskType) ? '기반업무' : '실행과제'} {task.title} @@ -216,8 +216,8 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan {/* 하단 요약 */}
전체 {filtered.length} - 실행과제 {filtered.filter(t => t.taskType !== '상시업무' && t.taskType !== '기반업무').length} - 기반업무 {filtered.filter(t => t.taskType === '상시업무' || t.taskType === '기반업무').length} + 실행과제 {filtered.filter(t => isProjectTask(t.taskType)).length} + 기반업무 {filtered.filter(t => isRoutineTask(t.taskType)).length}
diff --git a/frontend/src/lib/taskType.ts b/frontend/src/lib/taskType.ts new file mode 100644 index 0000000..9a501f3 --- /dev/null +++ b/frontend/src/lib/taskType.ts @@ -0,0 +1,16 @@ +/** 기반업무(구: 상시업무) 여부 */ +export function isRoutineTask(taskType: string | null | undefined): boolean { + return taskType === '상시업무' || taskType === '기반업무'; +} + +/** 실행과제(구: 프로젝트) 여부 */ +export function isProjectTask(taskType: string | null | undefined): boolean { + return !isRoutineTask(taskType); +} + +/** DB/폼 값 정규화 */ +export function normalizeTaskType(taskType: string | null | undefined): string { + if (!taskType || taskType === '프로젝트') return '실행과제'; + if (taskType === '상시업무') return '기반업무'; + return taskType; +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index a50ee50..4e34b57 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -18,6 +18,7 @@ import { DepartmentColumn } from '../components/dashboard/DepartmentColumn'; import { TaskManager } from '../components/dashboard/TaskManager'; import { useSocket } from '../contexts/SocketContext'; import { sendTaskSelected, openDetailWindow } from '../lib/dualMonitor'; +import { isRoutineTask } from '../lib/taskType'; const QUARTER = '2026-Q2'; const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'] as const; @@ -131,9 +132,9 @@ export default function DashboardPage() { const updateData: Record = {}; if (targetSection !== srcSection) updateData.section = targetSection; - if (areaType === 'routine' && draggedTask.taskType !== '상시업무' && draggedTask.taskType !== '기반업무') { + if (areaType === 'routine' && !isRoutineTask(draggedTask.taskType)) { updateData.taskType = '기반업무'; - } else if (areaType === 'project' && (draggedTask.taskType === '상시업무' || draggedTask.taskType === '기반업무')) { + } else if (areaType === 'project' && isRoutineTask(draggedTask.taskType)) { updateData.taskType = '실행과제'; } if (Object.keys(updateData).length > 0) { @@ -147,11 +148,16 @@ export default function DashboardPage() { if (!overTask) return; const dstSection = overTask.section ?? ''; + const typeChanged = isRoutineTask(draggedTask.taskType) !== isRoutineTask(overTask.taskType); - if (dstSection !== srcSection) { - // 컬럼 간 이동 — 섹션 변경 - patchTask.mutate({ id: activeId, data: { section: dstSection } }); - return; + if (dstSection !== srcSection || typeChanged) { + const updateData: Record = {}; + if (dstSection !== srcSection) updateData.section = dstSection; + if (typeChanged) { + updateData.taskType = isRoutineTask(overTask.taskType) ? '기반업무' : '실행과제'; + } + patchTask.mutate({ id: activeId, data: updateData }); + if (dstSection !== srcSection) return; } // ── 같은 컬럼 내 순서 변경 ───────────────────────────────────