feat: task manager modal, remove type filter, expand routine area
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
관리현황 없음
|
||||
|
||||
226
frontend/src/components/dashboard/TaskManager.tsx
Normal file
226
frontend/src/components/dashboard/TaskManager.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user