From 2f60ec6ab1e585423784e1ebb943ae10ab14eb79 Mon Sep 17 00:00:00 2001 From: EENE Dashboard Date: Tue, 2 Jun 2026 09:20:32 +0900 Subject: [PATCH] fix: context menu via wrapper div, normalize taskType old/new values Co-authored-by: Cursor --- frontend/src/components/common/TaskModal.tsx | 9 +- .../src/components/dashboard/TaskCard.tsx | 295 ++++++++---------- 2 files changed, 141 insertions(+), 163 deletions(-) diff --git a/frontend/src/components/common/TaskModal.tsx b/frontend/src/components/common/TaskModal.tsx index fcd3c0b..dffe256 100644 --- a/frontend/src/components/common/TaskModal.tsx +++ b/frontend/src/components/common/TaskModal.tsx @@ -44,11 +44,18 @@ 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; // 이미 '실행과제' 또는 '기반업무' + }; + const [form, setForm] = useState({ title: task?.title ?? '', section: task?.section ?? defaultSection, tag: task?.tag ?? '', - taskType: task?.taskType ?? '상시업무', + taskType: normalizeTaskType(task?.taskType), status: task?.status ?? 'TODO', progress: task?.progress ?? 0, description: task?.description ?? '', diff --git a/frontend/src/components/dashboard/TaskCard.tsx b/frontend/src/components/dashboard/TaskCard.tsx index 5befeea..28432a8 100644 --- a/frontend/src/components/dashboard/TaskCard.tsx +++ b/frontend/src/components/dashboard/TaskCard.tsx @@ -10,7 +10,6 @@ import { TaskModal } from '../common/TaskModal'; import type { TaskFormData } from '../common/TaskModal'; import type { Task } from '../../types'; - const STATUS_STYLE: Record = { IN_PROGRESS: 'bg-blue-500 text-white shadow-blue-500/20', REVIEW: 'bg-amber-400 text-white shadow-amber-400/20', @@ -27,67 +26,37 @@ const STATUS_LABEL: Record = { CANCELLED: '취소', }; - -// ─── 날짜 포맷 헬퍼 ───────────────────────────────────────── function fmtDate(iso: string | null | undefined): string { if (!iso) return ''; const d = new Date(iso); - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, '0'); - const dd = String(d.getDate()).padStart(2, '0'); - return `${y}.${m}.${dd}`; + return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`; } type SectionOption = { value: string; label: string }; -// ─── 드래그 가능한 래퍼 ────────────────────────────────────── -export function SortableTaskCard({ task, sectionOptions, onSelect }: { task: Task; sectionOptions?: SectionOption[]; onSelect?: (task: Task) => void }) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id }); - - const style: React.CSSProperties = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.4 : 1, - }; - - const handleClick = () => { - if (!isDragging) onSelect?.(task); - }; - - return ( - - ); -} - -// ─── 메인 TaskCard ────────────────────────────────────────── -export function TaskCard({ +// ─── 드래그 + 컨텍스트 메뉴 래퍼 ─────────────────────────── +// 컨텍스트 메뉴를 dnd-kit dragListeners 밖의 wrapper div에서 처리해 +// 어떤 컬럼에서도 우클릭이 일관되게 동작하도록 함 +export function SortableTaskCard({ task, - dragRef, - dragStyle, - dragAttributes, - dragListeners, sectionOptions, - onCardClick, + onSelect, }: { task: Task; - dragRef?: (node: HTMLElement | null) => void; - dragStyle?: React.CSSProperties; - dragAttributes?: DraggableAttributes; - dragListeners?: SyntheticListenerMap; sectionOptions?: SectionOption[]; - onCardClick?: () => void; + onSelect?: (task: Task) => void; }) { const queryClient = useQueryClient(); - // ── API mutations ────────────────────────────────────────── + 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'] }), @@ -103,144 +72,63 @@ export function TaskCard({ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }), }); - // ── 컨텍스트 메뉴 + 모달 상태 ───────────────────────────── - const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null); + // ── 상태 ─────────────────────────────────────────────────── + 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', + 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, + 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(); - } + if (window.confirm(`"${task.title}" 업무를 삭제하시겠습니까?`)) remove.mutate(); }; return ( <> -
{ - // 우클릭(button !== 0)을 dnd-kit에 전달하지 않음 - // dnd-kit은 pointerdown→pointerup 시 synthetic click을 발화하는데 - // 이것이 우클릭에도 적용되어 상세창이 열리는 원인 - if (e.button !== 0) return; - dragListeners?.onPointerDown?.(e); - }} - onClick={onCardClick} - onContextMenu={handleContextMenu} - > - {/* ── 상단: 제목 + 진행률 ── */} -
-
- - {task.title} - -
- = 70 ? 'text-emerald-500' : - task.progress >= 40 ? 'text-blue-400' : - 'text-orange-400' - }`}> - {task.progress}% - -
- - - {/* ── 기간 + 상태 ── */} -
- - {task.showDate && (task.startDate || task.dueDate) - ? `${task.startDate ? fmtDate(task.startDate) : '?'} ~ ${task.dueDate ? fmtDate(task.dueDate) : '?'}` - : ''} - - {task.showStatus && ( - - {STATUS_LABEL[task.status]} - - )} -
- - {/* ── 키워드 ── */} - {task.keywords && ( -
- {task.keywords.split(',').map((kw, i) => ( - - {kw.trim()} - - ))} -
- )} - - {/* ── description ── */} - {task.showDescription && task.description && ( -
- {task.description} -
- )} - - {/* ── 이슈 메모 ── */} - {task.showIssue && task.issueNote && ( -
- - {task.issueNote} -
- )} + {/* wrapper div — dnd-kit 외부에서 onContextMenu 처리 */} +
+ { if (!isDragging) onSelect?.(task); }} + />
- {/* ── 컨텍스트 메뉴 ── */} {ctxMenu && ( )} - {/* ── 추가 모달 ── */} {modalMode === 'add' && ( setModalMode(null)} /> )} - - {/* ── 수정 모달 ── */} {modalMode === 'edit' && ( ); } + +// ─── 순수 표시 컴포넌트 (드래그 핸들만 담당) ──────────────── +export function TaskCard({ + task, + dragRef, + dragStyle, + dragAttributes, + dragListeners, + sectionOptions: _sectionOptions, + onCardClick, +}: { + task: Task; + dragRef?: (node: HTMLElement | null) => void; + dragStyle?: React.CSSProperties; + dragAttributes?: DraggableAttributes; + dragListeners?: SyntheticListenerMap; + sectionOptions?: SectionOption[]; + onCardClick?: () => void; +}) { + return ( +
{ + if (e.button !== 0) return; // 우클릭은 dnd-kit에 전달하지 않음 + dragListeners?.onPointerDown?.(e); + }} + onClick={onCardClick} + > + {/* ── 제목 + 진행률 ── */} +
+ + {task.title} + + = 70 ? 'text-emerald-500' : + task.progress >= 40 ? 'text-blue-400' : 'text-orange-400' + }`}> + {task.progress}% + +
+ + {/* ── 기간 + 상태 ── */} +
+ + {task.showDate && (task.startDate || task.dueDate) + ? `${task.startDate ? fmtDate(task.startDate) : '?'} ~ ${task.dueDate ? fmtDate(task.dueDate) : '?'}` + : ''} + + {task.showStatus && ( + + {STATUS_LABEL[task.status]} + + )} +
+ + {/* ── 키워드 ── */} + {task.keywords && ( +
+ {task.keywords.split(',').map((kw, i) => ( + + {kw.trim()} + + ))} +
+ )} + + {/* ── 내용 ── */} + {task.showDescription && task.description && ( +
{task.description}
+ )} + + {/* ── 이슈 ── */} + {task.showIssue && task.issueNote && ( +
+ + {task.issueNote} +
+ )} +
+ ); +}