1502 lines
71 KiB
TypeScript
1502 lines
71 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 { Badge } from "@/components/ui/badge";
|
|
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,
|
|
Save,
|
|
Pencil,
|
|
Trash2,
|
|
ChevronRight,
|
|
FolderOpen,
|
|
Rocket,
|
|
ClipboardList,
|
|
BarChart3,
|
|
Users,
|
|
FileText,
|
|
Loader2,
|
|
Settings2,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { toast } from "sonner";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
getProjectList,
|
|
createProject,
|
|
updateProject,
|
|
getTasksByProject,
|
|
createTask,
|
|
updateTask,
|
|
deleteTask,
|
|
} from "@/lib/api/design";
|
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
|
|
|
// --- 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,
|
|
};
|
|
}
|
|
|
|
// --- 상태 색상 (CSS 변수 기반) ---
|
|
const getStatusColor = (status: ProjectStatus) => {
|
|
switch (status) {
|
|
case "진행중":
|
|
return "bg-primary/10 text-primary border-primary/20";
|
|
case "계획":
|
|
return "bg-muted text-muted-foreground border-border";
|
|
case "보류":
|
|
return "bg-destructive/10 text-destructive border-destructive/20";
|
|
case "완료":
|
|
return "bg-primary/15 text-primary border-primary/25";
|
|
default:
|
|
return "bg-muted text-muted-foreground border-border";
|
|
}
|
|
};
|
|
|
|
const getTaskStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case "대기":
|
|
return "bg-muted text-muted-foreground";
|
|
case "진행중":
|
|
return "bg-primary/10 text-primary";
|
|
case "검토중":
|
|
return "bg-destructive/10 text-destructive";
|
|
case "완료":
|
|
return "bg-primary/15 text-primary";
|
|
case "지연":
|
|
return "bg-destructive/10 text-destructive";
|
|
default:
|
|
return "bg-muted text-muted-foreground";
|
|
}
|
|
};
|
|
|
|
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-primary/10 text-primary",
|
|
depend: "bg-destructive/10 text-destructive",
|
|
related: "bg-secondary text-secondary-foreground",
|
|
};
|
|
return m[r];
|
|
};
|
|
|
|
const categoryIcons: Record<string, string> = {
|
|
기구설계: "⚙️",
|
|
전장설계: "⚡",
|
|
SW개발: "💻",
|
|
"구매/조달": "📦",
|
|
"조립/시운전": "🔧",
|
|
"검토/승인": "✅",
|
|
};
|
|
|
|
const progressColor = (p: number) =>
|
|
p >= 80 ? "bg-primary" : p >= 40 ? "bg-primary/70" : p > 0 ? "bg-primary/40" : "bg-muted-foreground/30";
|
|
|
|
const progressTextColor = (p: number) =>
|
|
p >= 80 ? "text-primary" : p >= 40 ? "text-primary/80" : p > 0 ? "text-primary/60" : "text-muted-foreground";
|
|
|
|
// --- 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;
|
|
}
|
|
|
|
// --- Grid Columns ---
|
|
const PROJECT_GRID_COLUMNS = [
|
|
{ key: "project_no", label: "프로젝트번호" },
|
|
{ key: "status", label: "상태" },
|
|
{ key: "name", label: "프로젝트명" },
|
|
{ key: "pm", label: "PM" },
|
|
{ key: "customer", label: "고객" },
|
|
{ key: "start_date", label: "시작일" },
|
|
{ key: "end_date", label: "종료예정" },
|
|
{ key: "progress", label: "진행률" },
|
|
{ key: "source_no", label: "원접수번호" },
|
|
];
|
|
|
|
// --- Component ---
|
|
export default function DesignProjectPage() {
|
|
const ts = useTableSettings("c16-design-project", "dsn_project", PROJECT_GRID_COLUMNS);
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [expandedIds, setExpandedIds] = useState<Record<string, boolean>>({});
|
|
|
|
// 검색 필터 (DynamicSearchFilter)
|
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
|
|
|
// 상세 탭
|
|
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("업무 상세를 불러오는데 실패했습니다.");
|
|
}
|
|
}, []);
|
|
|
|
// snake_case → camelCase 매핑 (DynamicSearchFilter의 columnName은 snake_case)
|
|
const fieldMap: Record<string, string> = {
|
|
project_no: "projectNo",
|
|
start_date: "startDate",
|
|
end_date: "endDate",
|
|
source_no: "sourceNo",
|
|
};
|
|
const getFieldValue = (obj: any, colName: string): string => {
|
|
const key = fieldMap[colName] || colName;
|
|
const val = obj[key];
|
|
return val !== undefined && val !== null ? String(val) : "";
|
|
};
|
|
|
|
// 필터링
|
|
const filteredProjects = useMemo(() => {
|
|
if (searchFilters.length === 0) return projects;
|
|
|
|
const matched = new Set<string>();
|
|
projects.forEach((p) => {
|
|
let pass = true;
|
|
for (const f of searchFilters) {
|
|
const val = getFieldValue(p, f.columnName);
|
|
if (f.operator === "contains") {
|
|
if (!val.toLowerCase().includes(f.value.toLowerCase())) { pass = false; break; }
|
|
} else if (f.operator === "equals") {
|
|
if (val !== f.value) { pass = false; break; }
|
|
} else if (f.operator === "in") {
|
|
const allowed = f.value.split("|");
|
|
if (!allowed.includes(val)) { pass = false; break; }
|
|
} else if (f.operator === "between") {
|
|
const [from, to] = f.value.split("|");
|
|
if (from && val < from) { pass = false; break; }
|
|
if (to && val > to) { pass = false; break; }
|
|
}
|
|
}
|
|
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, searchFilters]);
|
|
|
|
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 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)] gap-3 p-3">
|
|
{/* 검색 필터 */}
|
|
<div className="shrink-0">
|
|
<DynamicSearchFilter
|
|
tableName="dsn_project"
|
|
filterId="c16-design-project"
|
|
onFilterChange={setSearchFilters}
|
|
externalFilterConfig={ts.filterConfig}
|
|
dataCount={filteredProjects.length}
|
|
/>
|
|
</div>
|
|
|
|
{/* 좌우 분할 메인 */}
|
|
<div className="flex-1 overflow-hidden border rounded-lg bg-card">
|
|
<ResizablePanelGroup direction="horizontal">
|
|
{/* 왼쪽: 프로젝트 목록 */}
|
|
<ResizablePanel defaultSize={55} minSize={30}>
|
|
<div className="flex flex-col h-full">
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/40 shrink-0">
|
|
<div className="text-[13px] font-bold flex items-center gap-2">
|
|
프로젝트 목록
|
|
<span className="text-xs font-medium text-primary bg-primary/8 border border-primary/15 rounded-md px-1.5 py-0.5">{filteredProjects.length}건</span>
|
|
</div>
|
|
<Button size="sm" onClick={() => openProjectModal()}>
|
|
<Plus className="w-4 h-4 mr-1.5" /> 프로젝트 등록
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
|
<Settings2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto">
|
|
<EDataTable
|
|
columns={[
|
|
{ key: "projectNo", label: "프로젝트번호", width: "w-[160px]", render: (_val: any, row: any) => {
|
|
const depth = row._depth ?? 0;
|
|
const hasChildren = filteredProjects.some((c) => c.parentId === row.id);
|
|
const isExpanded = expandedIds[row.id] !== false;
|
|
return (
|
|
<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(row.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-primary/80" : "text-primary/60")}>{row.projectNo}</span>
|
|
{row.relation && (<span className={cn("px-1.5 py-0.5 rounded text-[10px] font-medium", getRelationColor(row.relation))}>{getRelationLabel(row.relation)}</span>)}
|
|
</div>
|
|
);
|
|
}},
|
|
{ key: "status", label: "상태", width: "w-[80px]", align: "center" as const, render: (val: any) => <span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getStatusColor(val))}>{val}</span> },
|
|
{ key: "name", label: "프로젝트명", width: "w-[200px]", render: (val: any, row: any) => {
|
|
const childCount = getAllDescendants(projects, row.id).length;
|
|
return (<><span className="font-medium text-sm">{val}</span>{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>}</>);
|
|
}},
|
|
{ key: "pm", label: "PM", width: "w-[70px]" },
|
|
{ key: "customer", label: "고객", width: "w-[80px]" },
|
|
{ key: "startDate", label: "시작일", width: "w-[90px]" },
|
|
{ key: "endDate", label: "종료예정", width: "w-[90px]" },
|
|
{ key: "progress", label: "진행률", width: "w-[100px]", align: "center" as const, render: (val: any) => (
|
|
<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(val))} style={{ width: `${val}%` }} />
|
|
</div>
|
|
<span className={cn("text-[11px] font-medium min-w-[28px] text-right", progressTextColor(val))}>{val}%</span>
|
|
</div>
|
|
)},
|
|
{ key: "sourceNo", label: "원접수번호", width: "w-[90px]", render: (val: any) => <span className="text-[13px] text-muted-foreground">{val || "-"}</span> },
|
|
] as EDataTableColumn[]}
|
|
data={ts.groupData(treeRows.map(({ project: p, depth }) => ({ ...p, _depth: depth })))}
|
|
rowKey={(row) => row.id}
|
|
loading={loading}
|
|
emptyMessage="조건에 맞는 프로젝트가 없어요"
|
|
selectedId={selectedId}
|
|
onSelect={(id) => {
|
|
if (id) {
|
|
setSelectedId(id);
|
|
setDetailTab("wbs");
|
|
fetchTaskDetails(id);
|
|
}
|
|
}}
|
|
onRowClick={(row) => {
|
|
setSelectedId(row.id);
|
|
setDetailTab("wbs");
|
|
fetchTaskDetails(row.id);
|
|
}}
|
|
showPagination={false}
|
|
draggableColumns={false}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle withHandle />
|
|
|
|
{/* 오른쪽: 상세 */}
|
|
<ResizablePanel defaultSize={45} minSize={25}>
|
|
<div className="flex flex-col h-full">
|
|
{/* 상세 헤더 */}
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/40 shrink-0">
|
|
{selectedProject ? (
|
|
<div className="text-[13px] font-bold flex items-center gap-2">
|
|
{selectedProject.name}
|
|
<Badge variant="outline" className="text-[11px] font-normal px-1.5 py-0">{selectedProject.projectNo}</Badge>
|
|
</div>
|
|
) : (
|
|
<div className="text-[13px] font-bold flex items-center gap-2">
|
|
<ClipboardList className="w-3.5 h-3.5" /> 프로젝트 상세
|
|
</div>
|
|
)}
|
|
{selectedProject && (
|
|
<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="secondary" className="h-7 text-xs" 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>
|
|
|
|
{!selectedId || !selectedProject ? (
|
|
<div className="flex-1 flex items-center justify-center p-8">
|
|
<div className="flex flex-col items-center gap-3 rounded-lg border-2 border-dashed border-border/60 px-12 py-10 text-center">
|
|
<Rocket className="h-10 w-10 text-muted-foreground/40" />
|
|
<p className="text-sm text-muted-foreground">좌측에서 프로젝트를 선택해주세요</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 overflow-auto p-3 space-y-3">
|
|
{/* 상위 프로젝트 링크 */}
|
|
{parentProject && (
|
|
<div
|
|
className="flex items-center gap-2 px-3 py-2 bg-primary/5 rounded-md border-l-[3px] border-primary text-sm cursor-pointer hover:bg-primary/10 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="space-y-1.5">
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">프로젝트 현황</span>
|
|
<div className="grid grid-cols-5 gap-2">
|
|
{[
|
|
{ label: "전체 업무", value: taskStats.total, color: "text-primary", bg: "bg-primary/5 border-primary/10" },
|
|
{ label: "완료", value: taskStats.completed, color: "text-primary", bg: "bg-primary/5 border-primary/10" },
|
|
{ label: "진행중", value: taskStats.inProgress, color: "text-primary/80", bg: "bg-primary/5 border-primary/10" },
|
|
{ label: "지연", value: taskStats.delayed, color: "text-destructive", bg: "bg-destructive/5 border-destructive/10" },
|
|
{ label: "하위 프로젝트", value: childProjects.length, color: "text-foreground", bg: "bg-muted/40 border-border" },
|
|
].map((item) => (
|
|
<div key={item.label} className={cn("rounded-lg p-2.5 text-center border transition-colors hover:opacity-80", item.bg)}>
|
|
<div className="text-[10px] text-muted-foreground mb-0.5">{item.label}</div>
|
|
<div className={cn("text-xl font-bold", item.color)}>{item.value}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 탭 */}
|
|
<Tabs value={detailTab} onValueChange={setDetailTab} className="flex flex-col flex-1 overflow-hidden gap-0">
|
|
<TabsList className="bg-card w-full justify-start rounded-none border-b border-border p-0 h-auto shrink-0">
|
|
{[
|
|
{ value: "wbs", icon: <ClipboardList className="w-3.5 h-3.5" />, label: "WBS" },
|
|
{ value: "gantt", icon: <BarChart3 className="w-3.5 h-3.5" />, label: "간트차트" },
|
|
{ value: "team", icon: <Users className="w-3.5 h-3.5" />, label: "팀원" },
|
|
{ value: "subprojects", icon: <FolderOpen className="w-3.5 h-3.5" />, label: `하위(${childProjects.length})` },
|
|
].map((tab) => (
|
|
<TabsTrigger
|
|
key={tab.value}
|
|
value={tab.value}
|
|
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-2.5 text-xs font-semibold gap-1.5"
|
|
>
|
|
{tab.icon} {tab.label}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
|
|
{/* WBS 탭 */}
|
|
<TabsContent value="wbs" className="flex flex-col flex-1 overflow-auto mt-0 p-2.5">
|
|
{selectedProject.tasks.length === 0 ? (
|
|
<div className="flex flex-col items-center gap-2 py-10 mx-auto max-w-[240px] border border-dashed border-border rounded-lg">
|
|
<ClipboardList className="w-10 h-10 text-muted-foreground/30" />
|
|
<span className="text-sm text-muted-foreground">등록된 업무가 없어요</span>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead className="w-[180px] text-[11px] uppercase tracking-wide">업무명</TableHead>
|
|
<TableHead className="w-[70px] text-[11px] uppercase tracking-wide">담당자</TableHead>
|
|
<TableHead className="w-[85px] text-[11px] uppercase tracking-wide">시작일</TableHead>
|
|
<TableHead className="w-[85px] text-[11px] uppercase tracking-wide">종료일</TableHead>
|
|
<TableHead className="w-[70px] text-center text-[11px] uppercase tracking-wide">상태</TableHead>
|
|
<TableHead className="w-[90px] text-center text-[11px] uppercase tracking-wide">진행률</TableHead>
|
|
<TableHead className="w-[80px] text-center text-[11px] uppercase tracking-wide">관리</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-[13px]">{task.name}</TableCell>
|
|
<TableCell className="text-[13px]">{task.assignee}</TableCell>
|
|
<TableCell className="text-[13px] text-muted-foreground">{task.start}</TableCell>
|
|
<TableCell className={cn("text-[13px]", 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="flex flex-col flex-1 overflow-auto mt-0 p-2.5">
|
|
{selectedProject.tasks.length === 0 ? (
|
|
<div className="flex flex-col items-center gap-2 py-10 mx-auto max-w-[240px] border border-dashed border-border rounded-lg">
|
|
<BarChart3 className="w-10 h-10 text-muted-foreground/30" />
|
|
<span className="text-sm text-muted-foreground">업무를 등록하면 간트차트가 표시돼요</span>
|
|
</div>
|
|
) : (
|
|
<GanttChart tasks={selectedProject.tasks} startDate={selectedProject.startDate} endDate={selectedProject.endDate} />
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* 팀원 탭 */}
|
|
<TabsContent value="team" className="flex flex-col flex-1 overflow-auto mt-0 p-2.5">
|
|
{selectedProject.tasks.length === 0 ? (
|
|
<div className="flex flex-col items-center gap-2 py-10 mx-auto max-w-[240px] border border-dashed border-border rounded-lg">
|
|
<Users className="w-10 h-10 text-muted-foreground/30" />
|
|
<span className="text-sm text-muted-foreground">업무를 등록하면 팀원 현황이 표시돼요</span>
|
|
</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-primary/80", "bg-primary/60", "bg-destructive/70"];
|
|
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-primary-foreground 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="flex flex-col flex-1 overflow-auto mt-0 p-2.5">
|
|
<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-2xl max-h-[85vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{projects.some((p) => p.id === formProjectId)
|
|
? "프로젝트 수정"
|
|
: formParentId
|
|
? "하위 프로젝트 등록"
|
|
: "프로젝트 등록"}
|
|
</DialogTitle>
|
|
<DialogDescription>프로젝트 기본 정보를 입력해주세요.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3 sm:space-y-4">
|
|
<div>
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">프로젝트 번호</span>
|
|
<Input value={formProjectNo || formProjectId} readOnly className="h-8 text-xs sm:h-10 sm:text-sm bg-muted/50" />
|
|
</div>
|
|
<div>
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">프로젝트명 <span className="text-destructive">*</span></span>
|
|
<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>
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">시작일 <span className="text-destructive">*</span></span>
|
|
<Input type="date" value={formStartDate} onChange={(e) => setFormStartDate(e.target.value)} className="h-8 text-xs sm:h-10 sm:text-sm" />
|
|
</div>
|
|
<div>
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">종료예정일 <span className="text-destructive">*</span></span>
|
|
<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>
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">PM <span className="text-destructive">*</span></span>
|
|
<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>
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">고객</span>
|
|
<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>
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">상위 프로젝트</span>
|
|
<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>
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">관계 유형</span>
|
|
<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>
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">원접수번호</span>
|
|
<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>
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">설명</span>
|
|
<Textarea value={formDesc} onChange={(e) => setFormDesc(e.target.value)} placeholder="프로젝트 개요" rows={3} />
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setIsProjectModalOpen(false)}>취소</Button>
|
|
<Button onClick={handleSaveProject}>
|
|
<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-lg max-h-[85vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{editingTaskIdx >= 0 ? "업무 수정" : "업무 등록"}</DialogTitle>
|
|
<DialogDescription>업무 정보를 입력해주세요.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">업무명 <span className="text-destructive">*</span></span>
|
|
<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>
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">업무 유형</span>
|
|
<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>
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">담당자 <span className="text-destructive">*</span></span>
|
|
<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>
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">시작일 <span className="text-destructive">*</span></span>
|
|
<Input type="date" value={tStart} onChange={(e) => setTStart(e.target.value)} className="h-8 text-xs sm:h-10 sm:text-sm" />
|
|
</div>
|
|
<div>
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">종료일 <span className="text-destructive">*</span></span>
|
|
<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>
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">상태</span>
|
|
<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>
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">진행률 (%)</span>
|
|
<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>
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">비고</span>
|
|
<Textarea value={tRemark} onChange={(e) => setTRemark(e.target.value)} placeholder="비고 사항" rows={2} />
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setIsTaskModalOpen(false)}>취소</Button>
|
|
<Button onClick={handleSaveTask}>
|
|
<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-3xl max-h-[85vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{detailTask?.name || "업무 상세"}</DialogTitle>
|
|
<DialogDescription>업무 이력 및 이슈를 확인해주세요.</DialogDescription>
|
|
</DialogHeader>
|
|
{detailTask && (
|
|
<>
|
|
{/* 태스크 요약 */}
|
|
<div className="grid grid-cols-2 gap-2 bg-muted/20 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-primary/5 rounded-md p-2.5 mb-3 text-xs text-primary border border-primary/20">
|
|
수행기록 등록/수정은 <strong>내업무현황</strong> 메뉴에서 진행해요.
|
|
</div>
|
|
{(!detailTask.workLogs || detailTask.workLogs.length === 0) ? (
|
|
<div className="flex flex-col items-center gap-2 py-8 mx-auto max-w-xs border border-dashed border-border rounded-lg">
|
|
<ClipboardList className="w-8 h-8 text-muted-foreground/30" />
|
|
<span className="text-sm text-muted-foreground">등록된 수행기록이 없어요</span>
|
|
</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-primary/5 text-primary border-primary/20">
|
|
📈 {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="flex flex-col items-center gap-2 py-8 mx-auto max-w-xs border border-dashed border-border rounded-lg">
|
|
<ClipboardList className="w-8 h-8 text-muted-foreground/30" />
|
|
<span className="text-sm text-muted-foreground">등록된 이슈가 없어요</span>
|
|
</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-destructive/70" : "text-muted-foreground";
|
|
const statusBadge = issue.status === "해결" ? "bg-primary/10 text-primary" : issue.status === "진행중" ? "bg-primary/10 text-primary" : "bg-destructive/10 text-destructive";
|
|
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)}>닫기</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<TableSettingsModal
|
|
open={ts.open}
|
|
onOpenChange={ts.setOpen}
|
|
tableName={ts.tableName}
|
|
settingsId={ts.settingsId}
|
|
defaultVisibleKeys={ts.defaultVisibleKeys}
|
|
onSave={ts.applySettings}
|
|
/>
|
|
</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-primary",
|
|
전장설계: "bg-primary/80",
|
|
SW개발: "bg-primary/60",
|
|
"구매/조달": "bg-secondary-foreground/70",
|
|
"조립/시운전": "bg-destructive/70",
|
|
"검토/승인": "bg-muted-foreground",
|
|
};
|
|
|
|
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-muted/30",
|
|
isToday && "bg-primary/5 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-primary";
|
|
|
|
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-muted/30")} style={{ minWidth: cellWidth }} />;
|
|
})}
|
|
<div
|
|
className={cn("absolute h-5 rounded text-[10px] text-primary-foreground 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>
|
|
);
|
|
}
|