Resolve invalid task creator IDs, fix API routing and file uploads on Vercel, and replace dummy seed data with HR_Dashboard import.
252 lines
12 KiB
TypeScript
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
|
|
);
|
|
}
|