fix: context menu via wrapper div, normalize taskType old/new values
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<TaskFormData>({
|
||||
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 ?? '',
|
||||
|
||||
@@ -10,7 +10,6 @@ import { TaskModal } from '../common/TaskModal';
|
||||
import type { TaskFormData } from '../common/TaskModal';
|
||||
import type { Task } from '../../types';
|
||||
|
||||
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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
|
||||
task={task}
|
||||
dragRef={setNodeRef}
|
||||
dragStyle={style}
|
||||
dragAttributes={attributes}
|
||||
dragListeners={listeners}
|
||||
sectionOptions={sectionOptions}
|
||||
onCardClick={handleClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 메인 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<string, unknown>) => 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 (
|
||||
<>
|
||||
<div
|
||||
ref={dragRef}
|
||||
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에 전달하지 않음
|
||||
// dnd-kit은 pointerdown→pointerup 시 synthetic click을 발화하는데
|
||||
// 이것이 우클릭에도 적용되어 상세창이 열리는 원인
|
||||
if (e.button !== 0) return;
|
||||
dragListeners?.onPointerDown?.(e);
|
||||
}}
|
||||
onClick={onCardClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/* ── 상단: 제목 + 진행률 ── */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-2 min-w-0 flex-1">
|
||||
<span className="min-w-0 truncate text-2xl font-black leading-snug text-slate-900">
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`mt-0.5 min-w-[4rem] shrink-0 text-right text-2xl font-black ${
|
||||
task.progress >= 70 ? 'text-emerald-500' :
|
||||
task.progress >= 40 ? 'text-blue-400' :
|
||||
'text-orange-400'
|
||||
}`}>
|
||||
{task.progress}%
|
||||
</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)
|
||||
? `${task.startDate ? fmtDate(task.startDate) : '?'} ~ ${task.dueDate ? fmtDate(task.dueDate) : '?'}`
|
||||
: ''}
|
||||
</span>
|
||||
{task.showStatus && (
|
||||
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-sm font-black shadow-sm ${STATUS_STYLE[task.status]}`}>
|
||||
{STATUS_LABEL[task.status]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 키워드 ── */}
|
||||
{task.keywords && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{task.keywords.split(',').map((kw, i) => (
|
||||
<span key={i} className="px-2 py-0.5 bg-slate-100 text-slate-600 text-sm font-bold rounded-md border border-slate-200/60">
|
||||
{kw.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── description ── */}
|
||||
{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>
|
||||
<span className="truncate">{task.issueNote}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 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}
|
||||
@@ -254,19 +142,16 @@ export function TaskCard({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 추가 모달 ── */}
|
||||
{modalMode === 'add' && (
|
||||
<TaskModal
|
||||
mode="add"
|
||||
defaultSection={task.section ?? 'HR'}
|
||||
defaultSection={task.section ?? '인사관리'}
|
||||
defaultQuarter={task.quarter}
|
||||
sectionOptions={sectionOptions}
|
||||
onSave={handleAdd}
|
||||
onClose={() => setModalMode(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 수정 모달 ── */}
|
||||
{modalMode === 'edit' && (
|
||||
<TaskModal
|
||||
mode="edit"
|
||||
@@ -279,3 +164,89 @@ export function TaskCard({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 순수 표시 컴포넌트 (드래그 핸들만 담당) ────────────────
|
||||
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 (
|
||||
<div
|
||||
ref={dragRef}
|
||||
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) => {
|
||||
if (e.button !== 0) return; // 우클릭은 dnd-kit에 전달하지 않음
|
||||
dragListeners?.onPointerDown?.(e);
|
||||
}}
|
||||
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}
|
||||
</span>
|
||||
<span className={`mt-0.5 min-w-[4rem] shrink-0 text-right text-2xl font-black ${
|
||||
task.progress >= 70 ? 'text-emerald-500' :
|
||||
task.progress >= 40 ? 'text-blue-400' : 'text-orange-400'
|
||||
}`}>
|
||||
{task.progress}%
|
||||
</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)
|
||||
? `${task.startDate ? fmtDate(task.startDate) : '?'} ~ ${task.dueDate ? fmtDate(task.dueDate) : '?'}`
|
||||
: ''}
|
||||
</span>
|
||||
{task.showStatus && (
|
||||
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-sm font-black shadow-sm ${STATUS_STYLE[task.status]}`}>
|
||||
{STATUS_LABEL[task.status]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 키워드 ── */}
|
||||
{task.keywords && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{task.keywords.split(',').map((kw, i) => (
|
||||
<span key={i} className="rounded-md border border-slate-200/60 bg-slate-100 px-2 py-0.5 text-sm font-bold text-slate-600">
|
||||
{kw.trim()}
|
||||
</span>
|
||||
))}
|
||||
</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>
|
||||
<span className="truncate">{task.issueNote}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user