- Introduced new routes and controllers for managing shipping orders, including listing, saving, and previewing next order numbers. - Added design management routes and controller for handling design requests, projects, tasks, and work logs. - Implemented company code filtering for multi-tenancy support in both shipping order and design request functionalities. - Enhanced the shipping plan routes to include listing and updating plans, improving overall shipping management capabilities. These changes aim to provide comprehensive management features for shipping orders and design processes, facilitating better organization and tracking within the application.
1513 lines
70 KiB
TypeScript
1513 lines
70 KiB
TypeScript
"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<RelationType, string> = {
|
|
sub: "하위",
|
|
depend: "종속",
|
|
related: "연관",
|
|
};
|
|
return m[r];
|
|
};
|
|
|
|
const getRelationColor = (r: RelationType | null) => {
|
|
if (!r) return "";
|
|
const m: Record<RelationType, string> = {
|
|
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<string, string> = {
|
|
기구설계: "⚙️",
|
|
전장설계: "⚡",
|
|
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<Project[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [expandedIds, setExpandedIds] = useState<Record<string, boolean>>({});
|
|
|
|
// 검색
|
|
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<RelationType>("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<TaskStatus>("대기");
|
|
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<string>();
|
|
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<string, (Task & { _idx: number })[]> = {};
|
|
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<string, Task[]> = {};
|
|
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 (
|
|
<div className="flex flex-col h-[calc(100vh-4rem)] bg-muted/30 p-4 gap-4">
|
|
{/* 검색 섹션 */}
|
|
<Card className="shrink-0">
|
|
<CardContent className="p-4 flex flex-wrap items-end gap-4">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs text-muted-foreground">상태</Label>
|
|
<Select value={searchStatus} onValueChange={setSearchStatus}>
|
|
<SelectTrigger className="w-[110px] h-9"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">전체</SelectItem>
|
|
<SelectItem value="진행중">진행중</SelectItem>
|
|
<SelectItem value="계획">계획</SelectItem>
|
|
<SelectItem value="보류">보류</SelectItem>
|
|
<SelectItem value="완료">완료</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs text-muted-foreground">PM</Label>
|
|
<Select value={searchPM} onValueChange={setSearchPM}>
|
|
<SelectTrigger className="w-[110px] h-9"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">전체</SelectItem>
|
|
<SelectItem value="이설계">이설계</SelectItem>
|
|
<SelectItem value="박도면">박도면</SelectItem>
|
|
<SelectItem value="최기구">최기구</SelectItem>
|
|
<SelectItem value="김전장">김전장</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs text-muted-foreground">프로젝트/고객 검색</Label>
|
|
<Input
|
|
placeholder="프로젝트번호 / 프로젝트명 / 고객명"
|
|
className="w-[280px] h-9"
|
|
value={searchKeyword}
|
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="flex-1" />
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}>
|
|
<RotateCcw className="w-4 h-4 mr-2" /> 초기화
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 좌우 분할 메인 */}
|
|
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
|
|
<ResizablePanelGroup direction="horizontal">
|
|
{/* 왼쪽: 프로젝트 목록 */}
|
|
<ResizablePanel defaultSize={selectedId ? 50 : 100} minSize={30}>
|
|
<div className="flex flex-col h-full">
|
|
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
|
<div className="font-semibold flex items-center gap-2">
|
|
<Rocket className="w-5 h-5" /> 프로젝트 목록
|
|
<Badge variant="secondary" className="font-normal">{filteredProjects.length}건</Badge>
|
|
</div>
|
|
<Button size="sm" onClick={() => openProjectModal()}>
|
|
<Plus className="w-4 h-4 mr-1.5" /> 프로젝트 등록
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto">
|
|
<Table>
|
|
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
|
|
<TableRow>
|
|
<TableHead className="w-[160px]">프로젝트번호</TableHead>
|
|
<TableHead className="w-[80px] text-center">상태</TableHead>
|
|
<TableHead className="w-[200px]">프로젝트명</TableHead>
|
|
<TableHead className="w-[70px]">PM</TableHead>
|
|
<TableHead className="w-[80px]">고객</TableHead>
|
|
<TableHead className="w-[90px]">시작일</TableHead>
|
|
<TableHead className="w-[90px]">종료예정</TableHead>
|
|
<TableHead className="w-[100px] text-center">진행률</TableHead>
|
|
<TableHead className="w-[90px]">원접수번호</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={9} className="h-32 text-center text-muted-foreground">
|
|
<Loader2 className="w-10 h-10 mx-auto mb-2 animate-spin text-muted-foreground" />
|
|
<div className="text-sm">로딩 중...</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : treeRows.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={9} className="h-32 text-center text-muted-foreground">
|
|
<Rocket className="w-10 h-10 mx-auto mb-2 text-muted-foreground/30" />
|
|
조건에 맞는 프로젝트가 없습니다
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
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 (
|
|
<TableRow
|
|
key={p.id}
|
|
className={cn(
|
|
"cursor-pointer hover:bg-muted/50 transition-colors",
|
|
selectedId === p.id && "bg-primary/5",
|
|
depth === 1 && "bg-slate-50/50",
|
|
depth >= 2 && "bg-violet-50/30"
|
|
)}
|
|
onClick={() => {
|
|
setSelectedId(p.id);
|
|
setDetailTab("wbs");
|
|
fetchTaskDetails(p.id);
|
|
}}
|
|
>
|
|
<TableCell>
|
|
<div className="flex items-center gap-1" style={{ paddingLeft: depth * 20 }}>
|
|
{hasChildren ? (
|
|
<button
|
|
className="p-0.5 rounded hover:bg-muted transition-transform"
|
|
onClick={(e) => { e.stopPropagation(); toggleExpand(p.id); }}
|
|
>
|
|
<ChevronRight className={cn("w-3.5 h-3.5 text-muted-foreground transition-transform", isExpanded && "rotate-90")} />
|
|
</button>
|
|
) : (
|
|
<span className="w-4" />
|
|
)}
|
|
<span className={cn("font-semibold text-xs", depth === 0 ? "text-primary" : depth === 1 ? "text-indigo-600" : "text-violet-600")}>
|
|
{p.projectNo}
|
|
</span>
|
|
{p.relation && (
|
|
<span className={cn("px-1.5 py-0.5 rounded text-[10px] font-medium", getRelationColor(p.relation))}>
|
|
{getRelationLabel(p.relation)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getStatusColor(p.status))}>
|
|
{p.status}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="font-medium text-sm">
|
|
{p.name}
|
|
{childCount > 0 && (
|
|
<Badge variant="outline" className="ml-1.5 text-[10px] py-0 px-1.5 font-normal">
|
|
<FolderOpen className="w-3 h-3 mr-0.5" /> {childCount}
|
|
</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-xs">{p.pm}</TableCell>
|
|
<TableCell className="text-xs">{p.customer}</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">{p.startDate}</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">{p.endDate}</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
|
<div className={cn("h-full rounded-full transition-all", progressColor(p.progress))} style={{ width: `${p.progress}%` }} />
|
|
</div>
|
|
<span className={cn("text-[11px] font-medium min-w-[28px] text-right", progressTextColor(p.progress))}>{p.progress}%</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">{p.sourceNo || "-"}</TableCell>
|
|
</TableRow>
|
|
);
|
|
})
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
</ResizablePanel>
|
|
|
|
{/* 오른쪽: 상세 */}
|
|
{selectedId && selectedProject && (
|
|
<>
|
|
<ResizableHandle withHandle />
|
|
<ResizablePanel defaultSize={50} minSize={30}>
|
|
<div className="flex flex-col h-full bg-card">
|
|
{/* 상세 헤더 */}
|
|
<div className="flex items-center justify-between p-3 border-b shrink-0">
|
|
<span className="font-semibold text-sm flex items-center gap-2">
|
|
<ClipboardList className="w-4 h-4" />
|
|
{selectedProject.projectNo} - {selectedProject.name}
|
|
</span>
|
|
<div className="flex items-center gap-1.5">
|
|
<Button size="sm" variant="default" className="h-7 text-xs" onClick={() => openTaskModal()}>
|
|
<Plus className="w-3.5 h-3.5 mr-1" /> 업무 추가
|
|
</Button>
|
|
<Button size="sm" variant="default" className="h-7 text-xs bg-violet-600 hover:bg-violet-700" onClick={() => openProjectModal(undefined, selectedProject.id)}>
|
|
<FolderOpen className="w-3.5 h-3.5 mr-1" /> 하위 프로젝트
|
|
</Button>
|
|
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openProjectModal(selectedProject)}>
|
|
<Pencil className="w-3.5 h-3.5 mr-1" /> 수정
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto p-4 space-y-4">
|
|
{/* 상위 프로젝트 링크 */}
|
|
{parentProject && (
|
|
<div
|
|
className="flex items-center gap-2 px-3 py-2 bg-blue-50 rounded-md border-l-[3px] border-primary text-sm cursor-pointer hover:bg-blue-100 transition-colors"
|
|
onClick={() => { setSelectedId(parentProject.id); setDetailTab("wbs"); fetchTaskDetails(parentProject.id); }}
|
|
>
|
|
<span className="text-muted-foreground">상위:</span>
|
|
<span className="text-primary font-semibold">{parentProject.projectNo} - {parentProject.name}</span>
|
|
{selectedProject.relation && (
|
|
<span className={cn("px-1.5 py-0.5 rounded text-[10px] font-medium", getRelationColor(selectedProject.relation))}>
|
|
{getRelationLabel(selectedProject.relation)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 개요 카드 */}
|
|
<div className="grid grid-cols-5 gap-3">
|
|
{[
|
|
{ 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) => (
|
|
<div key={item.label} className="bg-muted/30 rounded-lg p-3 text-center border">
|
|
<div className="text-[11px] text-muted-foreground mb-1">{item.label}</div>
|
|
<div className={cn("text-2xl font-bold", item.color)}>{item.value}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 탭 */}
|
|
<Tabs value={detailTab} onValueChange={setDetailTab}>
|
|
<TabsList className="w-full">
|
|
<TabsTrigger value="wbs" className="flex-1 text-xs gap-1.5">
|
|
<ClipboardList className="w-3.5 h-3.5" /> WBS
|
|
</TabsTrigger>
|
|
<TabsTrigger value="gantt" className="flex-1 text-xs gap-1.5">
|
|
<BarChart3 className="w-3.5 h-3.5" /> 간트차트
|
|
</TabsTrigger>
|
|
<TabsTrigger value="team" className="flex-1 text-xs gap-1.5">
|
|
<Users className="w-3.5 h-3.5" /> 팀원
|
|
</TabsTrigger>
|
|
<TabsTrigger value="subprojects" className="flex-1 text-xs gap-1.5">
|
|
<FolderOpen className="w-3.5 h-3.5" /> 하위({childProjects.length})
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* WBS 탭 */}
|
|
<TabsContent value="wbs" className="mt-4">
|
|
{selectedProject.tasks.length === 0 ? (
|
|
<div className="text-center py-10 text-muted-foreground">
|
|
<ClipboardList className="w-10 h-10 mx-auto mb-2 text-muted-foreground/30" />
|
|
<div className="text-sm">등록된 업무가 없습니다</div>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[180px]">업무명</TableHead>
|
|
<TableHead className="w-[70px]">담당자</TableHead>
|
|
<TableHead className="w-[85px]">시작일</TableHead>
|
|
<TableHead className="w-[85px]">종료일</TableHead>
|
|
<TableHead className="w-[70px] text-center">상태</TableHead>
|
|
<TableHead className="w-[90px] text-center">진행률</TableHead>
|
|
<TableHead className="w-[80px] text-center">관리</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{Object.entries(tasksByCategory).map(([cat, tasks]) => {
|
|
const catProg = Math.round(tasks.reduce((s, t) => s + t.progress, 0) / tasks.length);
|
|
return (
|
|
<React.Fragment key={cat}>
|
|
<TableRow className="bg-muted/30">
|
|
<TableCell colSpan={5} className="font-semibold text-sm">
|
|
{categoryIcons[cat] || "📋"} {cat}
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<span className={cn("text-xs font-semibold", progressTextColor(catProg))}>{catProg}%</span>
|
|
</TableCell>
|
|
<TableCell />
|
|
</TableRow>
|
|
{tasks.map((task) => {
|
|
const isDelay = task.status !== "완료" && new Date(task.end) < new Date();
|
|
const displayStatus = isDelay ? "지연" : task.status;
|
|
return (
|
|
<TableRow key={task._idx}>
|
|
<TableCell className="pl-8 text-xs">{task.name}</TableCell>
|
|
<TableCell className="text-xs">{task.assignee}</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">{task.start}</TableCell>
|
|
<TableCell className={cn("text-xs", isDelay && "text-destructive font-semibold")}>{task.end}</TableCell>
|
|
<TableCell className="text-center">
|
|
<span className={cn("px-2 py-0.5 rounded-full text-[10px] font-medium", getTaskStatusColor(displayStatus))}>
|
|
{displayStatus}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-1">
|
|
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
|
<div className={cn("h-full rounded-full", progressColor(task.progress))} style={{ width: `${task.progress}%` }} />
|
|
</div>
|
|
<span className="text-[10px] text-muted-foreground min-w-[24px] text-right">{task.progress}%</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<div className="flex items-center justify-center gap-0.5">
|
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => { setTaskDetailIdx(task._idx); setTaskDetailTab("log"); setIsTaskDetailOpen(true); }}>
|
|
<FileText className="w-3.5 h-3.5" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => openTaskModal(task._idx)}>
|
|
<Pencil className="w-3.5 h-3.5" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeleteTask(task._idx)}>
|
|
<Trash2 className="w-3.5 h-3.5 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* 간트차트 탭 */}
|
|
<TabsContent value="gantt" className="mt-4">
|
|
{selectedProject.tasks.length === 0 ? (
|
|
<div className="text-center py-10 text-muted-foreground">
|
|
<BarChart3 className="w-10 h-10 mx-auto mb-2 text-muted-foreground/30" />
|
|
<div className="text-sm">업무를 등록하면 간트차트가 표시됩니다</div>
|
|
</div>
|
|
) : (
|
|
<GanttChart tasks={selectedProject.tasks} startDate={selectedProject.startDate} endDate={selectedProject.endDate} />
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* 팀원 탭 */}
|
|
<TabsContent value="team" className="mt-4">
|
|
{selectedProject.tasks.length === 0 ? (
|
|
<div className="text-center py-10 text-muted-foreground">
|
|
<Users className="w-10 h-10 mx-auto mb-2 text-muted-foreground/30" />
|
|
<div className="text-sm">업무를 등록하면 팀원 현황이 표시됩니다</div>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
{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 (
|
|
<div key={name} className="border rounded-lg p-4 hover:shadow-sm transition-shadow">
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<div className={cn("w-10 h-10 rounded-full flex items-center justify-center text-white font-bold", avatarColors[idx % 4])}>
|
|
{name.charAt(0)}
|
|
</div>
|
|
<div>
|
|
<div className="font-semibold text-sm flex items-center gap-1.5">
|
|
{name}
|
|
{isPM && <Badge variant="secondary" className="text-[10px] py-0">PM</Badge>}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
업무 {tasks.length}건 (진행중 {tasks.filter((t) => t.status === "진행중").length}건)
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<ul className="space-y-1 mb-3">
|
|
{tasks.map((t, ti) => {
|
|
const isDelay = t.status !== "완료" && new Date(t.end) < new Date();
|
|
return (
|
|
<li key={ti} className="flex items-center justify-between text-xs py-1 border-b border-muted/50 last:border-0">
|
|
<span>
|
|
{isDelay ? "🔴" : t.status === "완료" ? "✅" : t.status === "진행중" ? "🔵" : "⚪"} {t.name}
|
|
</span>
|
|
<span className={cn("text-[11px]", isDelay ? "text-destructive" : "text-muted-foreground")}>{t.progress}%</span>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<span>종합:</span>
|
|
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
|
<div className={cn("h-full rounded-full", progressColor(avgProg))} style={{ width: `${avgProg}%` }} />
|
|
</div>
|
|
<span className={cn("font-semibold", progressTextColor(avgProg))}>{avgProg}%</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* 하위 프로젝트 탭 */}
|
|
<TabsContent value="subprojects" className="mt-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
{childProjects.map((child) => {
|
|
const completedCount = child.tasks.filter((t) => t.status === "완료").length;
|
|
return (
|
|
<div
|
|
key={child.id}
|
|
className="border rounded-lg p-4 cursor-pointer hover:border-primary hover:shadow-sm transition-all"
|
|
onClick={() => { setSelectedId(child.id); setDetailTab("wbs"); fetchTaskDetails(child.id); }}
|
|
>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div>
|
|
<div className="text-xs font-semibold text-primary">{child.projectNo}</div>
|
|
{child.relation && (
|
|
<span className={cn("px-1.5 py-0.5 rounded text-[10px] font-medium", getRelationColor(child.relation))}>
|
|
{getRelationLabel(child.relation)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getStatusColor(child.status))}>{child.status}</span>
|
|
</div>
|
|
<div className="font-semibold text-sm mb-2">{child.name}</div>
|
|
<div className="flex gap-3 text-xs text-muted-foreground mb-2">
|
|
<span>👤 {child.pm}</span>
|
|
<span>📅 {child.startDate} ~ {child.endDate}</span>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground mb-2">
|
|
업무 {child.tasks.length}건 (완료 {completedCount}건)
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<span>진행률</span>
|
|
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
|
<div className={cn("h-full rounded-full", progressColor(child.progress))} style={{ width: `${child.progress}%` }} />
|
|
</div>
|
|
<span className={cn("font-semibold", progressTextColor(child.progress))}>{child.progress}%</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
<div
|
|
className="border-2 border-dashed rounded-lg p-4 flex flex-col items-center justify-center min-h-[120px] cursor-pointer text-muted-foreground hover:border-primary hover:text-primary hover:bg-primary/5 transition-all"
|
|
onClick={() => openProjectModal(undefined, selectedProject.id)}
|
|
>
|
|
<Plus className="w-7 h-7 mb-1" />
|
|
<span className="text-sm font-medium">하위 프로젝트 등록</span>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
</ResizablePanel>
|
|
</>
|
|
)}
|
|
</ResizablePanelGroup>
|
|
</div>
|
|
|
|
{/* 프로젝트 등록/수정 모달 */}
|
|
<Dialog open={isProjectModalOpen} onOpenChange={setIsProjectModalOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">
|
|
{projects.some((p) => p.id === formProjectId) ? "프로젝트 수정" : formParentId ? "하위 프로젝트 등록" : "프로젝트 등록"}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">프로젝트 기본 정보를 입력하세요.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3 sm:space-y-4">
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">프로젝트 번호</Label>
|
|
<Input value={formProjectNo || formProjectId} readOnly className="h-8 text-xs sm:h-10 sm:text-sm bg-muted/50" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">프로젝트명 <span className="text-destructive">*</span></Label>
|
|
<Input value={formName} onChange={(e) => setFormName(e.target.value)} placeholder="프로젝트명" className="h-8 text-xs sm:h-10 sm:text-sm" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">시작일 <span className="text-destructive">*</span></Label>
|
|
<Input type="date" value={formStartDate} onChange={(e) => setFormStartDate(e.target.value)} className="h-8 text-xs sm:h-10 sm:text-sm" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">종료예정일 <span className="text-destructive">*</span></Label>
|
|
<Input type="date" value={formEndDate} onChange={(e) => setFormEndDate(e.target.value)} className="h-8 text-xs sm:h-10 sm:text-sm" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">PM <span className="text-destructive">*</span></Label>
|
|
<Select value={formPM || "none"} onValueChange={(v) => setFormPM(v === "none" ? "" : v)}>
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm"><SelectValue placeholder="선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">선택</SelectItem>
|
|
<SelectItem value="이설계">이설계</SelectItem>
|
|
<SelectItem value="박도면">박도면</SelectItem>
|
|
<SelectItem value="최기구">최기구</SelectItem>
|
|
<SelectItem value="김전장">김전장</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">고객</Label>
|
|
<Input value={formCustomer} onChange={(e) => setFormCustomer(e.target.value)} placeholder="고객/거래처명" className="h-8 text-xs sm:h-10 sm:text-sm" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">상위 프로젝트</Label>
|
|
<Select value={formParentId || "none"} onValueChange={(v) => setFormParentId(v === "none" ? "" : v)}>
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm"><SelectValue placeholder="없음" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">없음 (최상위)</SelectItem>
|
|
{projects.filter((p) => p.id !== formProjectId).map((p) => (
|
|
<SelectItem key={p.id} value={p.id}>{p.projectNo} - {p.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">관계 유형</Label>
|
|
<Select value={formRelation} onValueChange={(v) => setFormRelation(v as RelationType)}>
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="sub">하위 프로젝트</SelectItem>
|
|
<SelectItem value="depend">종속 프로젝트</SelectItem>
|
|
<SelectItem value="related">연관 프로젝트</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">원접수번호</Label>
|
|
<Input value={formSourceNo} onChange={(e) => setFormSourceNo(e.target.value)} placeholder="DR-XXXX-XXXX" className="h-8 text-xs sm:h-10 sm:text-sm" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">설명</Label>
|
|
<Textarea value={formDesc} onChange={(e) => setFormDesc(e.target.value)} placeholder="프로젝트 개요" rows={3} />
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button variant="outline" onClick={() => setIsProjectModalOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">취소</Button>
|
|
<Button onClick={handleSaveProject} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
|
<Save className="w-4 h-4 mr-1.5" /> 저장
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 업무 등록/수정 모달 */}
|
|
<Dialog open={isTaskModalOpen} onOpenChange={setIsTaskModalOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">{editingTaskIdx >= 0 ? "업무 수정" : "업무 등록"}</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">업무 정보를 입력하세요.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">업무명 <span className="text-destructive">*</span></Label>
|
|
<Input value={tName} onChange={(e) => setTName(e.target.value)} placeholder="업무명" className="h-8 text-xs sm:h-10 sm:text-sm" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">업무 유형</Label>
|
|
<Select value={tCategory} onValueChange={setTCategory}>
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{["기구설계", "전장설계", "SW개발", "구매/조달", "조립/시운전", "검토/승인"].map((c) => (
|
|
<SelectItem key={c} value={c}>{c}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">담당자 <span className="text-destructive">*</span></Label>
|
|
<Select value={tAssignee || "none"} onValueChange={(v) => setTAssignee(v === "none" ? "" : v)}>
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm"><SelectValue placeholder="선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">선택</SelectItem>
|
|
{["이설계", "박도면", "최기구", "김전장", "정SW", "한조립", "박구매", "팀장"].map((n) => (
|
|
<SelectItem key={n} value={n}>{n}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">시작일 <span className="text-destructive">*</span></Label>
|
|
<Input type="date" value={tStart} onChange={(e) => setTStart(e.target.value)} className="h-8 text-xs sm:h-10 sm:text-sm" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">종료일 <span className="text-destructive">*</span></Label>
|
|
<Input type="date" value={tEnd} onChange={(e) => setTEnd(e.target.value)} className="h-8 text-xs sm:h-10 sm:text-sm" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">상태</Label>
|
|
<Select value={tStatus} onValueChange={(v) => setTStatus(v as TaskStatus)}>
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{(["대기", "진행중", "검토중", "완료"] as TaskStatus[]).map((s) => (
|
|
<SelectItem key={s} value={s}>{s}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">진행률 (%)</Label>
|
|
<Input type="number" min={0} max={100} value={tProgress} onChange={(e) => setTProgress(Number(e.target.value))} className="h-8 text-xs sm:h-10 sm:text-sm" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">비고</Label>
|
|
<Textarea value={tRemark} onChange={(e) => setTRemark(e.target.value)} placeholder="비고 사항" rows={2} />
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button variant="outline" onClick={() => setIsTaskModalOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">취소</Button>
|
|
<Button onClick={handleSaveTask} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
|
<Save className="w-4 h-4 mr-1.5" /> 저장
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 태스크 상세 모달 (수행기록/이슈) */}
|
|
<Dialog open={isTaskDetailOpen} onOpenChange={setIsTaskDetailOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[700px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">{detailTask?.name || "업무 상세"}</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">업무 이력 및 이슈를 확인합니다.</DialogDescription>
|
|
</DialogHeader>
|
|
{detailTask && (
|
|
<>
|
|
{/* 태스크 요약 */}
|
|
<div className="grid grid-cols-2 gap-2 bg-muted/30 p-3 rounded-lg border text-xs">
|
|
<div className="flex gap-2"><span className="text-muted-foreground w-[50px]">유형</span><span className="font-medium">{detailTask.category}</span></div>
|
|
<div className="flex gap-2"><span className="text-muted-foreground w-[50px]">담당자</span><span className="font-medium">{detailTask.assignee}</span></div>
|
|
<div className="flex gap-2"><span className="text-muted-foreground w-[50px]">기간</span><span className="font-medium">{detailTask.start} ~ {detailTask.end}</span></div>
|
|
<div className="flex gap-2 items-center">
|
|
<span className="text-muted-foreground w-[50px]">진행률</span>
|
|
<div className="flex items-center gap-1.5 flex-1">
|
|
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden max-w-[60px]">
|
|
<div className={cn("h-full rounded-full", progressColor(detailTask.progress))} style={{ width: `${detailTask.progress}%` }} />
|
|
</div>
|
|
<span className={cn("font-semibold", progressTextColor(detailTask.progress))}>{detailTask.progress}%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Tabs value={taskDetailTab} onValueChange={setTaskDetailTab}>
|
|
<TabsList className="w-full">
|
|
<TabsTrigger value="log" className="flex-1 text-xs">수행이력 ({detailTask.workLogs?.length || 0})</TabsTrigger>
|
|
<TabsTrigger value="issue" className="flex-1 text-xs">이슈 ({detailTask.issues?.length || 0})</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="log" className="mt-3">
|
|
<div className="bg-blue-50 rounded-md p-2.5 mb-3 text-xs text-blue-800 border border-blue-100">
|
|
수행기록 등록/수정은 <strong>내업무현황</strong> 메뉴에서 진행합니다.
|
|
</div>
|
|
{(!detailTask.workLogs || detailTask.workLogs.length === 0) ? (
|
|
<div className="text-center py-8 text-muted-foreground text-sm">등록된 수행기록이 없습니다.</div>
|
|
) : (
|
|
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
|
{[...detailTask.workLogs].sort((a, b) => b.date.localeCompare(a.date)).map((log, i) => {
|
|
const d = new Date(log.date);
|
|
const days = ["일", "월", "화", "수", "목", "금", "토"];
|
|
return (
|
|
<div key={i} className="flex gap-3 p-3 bg-muted/20 rounded-lg border text-xs">
|
|
<div className="bg-background border rounded-md px-2 py-1.5 text-center min-w-[55px] shrink-0">
|
|
<div className="text-[10px] text-muted-foreground">{d.getMonth() + 1}월</div>
|
|
<div className="text-lg font-bold">{d.getDate()}</div>
|
|
<div className="text-[10px] text-muted-foreground">{days[d.getDay()]}</div>
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="text-sm mb-1">{log.desc}</div>
|
|
<div className="flex gap-2 text-muted-foreground flex-wrap">
|
|
<Badge variant="secondary" className="text-[10px] py-0">⏱ {log.hours}h</Badge>
|
|
{log.progressBefore !== undefined && (
|
|
<Badge variant="outline" className="text-[10px] py-0 bg-emerald-50 text-emerald-700 border-emerald-200">
|
|
📈 {log.progressBefore}% → {log.progressAfter}%
|
|
</Badge>
|
|
)}
|
|
{log.author && <span>👤 {log.author}</span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="issue" className="mt-3">
|
|
{(!detailTask.issues || detailTask.issues.length === 0) ? (
|
|
<div className="text-center py-8 text-muted-foreground text-sm">등록된 이슈가 없습니다.</div>
|
|
) : (
|
|
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
|
{detailTask.issues.map((issue) => {
|
|
const priorityColor = issue.priority === "긴급" ? "text-destructive" : issue.priority === "높음" ? "text-amber-600" : "text-muted-foreground";
|
|
const statusBadge = issue.status === "해결" ? "bg-emerald-100 text-emerald-700" : issue.status === "진행중" ? "bg-blue-100 text-blue-700" : "bg-rose-100 text-rose-700";
|
|
return (
|
|
<div key={issue.id} className="border rounded-lg p-3">
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<div className="font-semibold text-xs flex items-center gap-1">
|
|
<span className={priorityColor}>●</span> {issue.title}
|
|
</div>
|
|
<span className={cn("px-2 py-0.5 rounded-full text-[10px] font-medium", statusBadge)}>{issue.status}</span>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground mb-1">{issue.desc}</div>
|
|
<div className="flex gap-2 text-[11px] text-muted-foreground">
|
|
<span>📅 {issue.registeredDate}</span>
|
|
<span>👤 {issue.registeredBy}</span>
|
|
{issue.resolvedDate && <span>✅ {issue.resolvedDate}</span>}
|
|
<span className={priorityColor}>⚡ {issue.priority}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</>
|
|
)}
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setIsTaskDetailOpen(false)} className="h-8 text-xs sm:h-10 sm:text-sm">닫기</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- 간트차트 컴포넌트 ---
|
|
function GanttChart({ tasks, startDate, endDate }: { tasks: Task[]; startDate: string; endDate: string }) {
|
|
const pStart = new Date(startDate);
|
|
const pEnd = new Date(endDate);
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
const totalDays = Math.ceil((pEnd.getTime() - pStart.getTime()) / (1000 * 60 * 60 * 24)) + 1;
|
|
const useWeekly = totalDays > 90;
|
|
const cellWidth = useWeekly ? 50 : 28;
|
|
|
|
const dates: Date[] = [];
|
|
const cur = new Date(pStart);
|
|
if (useWeekly) {
|
|
while (cur <= pEnd) {
|
|
dates.push(new Date(cur));
|
|
cur.setDate(cur.getDate() + 7);
|
|
}
|
|
} else {
|
|
while (cur <= pEnd) {
|
|
dates.push(new Date(cur));
|
|
cur.setDate(cur.getDate() + 1);
|
|
}
|
|
}
|
|
|
|
const barColors: Record<string, string> = {
|
|
기구설계: "bg-blue-500",
|
|
전장설계: "bg-amber-500",
|
|
SW개발: "bg-violet-500",
|
|
"구매/조달": "bg-emerald-500",
|
|
"조립/시운전": "bg-rose-500",
|
|
"검토/승인": "bg-gray-400",
|
|
};
|
|
|
|
const todayFromStart = Math.ceil((today.getTime() - pStart.getTime()) / (1000 * 60 * 60 * 24));
|
|
const todayLeft = useWeekly ? (todayFromStart / 7) * cellWidth + 180 : todayFromStart * cellWidth + 180;
|
|
const showTodayLine = today >= pStart && today <= pEnd;
|
|
|
|
return (
|
|
<div className="border rounded-lg overflow-hidden">
|
|
{/* 범례 */}
|
|
<div className="flex gap-3 p-2 flex-wrap text-[11px] border-b bg-muted/20">
|
|
{Object.entries(barColors).map(([cat, color]) => (
|
|
<span key={cat} className="flex items-center gap-1">
|
|
{categoryIcons[cat]} <span className={cn("w-3 h-3 rounded-sm", color)} /> {cat}
|
|
</span>
|
|
))}
|
|
<span className="text-destructive font-semibold">│ 오늘</span>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto relative">
|
|
{showTodayLine && (
|
|
<div className="absolute top-0 bottom-0 w-[2px] bg-destructive z-5" style={{ left: todayLeft }} />
|
|
)}
|
|
|
|
{/* 헤더 */}
|
|
<div className="flex border-b sticky top-0 bg-muted/50 z-4">
|
|
<div className="w-[180px] min-w-[180px] p-2 text-xs font-semibold text-muted-foreground border-r shrink-0">업무명</div>
|
|
<div className="flex">
|
|
{dates.map((d, i) => {
|
|
const dow = d.getDay();
|
|
const isWeekend = (dow === 0 || dow === 6) && !useWeekly;
|
|
const isToday = d.toDateString() === today.toDateString();
|
|
return (
|
|
<div
|
|
key={i}
|
|
className={cn(
|
|
"text-center text-[10px] text-muted-foreground border-r py-1.5",
|
|
isWeekend && "bg-rose-50",
|
|
isToday && "bg-blue-50 text-primary font-bold"
|
|
)}
|
|
style={{ minWidth: cellWidth }}
|
|
>
|
|
{useWeekly ? `${d.getMonth() + 1}/${d.getDate()}` : d.getDate()}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 행 */}
|
|
{tasks.map((task, i) => {
|
|
const tStart = new Date(task.start);
|
|
const tEnd = new Date(task.end);
|
|
const dayFromStart = Math.max(0, Math.ceil((tStart.getTime() - pStart.getTime()) / (1000 * 60 * 60 * 24)));
|
|
const taskDays = Math.max(1, Math.ceil((tEnd.getTime() - tStart.getTime()) / (1000 * 60 * 60 * 24)) + 1);
|
|
const leftPos = useWeekly ? (dayFromStart / 7) * cellWidth : dayFromStart * cellWidth;
|
|
const barWidth = useWeekly ? (taskDays / 7) * cellWidth : taskDays * cellWidth;
|
|
const barColor = barColors[task.category] || "bg-blue-500";
|
|
|
|
return (
|
|
<div key={i} className="flex border-b hover:bg-muted/30 min-h-[34px]">
|
|
<div className="w-[180px] min-w-[180px] px-3 py-2 text-xs text-muted-foreground border-r truncate shrink-0" title={task.name}>{task.name}</div>
|
|
<div className="flex relative items-center" style={{ minWidth: dates.length * cellWidth }}>
|
|
{dates.map((d, di) => {
|
|
const dow = d.getDay();
|
|
const isWeekend = (dow === 0 || dow === 6) && !useWeekly;
|
|
return <div key={di} className={cn("border-r h-full", isWeekend && "bg-rose-50")} style={{ minWidth: cellWidth }} />;
|
|
})}
|
|
<div
|
|
className={cn("absolute h-5 rounded text-[10px] text-white flex items-center justify-center font-semibold", barColor)}
|
|
style={{ left: leftPos, width: Math.max(barWidth, 16), top: "50%", transform: "translateY(-50%)" }}
|
|
>
|
|
{barWidth > 30 ? `${task.progress}%` : ""}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|