ERP-node/frontend/app/(main)/design/project/page.tsx

1513 lines
70 KiB
TypeScript
Raw Normal View History

"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>
);
}