Initial commit - EENE Dashboard

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
EENE Dashboard
2026-05-29 18:07:10 +09:00
commit 22366dde72
64 changed files with 10483 additions and 0 deletions

View 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)}
/>
)}
</>
);
}