fix: column-level context menu capture for all departments
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import type { Task } from '../../types';
|
import type { Task } from '../../types';
|
||||||
|
import { normalizeTaskType } from '../../lib/taskType';
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
{ value: 'TODO', label: '대기' },
|
{ value: 'TODO', label: '대기' },
|
||||||
@@ -44,18 +45,12 @@ export function TaskModal({ mode, task, defaultSection = 'HR', defaultQuarter =
|
|||||||
return new Date(iso).toISOString().slice(0, 10);
|
return new Date(iso).toISOString().slice(0, 10);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 기존 DB값(프로젝트/상시업무)을 새 값으로 정규화
|
// DB값(프로젝트/상시업무) → 새 값(실행과제/기반업무) 정규화
|
||||||
const normalizeTaskType = (t: string | null | undefined) => {
|
|
||||||
if (!t || t === '프로젝트') return '실행과제';
|
|
||||||
if (t === '상시업무') return '기반업무';
|
|
||||||
return t; // 이미 '실행과제' 또는 '기반업무'
|
|
||||||
};
|
|
||||||
|
|
||||||
const [form, setForm] = useState<TaskFormData>({
|
const [form, setForm] = useState<TaskFormData>({
|
||||||
title: task?.title ?? '',
|
title: task?.title ?? '',
|
||||||
section: task?.section ?? defaultSection,
|
section: task?.section ?? defaultSection,
|
||||||
tag: task?.tag ?? '',
|
tag: task?.tag ?? '',
|
||||||
taskType: normalizeTaskType(task?.taskType),
|
taskType: task?.taskType ? normalizeTaskType(task.taskType) : '실행과제',
|
||||||
status: task?.status ?? 'TODO',
|
status: task?.status ?? 'TODO',
|
||||||
progress: task?.progress ?? 0,
|
progress: task?.progress ?? 0,
|
||||||
description: task?.description ?? '',
|
description: task?.description ?? '',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { apiClient } from '../../lib/apiClient';
|
import { apiClient } from '../../lib/apiClient';
|
||||||
|
import { isProjectTask, isRoutineTask } from '../../lib/taskType';
|
||||||
import { SortableTaskCard } from './TaskCard';
|
import { SortableTaskCard } from './TaskCard';
|
||||||
import { ContextMenu } from '../common/ContextMenu';
|
import { ContextMenu } from '../common/ContextMenu';
|
||||||
import { TaskModal } from '../common/TaskModal';
|
import { TaskModal } from '../common/TaskModal';
|
||||||
@@ -114,8 +115,11 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
|
|||||||
const subtitle = colConfig?.subtitle ?? initialSubtitle;
|
const subtitle = colConfig?.subtitle ?? initialSubtitle;
|
||||||
|
|
||||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; type: 'header' | 'list' } | null>(null);
|
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 [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [showHeaderModal, setShowHeaderModal] = useState(false);
|
const [showHeaderModal, setShowHeaderModal] = useState(false);
|
||||||
|
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
||||||
|
|
||||||
// ── useDroppable: 컬럼 드롭존 등록 ──────────────────────
|
// ── useDroppable: 컬럼 드롭존 등록 ──────────────────────
|
||||||
const { setNodeRef: setProjectDropRef, isOver: isProjectOver } = useDroppable({ id: `drop::project::${section}` });
|
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'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const patch = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||||
|
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) => {
|
const handleListContextMenu = (e: React.MouseEvent) => {
|
||||||
// 카드 위에서 발생한 이벤트는 무시 (TaskCard의 자체 메뉴가 처리)
|
|
||||||
if ((e.target as HTMLElement).closest('[data-task-card]')) return;
|
if ((e.target as HTMLElement).closest('[data-task-card]')) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
setCardMenu(null);
|
||||||
setCtxMenu({ x: e.clientX, y: e.clientY, type: 'list' });
|
setCtxMenu({ x: e.clientX, y: e.clientY, type: 'list' });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,9 +204,36 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
|
|||||||
setShowAddModal(false);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex min-h-0 flex-col overflow-hidden rounded-[1.6rem] border border-white/80 bg-white/70 shadow-[0_18px_45px_rgba(15,23,42,0.10)] ring-1 ring-slate-200/60 backdrop-blur">
|
<div
|
||||||
|
className="flex min-h-0 flex-col overflow-hidden rounded-[1.6rem] border border-white/80 bg-white/70 shadow-[0_18px_45px_rgba(15,23,42,0.10)] ring-1 ring-slate-200/60 backdrop-blur"
|
||||||
|
onContextMenuCapture={handleColumnContextMenuCapture}
|
||||||
|
>
|
||||||
{/* 컬럼 헤더 (noHeader 시 숨김) */}
|
{/* 컬럼 헤더 (noHeader 시 숨김) */}
|
||||||
{!noHeader && (
|
{!noHeader && (
|
||||||
<div
|
<div
|
||||||
@@ -197,7 +253,7 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
|
|||||||
|
|
||||||
{/* 실행과제 카드 목록 (스크롤 영역) */}
|
{/* 실행과제 카드 목록 (스크롤 영역) */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const projectTasks = orderedTasks.filter((t) => t.taskType !== '상시업무' && t.taskType !== '기반업무');
|
const projectTasks = orderedTasks.filter((t) => isProjectTask(t.taskType));
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setProjectDropRef}
|
ref={setProjectDropRef}
|
||||||
@@ -221,12 +277,13 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
|
|||||||
|
|
||||||
{/* 기반업무 고정 영역 */}
|
{/* 기반업무 고정 영역 */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const routineTasks = orderedTasks.filter((t) => t.taskType === '상시업무' || t.taskType === '기반업무');
|
const routineTasks = orderedTasks.filter((t) => isRoutineTask(t.taskType));
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setRoutineDropRef}
|
ref={setRoutineDropRef}
|
||||||
className={`shrink-0 border-t border-slate-200/80 bg-white/75 transition-colors ${isRoutineOver ? 'bg-amber-50/60' : ''}`}
|
className={`shrink-0 border-t border-slate-200/80 bg-white/75 transition-colors ${isRoutineOver ? 'bg-amber-50/60' : ''}`}
|
||||||
style={{ height: 300 }}
|
style={{ height: 300 }}
|
||||||
|
onContextMenu={handleListContextMenu}
|
||||||
>
|
>
|
||||||
<div className="h-full overflow-y-auto p-4">
|
<div className="h-full overflow-y-auto p-4">
|
||||||
{routineTasks.length === 0 ? (
|
{routineTasks.length === 0 ? (
|
||||||
@@ -246,7 +303,21 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 컨텍스트 메뉴 */}
|
{/* 카드 우클릭 메뉴 (추가/수정/삭제) */}
|
||||||
|
{cardMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={cardMenu.x}
|
||||||
|
y={cardMenu.y}
|
||||||
|
onClose={() => 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 && (
|
{ctxMenu && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
x={ctxMenu.x}
|
x={ctxMenu.x}
|
||||||
@@ -272,6 +343,16 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showEditModal && editingTask && (
|
||||||
|
<TaskModal
|
||||||
|
mode="edit"
|
||||||
|
task={editingTask}
|
||||||
|
sectionOptions={sectionOptions}
|
||||||
|
onSave={handleEdit}
|
||||||
|
onClose={() => { setShowEditModal(false); setEditingTask(null); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 헤더 편집 모달 */}
|
{/* 헤더 편집 모달 */}
|
||||||
{showHeaderModal && (
|
{showHeaderModal && (
|
||||||
<HeaderModal
|
<HeaderModal
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import type { DraggableAttributes } from '@dnd-kit/core';
|
import type { DraggableAttributes } from '@dnd-kit/core';
|
||||||
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
|
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';
|
import type { Task } from '../../types';
|
||||||
|
|
||||||
const STATUS_STYLE: Record<string, string> = {
|
const STATUS_STYLE: Record<string, string> = {
|
||||||
@@ -34,145 +28,38 @@ function fmtDate(iso: string | null | undefined): string {
|
|||||||
|
|
||||||
type SectionOption = { value: string; label: string };
|
type SectionOption = { value: string; label: string };
|
||||||
|
|
||||||
// ─── 드래그 + 컨텍스트 메뉴 래퍼 ───────────────────────────
|
|
||||||
// 컨텍스트 메뉴를 dnd-kit dragListeners 밖의 wrapper div에서 처리해
|
|
||||||
// 어떤 컬럼에서도 우클릭이 일관되게 동작하도록 함
|
|
||||||
export function SortableTaskCard({
|
export function SortableTaskCard({
|
||||||
task,
|
task,
|
||||||
sectionOptions,
|
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
task: Task;
|
task: Task;
|
||||||
sectionOptions?: SectionOption[];
|
sectionOptions?: SectionOption[];
|
||||||
onSelect?: (task: Task) => void;
|
onSelect?: (task: Task) => void;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
|
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 (
|
return (
|
||||||
<>
|
<TaskCard
|
||||||
{/* wrapper div — dnd-kit 외부에서 onContextMenu 처리 */}
|
task={task}
|
||||||
<div onContextMenu={handleContextMenu}>
|
dragRef={setNodeRef}
|
||||||
<TaskCard
|
dragStyle={{
|
||||||
task={task}
|
transform: CSS.Transform.toString(transform),
|
||||||
dragRef={setNodeRef}
|
transition,
|
||||||
dragStyle={dragStyle}
|
opacity: isDragging ? 0.35 : 1,
|
||||||
dragAttributes={attributes}
|
}}
|
||||||
dragListeners={listeners}
|
dragAttributes={attributes}
|
||||||
sectionOptions={sectionOptions}
|
dragListeners={listeners}
|
||||||
onCardClick={() => { if (!isDragging) onSelect?.(task); }}
|
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)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 순수 표시 컴포넌트 (드래그 핸들만 담당) ────────────────
|
|
||||||
export function TaskCard({
|
export function TaskCard({
|
||||||
task,
|
task,
|
||||||
dragRef,
|
dragRef,
|
||||||
dragStyle,
|
dragStyle,
|
||||||
dragAttributes,
|
dragAttributes,
|
||||||
dragListeners,
|
dragListeners,
|
||||||
sectionOptions: _sectionOptions,
|
|
||||||
onCardClick,
|
onCardClick,
|
||||||
}: {
|
}: {
|
||||||
task: Task;
|
task: Task;
|
||||||
@@ -180,7 +67,6 @@ export function TaskCard({
|
|||||||
dragStyle?: React.CSSProperties;
|
dragStyle?: React.CSSProperties;
|
||||||
dragAttributes?: DraggableAttributes;
|
dragAttributes?: DraggableAttributes;
|
||||||
dragListeners?: SyntheticListenerMap;
|
dragListeners?: SyntheticListenerMap;
|
||||||
sectionOptions?: SectionOption[];
|
|
||||||
onCardClick?: () => void;
|
onCardClick?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -188,16 +74,16 @@ export function TaskCard({
|
|||||||
ref={dragRef}
|
ref={dragRef}
|
||||||
style={dragStyle}
|
style={dragStyle}
|
||||||
{...dragAttributes}
|
{...dragAttributes}
|
||||||
{...dragListeners}
|
|
||||||
data-task-card="true"
|
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"
|
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) => {
|
onPointerDown={(e) => {
|
||||||
if (e.button !== 0) return; // 우클릭은 dnd-kit에 전달하지 않음
|
if (e.button !== 0) return;
|
||||||
dragListeners?.onPointerDown?.(e);
|
dragListeners?.onPointerDown?.(e);
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={dragListeners?.onKeyDown as React.KeyboardEventHandler<HTMLDivElement> | undefined}
|
||||||
onClick={onCardClick}
|
onClick={onCardClick}
|
||||||
>
|
>
|
||||||
{/* ── 제목 + 진행률 ── */}
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
<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">
|
<span className="min-w-0 flex-1 truncate text-2xl font-black leading-snug text-slate-900">
|
||||||
{task.title}
|
{task.title}
|
||||||
@@ -210,7 +96,6 @@ export function TaskCard({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 기간 + 상태 ── */}
|
|
||||||
<div className="mt-1 flex items-center gap-2">
|
<div className="mt-1 flex items-center gap-2">
|
||||||
<span className="flex-1 truncate text-sm font-semibold text-slate-400">
|
<span className="flex-1 truncate text-sm font-semibold text-slate-400">
|
||||||
{task.showDate && (task.startDate || task.dueDate)
|
{task.showDate && (task.startDate || task.dueDate)
|
||||||
@@ -224,7 +109,6 @@ export function TaskCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 키워드 ── */}
|
|
||||||
{task.keywords && (
|
{task.keywords && (
|
||||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||||
{task.keywords.split(',').map((kw, i) => (
|
{task.keywords.split(',').map((kw, i) => (
|
||||||
@@ -235,12 +119,10 @@ export function TaskCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 내용 ── */}
|
|
||||||
{task.showDescription && task.description && (
|
{task.showDescription && task.description && (
|
||||||
<div className="mt-2 truncate text-2xl text-slate-700">{task.description}</div>
|
<div className="mt-2 truncate text-2xl text-slate-700">{task.description}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 이슈 ── */}
|
|
||||||
{task.showIssue && task.issueNote && (
|
{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">
|
<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="shrink-0">▶</span>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { apiClient } from '../../lib/apiClient';
|
|||||||
import { TaskModal } from '../common/TaskModal';
|
import { TaskModal } from '../common/TaskModal';
|
||||||
import type { TaskFormData } from '../common/TaskModal';
|
import type { TaskFormData } from '../common/TaskModal';
|
||||||
import type { Task } from '../../types';
|
import type { Task } from '../../types';
|
||||||
|
import { isProjectTask, isRoutineTask } from '../../lib/taskType';
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
IN_PROGRESS: '진행', REVIEW: '보류', TODO: '대기', DONE: '완료', CANCELLED: '취소',
|
IN_PROGRESS: '진행', REVIEW: '보류', TODO: '대기', DONE: '완료', CANCELLED: '취소',
|
||||||
@@ -47,11 +48,10 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan
|
|||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 기반업무 = 상시업무(구), 실행과제 = 프로젝트(구) — 둘 다 매칭
|
|
||||||
const matchType = (taskType: string | null | undefined, filter: string) => {
|
const matchType = (taskType: string | null | undefined, filter: string) => {
|
||||||
if (filter === '전체') return true;
|
if (filter === '전체') return true;
|
||||||
if (filter === '실행과제') return taskType === '실행과제' || taskType === '프로젝트';
|
if (filter === '실행과제') return isProjectTask(taskType);
|
||||||
if (filter === '기반업무') return taskType === '기반업무' || taskType === '상시업무';
|
if (filter === '기반업무') return isRoutineTask(taskType);
|
||||||
return taskType === filter;
|
return taskType === filter;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -170,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 text-gray-600 font-medium whitespace-nowrap">{task.section ?? '-'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${
|
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${
|
||||||
(task.taskType === '상시업무' || task.taskType === '기반업무') ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700'
|
isRoutineTask(task.taskType) ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700'
|
||||||
}`}>
|
}`}>
|
||||||
{task.taskType === '상시업무' ? '기반업무' : task.taskType === '프로젝트' ? '실행과제' : (task.taskType ?? '실행과제')}
|
{isRoutineTask(task.taskType) ? '기반업무' : '실행과제'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 font-semibold text-gray-800 max-w-[200px] truncate">{task.title}</td>
|
<td className="px-4 py-3 font-semibold text-gray-800 max-w-[200px] truncate">{task.title}</td>
|
||||||
@@ -216,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">
|
<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-gray-700">{filtered.length}</strong>건</span>
|
||||||
<span>실행과제 <strong className="text-blue-600">{filtered.filter(t => t.taskType !== '상시업무' && t.taskType !== '기반업무').length}</strong>건</span>
|
<span>실행과제 <strong className="text-blue-600">{filtered.filter(t => isProjectTask(t.taskType)).length}</strong>건</span>
|
||||||
<span>기반업무 <strong className="text-amber-600">{filtered.filter(t => t.taskType === '상시업무' || t.taskType === '기반업무').length}</strong>건</span>
|
<span>기반업무 <strong className="text-amber-600">{filtered.filter(t => isRoutineTask(t.taskType)).length}</strong>건</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
16
frontend/src/lib/taskType.ts
Normal file
16
frontend/src/lib/taskType.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { DepartmentColumn } from '../components/dashboard/DepartmentColumn';
|
|||||||
import { TaskManager } from '../components/dashboard/TaskManager';
|
import { TaskManager } from '../components/dashboard/TaskManager';
|
||||||
import { useSocket } from '../contexts/SocketContext';
|
import { useSocket } from '../contexts/SocketContext';
|
||||||
import { sendTaskSelected, openDetailWindow } from '../lib/dualMonitor';
|
import { sendTaskSelected, openDetailWindow } from '../lib/dualMonitor';
|
||||||
|
import { isRoutineTask } from '../lib/taskType';
|
||||||
|
|
||||||
const QUARTER = '2026-Q2';
|
const QUARTER = '2026-Q2';
|
||||||
const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'] as const;
|
const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'] as const;
|
||||||
@@ -131,9 +132,9 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
const updateData: Record<string, any> = {};
|
const updateData: Record<string, any> = {};
|
||||||
if (targetSection !== srcSection) updateData.section = targetSection;
|
if (targetSection !== srcSection) updateData.section = targetSection;
|
||||||
if (areaType === 'routine' && draggedTask.taskType !== '상시업무' && draggedTask.taskType !== '기반업무') {
|
if (areaType === 'routine' && !isRoutineTask(draggedTask.taskType)) {
|
||||||
updateData.taskType = '기반업무';
|
updateData.taskType = '기반업무';
|
||||||
} else if (areaType === 'project' && (draggedTask.taskType === '상시업무' || draggedTask.taskType === '기반업무')) {
|
} else if (areaType === 'project' && isRoutineTask(draggedTask.taskType)) {
|
||||||
updateData.taskType = '실행과제';
|
updateData.taskType = '실행과제';
|
||||||
}
|
}
|
||||||
if (Object.keys(updateData).length > 0) {
|
if (Object.keys(updateData).length > 0) {
|
||||||
@@ -147,11 +148,16 @@ export default function DashboardPage() {
|
|||||||
if (!overTask) return;
|
if (!overTask) return;
|
||||||
|
|
||||||
const dstSection = overTask.section ?? '';
|
const dstSection = overTask.section ?? '';
|
||||||
|
const typeChanged = isRoutineTask(draggedTask.taskType) !== isRoutineTask(overTask.taskType);
|
||||||
|
|
||||||
if (dstSection !== srcSection) {
|
if (dstSection !== srcSection || typeChanged) {
|
||||||
// 컬럼 간 이동 — 섹션 변경
|
const updateData: Record<string, any> = {};
|
||||||
patchTask.mutate({ id: activeId, data: { section: dstSection } });
|
if (dstSection !== srcSection) updateData.section = dstSection;
|
||||||
return;
|
if (typeChanged) {
|
||||||
|
updateData.taskType = isRoutineTask(overTask.taskType) ? '기반업무' : '실행과제';
|
||||||
|
}
|
||||||
|
patchTask.mutate({ id: activeId, data: updateData });
|
||||||
|
if (dstSection !== srcSection) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 같은 컬럼 내 순서 변경 ───────────────────────────────────
|
// ── 같은 컬럼 내 순서 변경 ───────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user