Files
eene_dashboard/frontend/src/components/dashboard/TaskManager.tsx
EENE Dashboard 6066b5682d fix: production save errors and import HR dashboard data
Resolve invalid task creator IDs, fix API routing and file uploads on Vercel, and replace dummy seed data with HR_Dashboard import.
2026-06-05 22:08:56 +09:00

252 lines
12 KiB
TypeScript

import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createPortal } from 'react-dom';
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
import { TaskModal } from '../common/TaskModal';
import type { TaskFormData } from '../common/TaskModal';
import type { Task } from '../../types';
import { isProjectTask, isRoutineTask } from '../../lib/taskType';
const STATUS_LABEL: Record<string, string> = {
IN_PROGRESS: '진행', REVIEW: '보류', TODO: '대기', DONE: '완료', CANCELLED: '취소',
};
const STATUS_STYLE: Record<string, string> = {
IN_PROGRESS: 'bg-blue-100 text-blue-700',
REVIEW: 'bg-orange-100 text-orange-700',
TODO: 'bg-gray-100 text-gray-600',
DONE: 'bg-emerald-100 text-emerald-700',
CANCELLED: 'bg-gray-100 text-gray-400',
};
const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'] as const;
interface TaskManagerProps {
tasks: Task[];
sectionOptions: { value: string; label: string }[];
quarter: string;
onClose: () => void;
}
export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskManagerProps) {
const queryClient = useQueryClient();
const [filterSection, setFilterSection] = useState<string>('전체');
const [filterType, setFilterType] = useState<string>('전체');
const [modalMode, setModalMode] = useState<'add' | 'edit' | null>(null);
const [editingTask, setEditingTask] = useState<Task | null>(null);
const create = useMutation({
mutationFn: (data: Record<string, unknown>) => apiClient.post('/tasks', data),
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'] }),
});
const matchType = (taskType: string | null | undefined, filter: string) => {
if (filter === '전체') return true;
if (filter === '실행과제') return isProjectTask(taskType);
if (filter === '기반업무') return isRoutineTask(taskType);
return taskType === filter;
};
const filtered = tasks.filter((t) => {
if (filterSection !== '전체' && t.section !== filterSection) return false;
if (!matchType(t.taskType, filterType)) return false;
return true;
});
const handleAdd = async (data: TaskFormData) => {
try {
await create.mutateAsync({
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,
showDate: data.showDate,
showDescription: data.showDescription,
showStatus: data.showStatus,
showIssue: data.showIssue,
showProgress: data.showProgress,
keywords: data.keywords || null,
quarter: data.quarter,
priority: 'MEDIUM',
});
setModalMode(null);
} catch (err: unknown) {
alert(getApiErrorMessage(err, '업무 추가에 실패했습니다.'));
}
};
const handleEdit = (data: TaskFormData) => {
if (!editingTask) return;
patch.mutate({
id: editingTask.id,
data: {
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,
showDate: data.showDate,
showDescription: data.showDescription,
showStatus: data.showStatus,
showIssue: data.showIssue,
showProgress: data.showProgress,
keywords: data.keywords || null,
},
});
setModalMode(null);
setEditingTask(null);
};
const handleDelete = (task: Task) => {
if (window.confirm(`"${task.title}" 업무를 삭제하시겠습니까?`)) {
remove.mutate(task.id);
}
};
return createPortal(
<div className="fixed inset-0 z-[9000] flex items-center justify-center bg-black/60" onClick={onClose}>
<div
className="bg-white rounded-2xl shadow-2xl flex flex-col"
style={{ width: '90vw', maxWidth: 1100, height: '85vh' }}
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
<h2 className="text-xl font-black text-gray-800">📋 </h2>
<div className="flex items-center gap-3">
{/* 부문 필터 */}
<div className="flex gap-1">
{['전체', ...SECTIONS].map((s) => (
<button key={s} onClick={() => setFilterSection(s)}
className={`text-xs font-semibold px-3 py-1.5 rounded-lg transition-colors ${
filterSection === s ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}>{s}</button>
))}
</div>
<div className="w-px h-5 bg-gray-200" />
{/* 유형 필터 */}
<div className="flex gap-1">
{['전체', '실행과제', '기반업무'].map((t) => (
<button key={t} onClick={() => setFilterType(t)}
className={`text-xs font-semibold px-3 py-1.5 rounded-lg transition-colors ${
filterType === t ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}>{t}</button>
))}
</div>
<div className="w-px h-5 bg-gray-200" />
<button
onClick={() => { setEditingTask(null); setModalMode('add'); }}
className="flex items-center gap-1.5 px-4 py-1.5 rounded-lg bg-blue-600 text-white text-sm font-bold hover:bg-blue-700 transition-colors"
>
</button>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none ml-1"></button>
</div>
</div>
{/* 테이블 */}
<div className="flex-1 overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-gray-50 border-b border-gray-200 z-10">
<tr>
<th className="text-left px-4 py-3 font-bold text-gray-500 w-24"></th>
<th className="text-left px-4 py-3 font-bold text-gray-500 w-20"></th>
<th className="text-left px-4 py-3 font-bold text-gray-500"></th>
<th className="text-left px-4 py-3 font-bold text-gray-500 w-48"></th>
<th className="text-left px-4 py-3 font-bold text-gray-500 w-40"></th>
<th className="text-center px-4 py-3 font-bold text-gray-500 w-16"></th>
<th className="text-center px-4 py-3 font-bold text-gray-500 w-16"></th>
<th className="text-left px-4 py-3 font-bold text-gray-500 w-48"></th>
<th className="w-20" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filtered.length === 0 ? (
<tr><td colSpan={9} className="text-center py-16 text-gray-300 text-lg"> </td></tr>
) : filtered.map((task) => (
<tr key={task.id} className="hover:bg-blue-50/40 transition-colors group">
<td className="px-4 py-3 text-gray-600 font-medium whitespace-nowrap">{task.section ?? '-'}</td>
<td className="px-4 py-3">
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${
isRoutineTask(task.taskType) ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700'
}`}>
{isRoutineTask(task.taskType) ? '기반업무' : '실행과제'}
</span>
</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 text-gray-500 max-w-[200px] truncate">{task.description ?? '-'}</td>
<td className="px-4 py-3 text-gray-500 whitespace-nowrap text-xs">
{task.startDate || task.dueDate
? `${task.startDate?.slice(0,10) ?? '?'} ~ ${task.dueDate?.slice(0,10) ?? '?'}`
: '-'}
</td>
<td className="px-4 py-3 text-center">
<span className={`font-black text-base ${
task.progress >= 70 ? 'text-emerald-500' : task.progress >= 40 ? 'text-blue-500' : 'text-orange-400'
}`}>{task.progress}%</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${STATUS_STYLE[task.status]}`}>
{STATUS_LABEL[task.status]}
</span>
</td>
<td className="px-4 py-3 text-red-500 max-w-[200px] truncate text-xs">{task.issueNote ?? ''}</td>
<td className="px-4 py-3">
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => { setEditingTask(task); setModalMode('edit'); }}
className="p-1.5 rounded-lg hover:bg-blue-100 text-blue-500 transition-colors"
title="수정"
></button>
<button
onClick={() => handleDelete(task)}
className="p-1.5 rounded-lg hover:bg-red-100 text-red-500 transition-colors"
title="삭제"
>🗑</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 하단 요약 */}
<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-blue-600">{filtered.filter(t => isProjectTask(t.taskType)).length}</strong></span>
<span> <strong className="text-amber-600">{filtered.filter(t => isRoutineTask(t.taskType)).length}</strong></span>
</div>
</div>
{modalMode === 'add' && (
<TaskModal
mode="add"
defaultSection={filterSection !== '전체' ? filterSection : '인사관리'}
defaultQuarter={quarter}
sectionOptions={sectionOptions}
onSave={handleAdd}
onClose={() => setModalMode(null)}
/>
)}
{modalMode === 'edit' && editingTask && (
<TaskModal
mode="edit"
task={editingTask}
sectionOptions={sectionOptions}
onSave={handleEdit}
onClose={() => { setModalMode(null); setEditingTask(null); }}
/>
)}
</div>,
document.body
);
}