fix: column-level context menu capture for all departments

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
EENE Dashboard
2026-06-02 13:01:27 +09:00
parent 2f60ec6ab1
commit 395680ea20
6 changed files with 139 additions and 159 deletions

View File

@@ -1,13 +1,7 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { DraggableAttributes } from '@dnd-kit/core';
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import { apiClient } from '../../lib/apiClient';
import { ContextMenu } from '../common/ContextMenu';
import { TaskModal } from '../common/TaskModal';
import type { TaskFormData } from '../common/TaskModal';
import type { Task } from '../../types';
const STATUS_STYLE: Record<string, string> = {
@@ -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<string, unknown>) => apiClient.post('/tasks', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
});
const patch = useMutation({
mutationFn: (data: Record<string, unknown>) => 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 처리 */}
<div onContextMenu={handleContextMenu}>
<TaskCard
task={task}
dragRef={setNodeRef}
dragStyle={dragStyle}
dragAttributes={attributes}
dragListeners={listeners}
sectionOptions={sectionOptions}
onCardClick={() => { if (!isDragging) onSelect?.(task); }}
/>
</div>
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
onClose={() => setCtxMenu(null)}
items={[
{ icon: '✚', label: '업무 추가', onClick: () => setModalMode('add') },
{ icon: '✏', label: '업무 수정', onClick: () => setModalMode('edit') },
{ icon: '🗑', label: '업무 삭제', onClick: handleDelete, danger: true },
]}
/>
)}
{modalMode === 'add' && (
<TaskModal
mode="add"
defaultSection={task.section ?? '인사관리'}
defaultQuarter={task.quarter}
sectionOptions={sectionOptions}
onSave={handleAdd}
onClose={() => setModalMode(null)}
/>
)}
{modalMode === 'edit' && (
<TaskModal
mode="edit"
task={task}
sectionOptions={sectionOptions}
onSave={handleEdit}
onClose={() => setModalMode(null)}
/>
)}
</>
<TaskCard
task={task}
dragRef={setNodeRef}
dragStyle={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.35 : 1,
}}
dragAttributes={attributes}
dragListeners={listeners}
onCardClick={() => { 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<HTMLDivElement> | undefined}
onClick={onCardClick}
>
{/* ── 제목 + 진행률 ── */}
<div className="flex items-start justify-between gap-3">
<span className="min-w-0 flex-1 truncate text-2xl font-black leading-snug text-slate-900">
{task.title}
@@ -210,7 +96,6 @@ export function TaskCard({
</span>
</div>
{/* ── 기간 + 상태 ── */}
<div className="mt-1 flex items-center gap-2">
<span className="flex-1 truncate text-sm font-semibold text-slate-400">
{task.showDate && (task.startDate || task.dueDate)
@@ -224,7 +109,6 @@ export function TaskCard({
)}
</div>
{/* ── 키워드 ── */}
{task.keywords && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{task.keywords.split(',').map((kw, i) => (
@@ -235,12 +119,10 @@ export function TaskCard({
</div>
)}
{/* ── 내용 ── */}
{task.showDescription && task.description && (
<div className="mt-2 truncate text-2xl text-slate-700">{task.description}</div>
)}
{/* ── 이슈 ── */}
{task.showIssue && task.issueNote && (
<div className="mt-1.5 flex min-w-0 gap-2 rounded-xl bg-red-50/80 px-2 py-1 text-2xl text-red-500">
<span className="shrink-0"></span>