"use client"; import React, { useState, useMemo, useCallback, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable"; import { Plus, RotateCcw, Save, Pencil, Trash2, ChevronRight, FolderOpen, Rocket, ClipboardList, BarChart3, Users, FileText, Loader2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { getProjectList, createProject, updateProject, getTasksByProject, createTask, updateTask, deleteTask, } from "@/lib/api/design"; // --- Types --- type ProjectStatus = "진행중" | "계획" | "보류" | "완료"; type TaskStatus = "대기" | "진행중" | "검토중" | "완료"; type RelationType = "sub" | "depend" | "related"; interface WorkLog { date: string; hours: number; desc: string; progressBefore: number; progressAfter: number; author: string; } interface Issue { id: number; title: string; status: string; priority: string; desc: string; registeredBy: string; registeredDate: string; resolvedDate?: string; } interface Task { id?: string; name: string; category: string; assignee: string; start: string; end: string; status: TaskStatus; progress: number; remark: string; workLogs: WorkLog[]; issues: Issue[]; } interface Project { id: string; projectNo: string; name: string; status: ProjectStatus; pm: string; customer: string; startDate: string; endDate: string; sourceNo: string; desc: string; progress: number; parentId: string | null; relation: RelationType | null; tasks: Task[]; } // API 응답(snake_case) -> 프론트(camelCase) 매핑 function mapWorkLog(w: any): WorkLog { const dt = w.start_dt || w.date; const date = typeof dt === "string" ? dt.split("T")[0] : ""; return { date, hours: Number(w.hours) || 0, desc: w.description || w.desc || "", progressBefore: Number(w.progress_before) || 0, progressAfter: Number(w.progress_after) || 0, author: w.author || "", }; } function mapIssue(i: any): Issue { return { id: i.id, title: i.title || "", status: i.status || "", priority: i.priority || "", desc: i.description || i.desc || "", registeredBy: i.registered_by || "", registeredDate: i.registered_date || "", resolvedDate: i.resolved_date, }; } function mapTask(t: any): Task { const workLogs = (t.work_logs || t.workLogs || []).map(mapWorkLog); const issues = (t.issues || []).map(mapIssue); const start = t.start_date || t.start || ""; const end = t.end_date || t.end || ""; return { id: t.id, name: t.name || "", category: t.category || "기구설계", assignee: t.assignee || "", start: typeof start === "string" ? start.split("T")[0] : "", end: typeof end === "string" ? end.split("T")[0] : "", status: (t.status || "대기") as TaskStatus, progress: Number(t.progress) || 0, remark: t.remark || "", workLogs, issues, }; } function mapProject(p: any): Project { const tasks = (p.tasks || []).map(mapTask); return { id: p.id, projectNo: p.project_no || p.id, name: p.name || "", status: (p.status || "계획") as ProjectStatus, pm: p.pm || "", customer: p.customer || "", startDate: (p.start_date || "").toString().split("T")[0], endDate: (p.end_date || "").toString().split("T")[0], sourceNo: p.source_no || "", desc: p.description || p.desc || "", progress: Number(p.progress) || 0, parentId: p.parent_id || null, relation: (p.relation_type || p.relation) as RelationType | null, tasks, }; } // --- 상태 색상 --- const getStatusColor = (status: ProjectStatus) => { switch (status) { case "진행중": return "bg-blue-100 text-blue-800 border-blue-200"; case "계획": return "bg-slate-100 text-slate-800 border-slate-200"; case "보류": return "bg-amber-100 text-amber-800 border-amber-200"; case "완료": return "bg-emerald-100 text-emerald-800 border-emerald-200"; default: return "bg-gray-100 text-gray-800 border-gray-200"; } }; const getTaskStatusColor = (status: string) => { switch (status) { case "대기": return "bg-gray-100 text-gray-700"; case "진행중": return "bg-blue-100 text-blue-800"; case "검토중": return "bg-amber-100 text-amber-800"; case "완료": return "bg-emerald-100 text-emerald-800"; case "지연": return "bg-rose-100 text-rose-800"; default: return "bg-gray-100 text-gray-700"; } }; const getRelationLabel = (r: RelationType | null) => { if (!r) return ""; const m: Record = { sub: "하위", depend: "종속", related: "연관", }; return m[r]; }; const getRelationColor = (r: RelationType | null) => { if (!r) return ""; const m: Record = { sub: "bg-blue-100 text-blue-700", depend: "bg-amber-100 text-amber-800", related: "bg-purple-100 text-purple-700", }; return m[r]; }; const categoryIcons: Record = { 기구설계: "⚙️", 전장설계: "⚡", SW개발: "💻", "구매/조달": "📦", "조립/시운전": "🔧", "검토/승인": "✅", }; const progressColor = (p: number) => p >= 80 ? "bg-emerald-500" : p >= 40 ? "bg-blue-500" : p > 0 ? "bg-amber-500" : "bg-gray-300"; const progressTextColor = (p: number) => p >= 80 ? "text-emerald-600" : p >= 40 ? "text-blue-600" : p > 0 ? "text-amber-600" : "text-gray-400"; // --- Helper functions --- function getChildren(projects: Project[], parentId: string): Project[] { return projects.filter((p) => p.parentId === parentId); } function getAllDescendants(projects: Project[], parentId: string): Project[] { const children = getChildren(projects, parentId); let all = [...children]; children.forEach((c) => { all = all.concat(getAllDescendants(projects, c.id)); }); return all; } // --- Component --- export default function DesignProjectPage() { const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [selectedId, setSelectedId] = useState(null); const [expandedIds, setExpandedIds] = useState>({}); // 검색 const [searchStatus, setSearchStatus] = useState("all"); const [searchPM, setSearchPM] = useState("all"); const [searchKeyword, setSearchKeyword] = useState(""); // 상세 탭 const [detailTab, setDetailTab] = useState("wbs"); // 모달 const [isProjectModalOpen, setIsProjectModalOpen] = useState(false); const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); const [isTaskDetailOpen, setIsTaskDetailOpen] = useState(false); const [editingTaskIdx, setEditingTaskIdx] = useState(-1); const [taskDetailIdx, setTaskDetailIdx] = useState(-1); const [taskDetailTab, setTaskDetailTab] = useState("log"); // 프로젝트 폼 const [formProjectId, setFormProjectId] = useState(""); const [formProjectNo, setFormProjectNo] = useState(""); const [formName, setFormName] = useState(""); const [formStartDate, setFormStartDate] = useState(""); const [formEndDate, setFormEndDate] = useState(""); const [formPM, setFormPM] = useState(""); const [formCustomer, setFormCustomer] = useState(""); const [formSourceNo, setFormSourceNo] = useState(""); const [formDesc, setFormDesc] = useState(""); const [formParentId, setFormParentId] = useState(""); const [formRelation, setFormRelation] = useState("sub"); // 태스크 폼 const [tName, setTName] = useState(""); const [tCategory, setTCategory] = useState("기구설계"); const [tAssignee, setTAssignee] = useState(""); const [tStart, setTStart] = useState(""); const [tEnd, setTEnd] = useState(""); const [tStatus, setTStatus] = useState("대기"); const [tProgress, setTProgress] = useState(0); const [tRemark, setTRemark] = useState(""); const fetchProjects = useCallback(async () => { setLoading(true); try { const res = await getProjectList(); if (res.success && res.data) { const mapped = (res.data as any[]).map(mapProject); setProjects(mapped); } else { setProjects([]); } } catch { toast.error("프로젝트 목록을 불러오는데 실패했습니다."); setProjects([]); } finally { setLoading(false); } }, []); useEffect(() => { fetchProjects(); }, [fetchProjects]); const fetchTaskDetails = useCallback(async (projectId: string) => { try { const res = await getTasksByProject(projectId); if (res.success && res.data) { const tasks = (res.data as any[]).map(mapTask); setProjects((prev) => prev.map((p) => (p.id === projectId ? { ...p, tasks } : p)) ); } } catch { toast.error("업무 상세를 불러오는데 실패했습니다."); } }, []); // 필터링 const filteredProjects = useMemo(() => { if (searchStatus === "all" && searchPM === "all" && !searchKeyword) return projects; const matched = new Set(); projects.forEach((p) => { let pass = true; if (searchStatus !== "all" && p.status !== searchStatus) pass = false; if (searchPM !== "all" && p.pm !== searchPM) pass = false; if (searchKeyword) { const str = [p.projectNo, p.name, p.customer, p.pm, p.sourceNo].join(" ").toLowerCase(); if (!str.includes(searchKeyword.toLowerCase())) pass = false; } if (pass) matched.add(p.id); }); const result = new Set(matched); matched.forEach((id) => { getAllDescendants(projects, id).forEach((d) => result.add(d.id)); }); matched.forEach((id) => { let current = projects.find((p) => p.id === id); while (current?.parentId) { result.add(current.parentId); current = projects.find((p) => p.id === current!.parentId); } }); return projects.filter((p) => result.has(p.id)); }, [projects, searchStatus, searchPM, searchKeyword]); const selectedProject = useMemo( () => projects.find((p) => p.id === selectedId), [projects, selectedId] ); // 트리 렌더 const buildTreeRows = useCallback( (parentId: string | null, depth: number): { project: Project; depth: number }[] => { const children = filteredProjects.filter((p) => p.parentId === parentId); const rows: { project: Project; depth: number }[] = []; children.forEach((child) => { rows.push({ project: child, depth }); if (expandedIds[child.id] !== false) { rows.push(...buildTreeRows(child.id, depth + 1)); } }); return rows; }, [filteredProjects, expandedIds] ); const treeRows = useMemo(() => buildTreeRows(null, 0), [buildTreeRows]); const toggleExpand = (id: string) => { setExpandedIds((prev) => ({ ...prev, [id]: prev[id] === undefined ? false : !prev[id], })); }; const handleResetSearch = () => { setSearchStatus("all"); setSearchPM("all"); setSearchKeyword(""); }; // --- 프로젝트 모달 --- const openProjectModal = (editProject?: Project, presetParentId?: string) => { if (editProject) { setFormProjectId(editProject.id); setFormProjectNo(editProject.projectNo); setFormName(editProject.name); setFormStartDate(editProject.startDate); setFormEndDate(editProject.endDate); setFormPM(editProject.pm); setFormCustomer(editProject.customer); setFormSourceNo(editProject.sourceNo); setFormDesc(editProject.desc); setFormParentId(editProject.parentId || ""); setFormRelation((editProject.relation as RelationType) || "sub"); } else { const maxNum = projects.reduce((max, p) => { const match = p.projectNo?.match(/PJ-\d{4}-(\d+)/); const num = match ? parseInt(match[1], 10) : 0; return num > max ? num : max; }, 0); const year = new Date().getFullYear(); const newProjectNo = `PJ-${year}-${String(maxNum + 1).padStart(4, "0")}`; setFormProjectId(""); setFormProjectNo(newProjectNo); setFormName(""); setFormStartDate(new Date().toISOString().split("T")[0]); setFormEndDate(""); setFormPM(""); setFormCustomer(""); setFormSourceNo(""); setFormDesc(""); setFormParentId(presetParentId || ""); setFormRelation("sub"); if (presetParentId) { const parent = projects.find((p) => p.id === presetParentId); if (parent) { setFormCustomer(parent.customer); setFormEndDate(parent.endDate); } } } setIsProjectModalOpen(true); }; const handleSaveProject = async () => { if (!formName.trim()) { toast.error("프로젝트명을 입력하세요."); return; } if (!formStartDate) { toast.error("시작일을 입력하세요."); return; } if (!formEndDate) { toast.error("종료예정일을 입력하세요."); return; } if (!formPM) { toast.error("PM을 선택하세요."); return; } const existing = projects.find((p) => p.id === formProjectId); const payload = { project_no: formProjectNo || formProjectId, name: formName, status: (existing?.status || "계획") as ProjectStatus, pm: formPM, customer: formCustomer, start_date: formStartDate, end_date: formEndDate, source_no: formSourceNo, description: formDesc, progress: existing?.progress ?? 0, parent_id: formParentId || null, relation_type: formParentId ? formRelation : null, }; const isEdit = !!formProjectId; try { if (isEdit) { const res = await updateProject(formProjectId, payload); if (res.success) { toast.success("프로젝트가 수정되었습니다."); await fetchProjects(); setIsProjectModalOpen(false); } else { toast.error(res.message || "프로젝트 수정에 실패했습니다."); } } else { const res = await createProject(payload); if (res.success && res.data) { toast.success("프로젝트가 등록되었습니다."); await fetchProjects(); if (formParentId) { setExpandedIds((prev) => ({ ...prev, [formParentId]: true })); } const projectId = (res.data as any).id; setSelectedId(projectId); fetchTaskDetails(projectId); setIsProjectModalOpen(false); } else { toast.error(res.message || "프로젝트 등록에 실패했습니다."); } } } catch { toast.error("프로젝트 저장에 실패했습니다."); } }; // --- 태스크 모달 --- const openTaskModal = (idx?: number) => { if (idx !== undefined && selectedProject) { const t = selectedProject.tasks[idx]; setEditingTaskIdx(idx); setTName(t.name); setTCategory(t.category); setTAssignee(t.assignee); setTStart(t.start); setTEnd(t.end); setTStatus(t.status); setTProgress(t.progress); setTRemark(t.remark); } else { setEditingTaskIdx(-1); setTName(""); setTCategory("기구설계"); setTAssignee(""); setTStart(selectedProject?.startDate || ""); setTEnd(selectedProject?.endDate || ""); setTStatus("대기"); setTProgress(0); setTRemark(""); } setIsTaskModalOpen(true); }; const handleSaveTask = async () => { if (!tName.trim()) { toast.error("업무명을 입력하세요."); return; } if (!tAssignee) { toast.error("담당자를 선택하세요."); return; } if (!tStart || !tEnd) { toast.error("시작일과 종료일을 입력하세요."); return; } if (!selectedId) return; const payload = { name: tName, category: tCategory, assignee: tAssignee, start_date: tStart, end_date: tEnd, status: tStatus, progress: tProgress, priority: "보통", remark: tRemark, sort_order: String(editingTaskIdx >= 0 ? editingTaskIdx : selectedProject?.tasks.length ?? 0), }; try { if (editingTaskIdx >= 0 && selectedProject?.tasks[editingTaskIdx]?.id) { const taskId = selectedProject.tasks[editingTaskIdx].id!; const res = await updateTask(taskId, payload); if (res.success) { toast.success("업무가 수정되었습니다."); await fetchTaskDetails(selectedId); setIsTaskModalOpen(false); } else { toast.error(res.message || "업무 수정에 실패했습니다."); } } else { const res = await createTask(selectedId, payload); if (res.success) { toast.success("업무가 등록되었습니다."); await fetchTaskDetails(selectedId); await fetchProjects(); setIsTaskModalOpen(false); } else { toast.error(res.message || "업무 등록에 실패했습니다."); } } } catch { toast.error("업무 저장에 실패했습니다."); } }; const handleDeleteTask = async (idx: number) => { if (!selectedProject || !selectedId) return; const task = selectedProject.tasks[idx]; if (!task) return; if (!confirm(`"${task.name}" 업무를 삭제하시겠습니까?`)) return; const taskId = task.id; if (!taskId) { toast.error("삭제할 업무 정보를 찾을 수 없습니다."); return; } try { const res = await deleteTask(taskId); if (res.success) { toast.success("업무가 삭제되었습니다."); await fetchTaskDetails(selectedId); await fetchProjects(); } else { toast.error(res.message || "업무 삭제에 실패했습니다."); } } catch { toast.error("업무 삭제에 실패했습니다."); } }; // --- 상세 패널 계산 --- const childProjects = useMemo( () => (selectedId ? getChildren(projects, selectedId) : []), [projects, selectedId] ); const taskStats = useMemo(() => { if (!selectedProject) return { total: 0, completed: 0, inProgress: 0, delayed: 0 }; const total = selectedProject.tasks.length; const completed = selectedProject.tasks.filter((t) => t.status === "완료").length; const inProgress = selectedProject.tasks.filter((t) => t.status === "진행중").length; const delayed = selectedProject.tasks.filter( (t) => t.status !== "완료" && new Date(t.end) < new Date() ).length; return { total, completed, inProgress, delayed }; }, [selectedProject]); // 카테고리별 그룹핑 (WBS) const tasksByCategory = useMemo(() => { if (!selectedProject) return {}; const groups: Record = {}; selectedProject.tasks.forEach((t, i) => { if (!groups[t.category]) groups[t.category] = []; groups[t.category].push({ ...t, _idx: i }); }); return groups; }, [selectedProject]); // 팀원별 그룹핑 const teamMembers = useMemo(() => { if (!selectedProject) return {}; const members: Record = {}; selectedProject.tasks.forEach((t) => { if (!members[t.assignee]) members[t.assignee] = []; members[t.assignee].push(t); }); return members; }, [selectedProject]); const parentProject = useMemo( () => (selectedProject?.parentId ? projects.find((p) => p.id === selectedProject.parentId) : null), [projects, selectedProject] ); // 태스크 상세 const detailTask = useMemo( () => (selectedProject && taskDetailIdx >= 0 ? selectedProject.tasks[taskDetailIdx] : null), [selectedProject, taskDetailIdx] ); return (
{/* 검색 섹션 */}
setSearchKeyword(e.target.value)} />
{/* 좌우 분할 메인 */}
{/* 왼쪽: 프로젝트 목록 */}
프로젝트 목록 {filteredProjects.length}건
프로젝트번호 상태 프로젝트명 PM 고객 시작일 종료예정 진행률 원접수번호 {loading ? (
로딩 중...
) : treeRows.length === 0 ? ( 조건에 맞는 프로젝트가 없습니다 ) : ( treeRows.map(({ project: p, depth }) => { const hasChildren = filteredProjects.some((c) => c.parentId === p.id); const isExpanded = expandedIds[p.id] !== false; const childCount = getAllDescendants(projects, p.id).length; return ( = 2 && "bg-violet-50/30" )} onClick={() => { setSelectedId(p.id); setDetailTab("wbs"); fetchTaskDetails(p.id); }} >
{hasChildren ? ( ) : ( )} {p.projectNo} {p.relation && ( {getRelationLabel(p.relation)} )}
{p.status} {p.name} {childCount > 0 && ( {childCount} )} {p.pm} {p.customer} {p.startDate} {p.endDate}
{p.progress}%
{p.sourceNo || "-"} ); }) )}
{/* 오른쪽: 상세 */} {selectedId && selectedProject && ( <>
{/* 상세 헤더 */}
{selectedProject.projectNo} - {selectedProject.name}
{/* 상위 프로젝트 링크 */} {parentProject && (
{ setSelectedId(parentProject.id); setDetailTab("wbs"); fetchTaskDetails(parentProject.id); }} > 상위: {parentProject.projectNo} - {parentProject.name} {selectedProject.relation && ( {getRelationLabel(selectedProject.relation)} )}
)} {/* 개요 카드 */}
{[ { label: "전체 업무", value: taskStats.total, color: "text-primary" }, { label: "완료", value: taskStats.completed, color: "text-emerald-600" }, { label: "진행중", value: taskStats.inProgress, color: "text-amber-600" }, { label: "지연", value: taskStats.delayed, color: "text-destructive" }, { label: "하위 프로젝트", value: childProjects.length, color: "text-violet-600" }, ].map((item) => (
{item.label}
{item.value}
))}
{/* 탭 */} WBS 간트차트 팀원 하위({childProjects.length}) {/* WBS 탭 */} {selectedProject.tasks.length === 0 ? (
등록된 업무가 없습니다
) : ( 업무명 담당자 시작일 종료일 상태 진행률 관리 {Object.entries(tasksByCategory).map(([cat, tasks]) => { const catProg = Math.round(tasks.reduce((s, t) => s + t.progress, 0) / tasks.length); return ( {categoryIcons[cat] || "📋"} {cat} {catProg}% {tasks.map((task) => { const isDelay = task.status !== "완료" && new Date(task.end) < new Date(); const displayStatus = isDelay ? "지연" : task.status; return ( {task.name} {task.assignee} {task.start} {task.end} {displayStatus}
{task.progress}%
); })} ); })}
)}
{/* 간트차트 탭 */} {selectedProject.tasks.length === 0 ? (
업무를 등록하면 간트차트가 표시됩니다
) : ( )}
{/* 팀원 탭 */} {selectedProject.tasks.length === 0 ? (
업무를 등록하면 팀원 현황이 표시됩니다
) : (
{Object.entries(teamMembers).map(([name, tasks], idx) => { const avgProg = Math.round(tasks.reduce((s, t) => s + t.progress, 0) / tasks.length); const isPM = name === selectedProject.pm; const avatarColors = ["bg-primary", "bg-emerald-600", "bg-violet-600", "bg-amber-600"]; return (
{name.charAt(0)}
{name} {isPM && PM}
업무 {tasks.length}건 (진행중 {tasks.filter((t) => t.status === "진행중").length}건)
    {tasks.map((t, ti) => { const isDelay = t.status !== "완료" && new Date(t.end) < new Date(); return (
  • {isDelay ? "🔴" : t.status === "완료" ? "✅" : t.status === "진행중" ? "🔵" : "⚪"} {t.name} {t.progress}%
  • ); })}
종합:
{avgProg}%
); })}
)} {/* 하위 프로젝트 탭 */}
{childProjects.map((child) => { const completedCount = child.tasks.filter((t) => t.status === "완료").length; return (
{ setSelectedId(child.id); setDetailTab("wbs"); fetchTaskDetails(child.id); }} >
{child.projectNo}
{child.relation && ( {getRelationLabel(child.relation)} )}
{child.status}
{child.name}
👤 {child.pm} 📅 {child.startDate} ~ {child.endDate}
업무 {child.tasks.length}건 (완료 {completedCount}건)
진행률
{child.progress}%
); })}
openProjectModal(undefined, selectedProject.id)} > 하위 프로젝트 등록
)}
{/* 프로젝트 등록/수정 모달 */} {projects.some((p) => p.id === formProjectId) ? "프로젝트 수정" : formParentId ? "하위 프로젝트 등록" : "프로젝트 등록"} 프로젝트 기본 정보를 입력하세요.
setFormName(e.target.value)} placeholder="프로젝트명" className="h-8 text-xs sm:h-10 sm:text-sm" />
setFormStartDate(e.target.value)} className="h-8 text-xs sm:h-10 sm:text-sm" />
setFormEndDate(e.target.value)} className="h-8 text-xs sm:h-10 sm:text-sm" />
setFormCustomer(e.target.value)} placeholder="고객/거래처명" className="h-8 text-xs sm:h-10 sm:text-sm" />
setFormSourceNo(e.target.value)} placeholder="DR-XXXX-XXXX" className="h-8 text-xs sm:h-10 sm:text-sm" />