Initial commit - EENE Dashboard
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
282
frontend/src/components/dashboard/TaskCard.tsx
Normal file
282
frontend/src/components/dashboard/TaskCard.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import { useState, useRef } 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 { sendTaskSelected } from '../../lib/dualMonitor';
|
||||
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 TAG_CONFIG: Record<string, { bg: string; text: string }> = {
|
||||
Growth: { bg: 'bg-blue-100', text: 'text-blue-800' },
|
||||
Policy: { bg: 'bg-purple-100', text: 'text-purple-800' },
|
||||
Performance: { bg: 'bg-emerald-100', text: 'text-emerald-800' },
|
||||
Culture: { bg: 'bg-amber-100', text: 'text-amber-800' },
|
||||
Asset: { bg: 'bg-cyan-100', text: 'text-cyan-800' },
|
||||
Space: { bg: 'bg-indigo-100', text: 'text-indigo-800' },
|
||||
Safety: { bg: 'bg-red-100', text: 'text-red-800' },
|
||||
Environment: { bg: 'bg-lime-100', text: 'text-lime-800' },
|
||||
};
|
||||
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
IN_PROGRESS: 'bg-blue-500 text-white',
|
||||
REVIEW: 'bg-orange-400 text-white',
|
||||
TODO: 'bg-gray-300 text-gray-700',
|
||||
DONE: 'bg-emerald-500 text-white',
|
||||
CANCELLED: 'bg-gray-200 text-gray-500',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
IN_PROGRESS: '진행',
|
||||
REVIEW: '보류',
|
||||
TODO: '대기',
|
||||
DONE: '완료',
|
||||
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}`;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
// dnd-kit이 dragListeners의 onPointerDown을 가로채므로 onClick이 막힐 수 있음.
|
||||
// pointerDown 시작 위치를 ref로 기억했다가 pointerUp에서 이동이 적으면 클릭으로 판정.
|
||||
const pointerStart = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent) => {
|
||||
pointerStart.current = { x: e.clientX, y: e.clientY };
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent) => {
|
||||
if (!pointerStart.current || isDragging) return;
|
||||
const dx = e.clientX - pointerStart.current.x;
|
||||
const dy = e.clientY - pointerStart.current.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 6) {
|
||||
onSelect?.(task);
|
||||
}
|
||||
pointerStart.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<TaskCard
|
||||
task={task}
|
||||
dragRef={setNodeRef}
|
||||
dragStyle={style}
|
||||
dragAttributes={attributes}
|
||||
dragListeners={listeners}
|
||||
sectionOptions={sectionOptions}
|
||||
onCardPointerDown={handlePointerDown}
|
||||
onCardPointerUp={handlePointerUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 메인 TaskCard ──────────────────────────────────────────
|
||||
export function TaskCard({
|
||||
task,
|
||||
dragRef,
|
||||
dragStyle,
|
||||
dragAttributes,
|
||||
dragListeners,
|
||||
sectionOptions,
|
||||
onCardPointerDown,
|
||||
onCardPointerUp,
|
||||
}: {
|
||||
task: Task;
|
||||
dragRef?: (node: HTMLElement | null) => void;
|
||||
dragStyle?: React.CSSProperties;
|
||||
dragAttributes?: DraggableAttributes;
|
||||
dragListeners?: SyntheticListenerMap;
|
||||
sectionOptions?: SectionOption[];
|
||||
onCardPointerDown?: (e: React.PointerEvent) => void;
|
||||
onCardPointerUp?: (e: React.PointerEvent) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const tagCfg = TAG_CONFIG[task.tag ?? ''] ?? { bg: 'bg-gray-100', text: 'text-gray-600' };
|
||||
|
||||
// ── API 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);
|
||||
|
||||
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,
|
||||
tag: data.tag || 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,
|
||||
quarter: data.quarter,
|
||||
priority: 'MEDIUM',
|
||||
creatorId: 'system',
|
||||
});
|
||||
setModalMode(null);
|
||||
};
|
||||
|
||||
const handleEdit = (data: TaskFormData) => {
|
||||
patch.mutate({
|
||||
title: data.title,
|
||||
section: data.section || null,
|
||||
tag: data.tag || 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,
|
||||
});
|
||||
setModalMode(null);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (window.confirm(`"${task.title}" 업무를 삭제하시겠습니까?`)) {
|
||||
remove.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={dragRef}
|
||||
style={dragStyle}
|
||||
{...dragAttributes}
|
||||
{...dragListeners}
|
||||
className="bg-white rounded-2xl px-5 py-3 mb-3 shadow-sm border border-gray-100 hover:shadow-md hover:border-gray-200 transition-all select-none h-[112px] cursor-grab active:cursor-grabbing overflow-hidden"
|
||||
onPointerDown={onCardPointerDown}
|
||||
onPointerUp={onCardPointerUp}
|
||||
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="text-2xl font-bold text-gray-900 leading-snug min-w-0 truncate">
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`shrink-0 text-2xl font-black mt-0.5 min-w-[4rem] text-right ${
|
||||
task.progress >= 70 ? 'text-emerald-500' :
|
||||
task.progress >= 40 ? 'text-blue-400' :
|
||||
'text-orange-400'
|
||||
}`}>
|
||||
{task.progress}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
{/* ── 태그 + 기간 + 상태 ── */}
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<span
|
||||
className={`shrink-0 text-sm font-black px-2.5 py-0.5 rounded-full ${tagCfg.bg} ${tagCfg.text} cursor-pointer`}
|
||||
onClick={() => sendTaskSelected(task.id)}
|
||||
>
|
||||
{task.tag}
|
||||
</span>
|
||||
<span className="text-base text-gray-400 font-medium flex-1 truncate">
|
||||
{(task.startDate || task.dueDate)
|
||||
? `${task.startDate ? fmtDate(task.startDate) : '?'} ~ ${task.dueDate ? fmtDate(task.dueDate) : '?'}`
|
||||
: ''}
|
||||
</span>
|
||||
<span className={`text-sm font-bold px-2.5 py-0.5 rounded-full ${STATUS_STYLE[task.status]} shrink-0`}>
|
||||
{STATUS_LABEL[task.status]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── 이슈 메모 ── */}
|
||||
{task.issueNote && (
|
||||
<div className="mt-2 flex gap-2 text-sm font-semibold text-red-600 min-w-0">
|
||||
<span className="shrink-0">▶ 이슈 :</span>
|
||||
<span className="truncate">{task.issueNote}</span>
|
||||
</div>
|
||||
)}
|
||||
</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 ?? 'HR'}
|
||||
defaultQuarter={task.quarter}
|
||||
sectionOptions={sectionOptions}
|
||||
onSave={handleAdd}
|
||||
onClose={() => setModalMode(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 수정 모달 ── */}
|
||||
{modalMode === 'edit' && (
|
||||
<TaskModal
|
||||
mode="edit"
|
||||
task={task}
|
||||
sectionOptions={sectionOptions}
|
||||
onSave={handleEdit}
|
||||
onClose={() => setModalMode(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user