feat: task manager modal, remove type filter, expand routine area

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
EENE Dashboard
2026-06-01 13:35:18 +09:00
parent a5dcc9321e
commit 5dec3f2fae
4 changed files with 256 additions and 33 deletions

View File

@@ -10,14 +10,13 @@ interface Stats {
interface DashboardHeaderProps {
quarter: string;
stats: Stats;
activeType: string;
onTypeChange: (type: string) => void;
activeStatus: string;
onStatusChange: (status: string) => void;
onOpenDetailWindow: () => void;
onOpenTaskManager: () => void;
}
export function DashboardHeader({ quarter, stats, activeType, onTypeChange, activeStatus, onStatusChange, onOpenDetailWindow }: DashboardHeaderProps) {
export function DashboardHeader({ quarter, stats, activeStatus, onStatusChange, onOpenDetailWindow, onOpenTaskManager }: DashboardHeaderProps) {
const today = new Date();
const todayStr = `${today.getMonth() + 1}${today.getDate()}`;
@@ -51,7 +50,7 @@ export function DashboardHeader({ quarter, stats, activeType, onTypeChange, acti
)}
</div>
{/* ── 오른쪽: 날짜 + 업무유형 필터 ── */}
{/* ── 오른쪽: 버튼들 + 날짜 ── */}
<div className="ml-auto flex items-center gap-3 shrink-0">
<button
onClick={onOpenDetailWindow}
@@ -61,24 +60,15 @@ export function DashboardHeader({ quarter, stats, activeType, onTypeChange, acti
<span>🖥</span>
<span> </span>
</button>
<button
onClick={onOpenTaskManager}
className="flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-white/10 text-white/70 hover:bg-white/20 transition-colors border border-white/10 hover:border-white/30"
>
<span>📋</span>
<span></span>
</button>
<div className="w-px h-5 bg-white/20" />
<span className="text-xs text-white/50">{todayStr}</span>
<div className="w-px h-5 bg-white/20" />
<div className="flex gap-1">
{['전체', '상시업무', '프로젝트'].map((type) => (
<button
key={type}
onClick={() => onTypeChange(type)}
className={`text-xs font-semibold px-3 py-1.5 rounded-lg transition-colors ${
activeType === type
? 'bg-blue-500 text-white'
: 'bg-white/10 text-white/70 hover:bg-white/20'
}`}
>
{type}
</button>
))}
</div>
</div>
</header>

View File

@@ -257,7 +257,7 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
const routineTasks = orderedTasks.filter((t) => t.taskType === '상시업무');
return (
<div className="shrink-0 border-t border-gray-200">
<div className="px-4 pb-3 h-[200px] overflow-y-auto">
<div className="px-4 pb-3 h-[300px] overflow-y-auto">
{routineTasks.length === 0 ? (
<div className="flex items-center justify-center h-full text-base text-gray-300">

View File

@@ -0,0 +1,226 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createPortal } from 'react-dom';
import { apiClient } from '../../lib/apiClient';
import { TaskModal } from '../common/TaskModal';
import type { TaskFormData } from '../common/TaskModal';
import type { Task } from '../../types';
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 filtered = tasks.filter((t) => {
if (filterSection !== '전체' && t.section !== filterSection) return false;
if (filterType !== '전체' && t.taskType !== filterType) return false;
return true;
});
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) => {
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,
},
});
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 ${
task.taskType === '상시업무' ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700'
}`}>
{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 => t.taskType !== '상시업무').length}</strong></span>
<span> <strong className="text-amber-600">{filtered.filter(t => 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
);
}

View File

@@ -4,14 +4,15 @@ import { apiClient } from '../lib/apiClient';
import { useTasks } from '../hooks/useTasks';
import { DashboardHeader } from '../components/dashboard/DashboardHeader';
import { DepartmentColumn } from '../components/dashboard/DepartmentColumn';
import { TaskManager } from '../components/dashboard/TaskManager';
import { useSocket } from '../contexts/SocketContext';
import { sendTaskSelected, openDetailWindow } from '../lib/dualMonitor';
const QUARTER = '2026-Q2';
export default function DashboardPage() {
const [activeType, setActiveType] = useState('전체');
const [activeStatus, setActiveStatus] = useState('전체');
const [showTaskManager, setShowTaskManager] = useState(false);
const queryClient = useQueryClient();
const socket = useSocket();
@@ -25,18 +26,16 @@ export default function DashboardPage() {
return () => { socket.off('tasks:refresh', refresh); socket.off('task:updated', refresh); };
}, [socket, queryClient]);
const byType = tasks.filter((t) => activeType === '전체' || t.taskType === activeType);
const stats = {
total: byType.length,
inProgress: byType.filter((t) => t.status === 'IN_PROGRESS').length,
review: byType.filter((t) => t.status === 'REVIEW' || t.status === 'CANCELLED').length,
waiting: byType.filter((t) => t.status === 'TODO').length,
done: byType.filter((t) => t.status === 'DONE').length,
issues: byType.filter((t) => !!t.issueNote).length,
total: tasks.length,
inProgress: tasks.filter((t) => t.status === 'IN_PROGRESS').length,
review: tasks.filter((t) => t.status === 'REVIEW' || t.status === 'CANCELLED').length,
waiting: tasks.filter((t) => t.status === 'TODO').length,
done: tasks.filter((t) => t.status === 'DONE').length,
issues: tasks.filter((t) => !!t.issueNote).length,
};
const filtered = byType.filter((t) => {
const filtered = tasks.filter((t) => {
if (activeStatus === '전체') return true;
if (activeStatus === 'ISSUES') return !!t.issueNote;
if (activeStatus === 'REVIEW') return t.status === 'REVIEW' || t.status === 'CANCELLED';
@@ -78,11 +77,10 @@ export default function DashboardPage() {
<DashboardHeader
quarter={QUARTER}
stats={stats}
activeType={activeType}
onTypeChange={(type) => { setActiveType(type); setActiveStatus('전체'); }}
activeStatus={activeStatus}
onStatusChange={setActiveStatus}
onOpenDetailWindow={openDetailWindow}
onOpenTaskManager={() => setShowTaskManager(true)}
/>
<main className="relative flex-1 overflow-hidden min-h-0">
@@ -138,6 +136,15 @@ export default function DashboardPage() {
</div>
</main>
{showTaskManager && (
<TaskManager
tasks={tasks}
sectionOptions={sectionOptions}
quarter={QUARTER}
onClose={() => setShowTaskManager(false)}
/>
)}
</div>
);
}