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

1097 lines
51 KiB
TypeScript
Raw Normal View History

"use client";
import React, { useState, useMemo, useCallback, useRef, 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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
Search,
RotateCcw,
RefreshCw,
ClipboardList,
Pencil,
Inbox,
CheckCircle2,
XCircle,
Rocket,
Eye,
ChevronRight,
ArrowRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import {
getDesignRequestList,
updateDesignRequest,
addRequestHistory,
} from "@/lib/api/design";
import { Loader2 } from "lucide-react";
// --- Types ---
type SourceType = "dr" | "ecr";
type TaskStatus = "신규접수" | "검토중" | "승인완료" | "반려" | "프로젝트생성";
type Priority = "긴급" | "높음" | "보통" | "낮음";
type MainTab = "all" | "dr" | "ecr";
interface HistoryItem {
step: string;
date: string;
user: string;
desc: string;
}
interface TaskItem {
dbId: string;
id: string;
sourceType: SourceType;
date: string;
dueDate: string;
priority: Priority;
status: TaskStatus;
approvalStep: number;
targetName: string;
customer: string;
reqDept: string;
requester: string;
designer: string;
orderNo?: string;
designType?: string;
spec?: string;
changeType?: string;
drawingNo?: string;
reason?: string;
impact?: string[];
reviewMemo: string;
projectNo?: string;
history: HistoryItem[];
}
// --- 상태/우선순위 배지 색상 ---
const getStatusVariant = (status: TaskStatus) => {
switch (status) {
case "신규접수":
return "bg-amber-100 text-amber-800 border-amber-200";
case "검토중":
return "bg-blue-100 text-blue-800 border-blue-200";
case "승인완료":
return "bg-emerald-100 text-emerald-800 border-emerald-200";
case "반려":
return "bg-rose-100 text-rose-800 border-rose-200";
case "프로젝트생성":
return "bg-violet-100 text-violet-800 border-violet-200";
default:
return "bg-gray-100 text-gray-800 border-gray-200";
}
};
const getPriorityVariant = (priority: Priority) => {
switch (priority) {
case "긴급":
return "bg-rose-100 text-rose-800 border-rose-200";
case "높음":
return "bg-amber-100 text-amber-800 border-amber-200";
case "보통":
return "bg-blue-100 text-blue-800 border-blue-200";
case "낮음":
return "bg-gray-100 text-gray-600 border-gray-200";
default:
return "bg-gray-100 text-gray-600 border-gray-200";
}
};
const getSourceBadge = (type: SourceType) => {
if (type === "dr")
return "bg-blue-100 text-blue-800 border-blue-200";
return "bg-amber-100 text-amber-800 border-amber-200";
};
const getStepIcon = (step: string) => {
switch (step) {
case "접수": return <Inbox className="inline h-4 w-4 mr-1" />;
case "검토": return <Eye className="inline h-4 w-4 mr-1" />;
case "승인": return <CheckCircle2 className="inline h-4 w-4 mr-1" />;
case "반려": return <XCircle className="inline h-4 w-4 mr-1" />;
case "프로젝트": return <Rocket className="inline h-4 w-4 mr-1" />;
default: return <ClipboardList className="inline h-4 w-4 mr-1" />;
}
};
// --- API 응답 → TaskItem 매핑 ---
function mapApiToTaskItem(r: any): TaskItem {
const historyRaw = Array.isArray(r.history) ? r.history : [];
const history: HistoryItem[] = historyRaw.map((h: any) => ({
step: h.step || "",
date: h.history_date || "",
user: h.user_name || "",
desc: h.description || "",
}));
const impact = Array.isArray(r.impact) ? r.impact : [];
const formatDate = (d: string | null | undefined) =>
d ? (d.includes("T") ? d.split("T")[0] : d) : "";
return {
dbId: r.id,
id: r.request_no || r.id,
sourceType: (r.source_type === "ecr" ? "ecr" : "dr") as SourceType,
date: formatDate(r.request_date),
dueDate: formatDate(r.due_date),
priority: (r.priority || "보통") as Priority,
status: (r.status || "신규접수") as TaskStatus,
approvalStep: typeof r.approval_step === "number" ? r.approval_step : 0,
targetName: r.target_name || "",
customer: r.customer || "",
reqDept: r.req_dept || "",
requester: r.requester || "",
designer: r.designer || "",
orderNo: r.order_no,
designType: r.design_type,
spec: r.spec,
changeType: r.change_type,
drawingNo: r.drawing_no,
reason: r.reason,
impact: impact.length ? impact : undefined,
reviewMemo: r.review_memo || "",
projectNo: r.project_id,
history,
};
}
// --- 승인 프로세스 스텝 ---
const APPROVAL_STEPS = [
{ label: "접수", step: 0 },
{ label: "검토", step: 1 },
{ label: "내부승인", step: 2 },
{ label: "프로젝트", step: 3 },
];
// --- 현황 카드 설정 ---
const STAT_CARDS: { label: string; status: TaskStatus; color: string; textColor: string }[] = [
{ label: "신규접수", status: "신규접수", color: "from-indigo-500 to-purple-600", textColor: "text-white" },
{ label: "검토중", status: "검토중", color: "from-amber-400 to-orange-500", textColor: "text-gray-900" },
{ label: "승인완료", status: "승인완료", color: "from-cyan-400 to-blue-500", textColor: "text-white" },
{ label: "반려", status: "반려", color: "from-rose-400 to-red-500", textColor: "text-white" },
{ label: "프로젝트", status: "프로젝트생성", color: "from-violet-400 to-purple-500", textColor: "text-white" },
];
export default function DesignTaskManagementPage() {
const [allTasks, setAllTasks] = useState<TaskItem[]>([]);
const [loading, setLoading] = useState(true);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [currentTab, setCurrentTab] = useState<MainTab>("all");
const fetchTasks = useCallback(async () => {
setLoading(true);
const res = await getDesignRequestList({});
setLoading(false);
if (res.success && res.data) {
const mapped = (res.data as any[]).map(mapApiToTaskItem);
setAllTasks(mapped);
} else {
toast.error(res.message || "데이터를 불러오지 못했습니다.");
setAllTasks([]);
}
}, []);
useEffect(() => {
fetchTasks();
}, [fetchTasks]);
// 검색 필터
const [searchStatus, setSearchStatus] = useState<string>("all");
const [searchPriority, setSearchPriority] = useState<string>("all");
const [searchReqDept, setSearchReqDept] = useState<string>("all");
const [searchKeyword, setSearchKeyword] = useState("");
// 모달 상태
const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectTaskId, setRejectTaskId] = useState<string | null>(null);
const [rejectReason, setRejectReason] = useState("");
const [projectModalOpen, setProjectModalOpen] = useState(false);
const [projectTaskId, setProjectTaskId] = useState<string | null>(null);
const [projectForm, setProjectForm] = useState({
projNo: "",
projName: "",
projSourceNo: "",
projStartDate: "",
projEndDate: "",
projPM: "",
projCustomer: "",
projDesc: "",
});
// 검토 메모
const [reviewMemoText, setReviewMemoText] = useState("");
// 탭별 카운트
const tabCounts = useMemo(() => {
const drItems = allTasks.filter((t) => t.sourceType === "dr");
const ecrItems = allTasks.filter((t) => t.sourceType === "ecr");
const newDR = drItems.filter((t) => t.status === "신규접수").length;
const newECR = ecrItems.filter((t) => t.status === "신규접수").length;
return {
all: newDR + newECR || allTasks.length,
allIsNew: newDR + newECR > 0,
dr: newDR || drItems.length,
drIsNew: newDR > 0,
ecr: newECR || ecrItems.length,
ecrIsNew: newECR > 0,
};
}, [allTasks]);
// 필터링된 데이터
const filteredData = useMemo(() => {
return allTasks.filter((item) => {
if (currentTab === "dr" && item.sourceType !== "dr") return false;
if (currentTab === "ecr" && item.sourceType !== "ecr") return false;
if (searchStatus !== "all" && item.status !== searchStatus) return false;
if (searchPriority !== "all" && item.priority !== searchPriority) return false;
if (searchReqDept !== "all" && item.reqDept !== searchReqDept) return false;
if (searchKeyword) {
const str = [item.id, item.targetName, item.customer, item.requester, item.designer, item.reqDept]
.join(" ")
.toLowerCase();
if (!str.includes(searchKeyword.toLowerCase())) return false;
}
return true;
});
}, [allTasks, currentTab, searchStatus, searchPriority, searchReqDept, searchKeyword]);
// 현황 통계
const stats = useMemo(() => {
return {
신규접수: allTasks.filter((t) => t.status === "신규접수").length,
검토중: allTasks.filter((t) => t.status === "검토중").length,
승인완료: allTasks.filter((t) => t.status === "승인완료").length,
반려: allTasks.filter((t) => t.status === "반려").length,
프로젝트생성: allTasks.filter((t) => t.status === "프로젝트생성").length,
};
}, [allTasks]);
const selectedTask = useMemo(
() => allTasks.find((t) => t.dbId === selectedTaskId) || null,
[allTasks, selectedTaskId]
);
// 선택된 업무 변경 시 메모 동기화
useEffect(() => {
if (selectedTask) {
setReviewMemoText(selectedTask.reviewMemo || "");
}
}, [selectedTask]);
// --- 액션 핸들러 ---
const handleSelectTask = useCallback((dbId: string) => {
setSelectedTaskId(dbId);
}, []);
const handleStartReview = useCallback(
async (dbId: string) => {
const designer = prompt("설계 담당자를 입력하세요:");
if (designer === null) return;
const historyDate = new Date().toISOString().split("T")[0];
const historyRes = await addRequestHistory(dbId, {
step: "검토",
history_date: historyDate,
user_name: designer || "시스템",
description: "검토 착수 - 담당자 배정",
});
if (!historyRes.success) {
toast.error(historyRes.message || "이력 추가에 실패했습니다.");
return;
}
const updateRes = await updateDesignRequest(dbId, {
status: "검토중",
approval_step: 1,
designer: designer || "",
});
if (!updateRes.success) {
toast.error(updateRes.message || "상태 업데이트에 실패했습니다.");
return;
}
toast.success("검토가 착수되었습니다.");
fetchTasks();
},
[fetchTasks]
);
const handleApprove = useCallback(
async (dbId: string) => {
if (!confirm("내부 승인을 진행하시겠습니까?")) return;
const historyDate = new Date().toISOString().split("T")[0];
const historyRes = await addRequestHistory(dbId, {
step: "승인",
history_date: historyDate,
user_name: "팀장",
description: "내부 검토 완료, 승인 처리",
});
if (!historyRes.success) {
toast.error(historyRes.message || "이력 추가에 실패했습니다.");
return;
}
const updateRes = await updateDesignRequest(dbId, {
status: "승인완료",
approval_step: 3,
});
if (!updateRes.success) {
toast.error(updateRes.message || "상태 업데이트에 실패했습니다.");
return;
}
toast.success("승인 처리되었습니다.");
fetchTasks();
},
[fetchTasks]
);
const handleOpenRejectModal = useCallback((dbId: string) => {
setRejectTaskId(dbId);
setRejectReason("");
setRejectModalOpen(true);
}, []);
const handleConfirmReject = useCallback(async () => {
if (!rejectReason.trim()) {
toast.error("반려 사유를 입력하세요.");
return;
}
if (!rejectTaskId) return;
const historyDate = new Date().toISOString().split("T")[0];
const historyRes = await addRequestHistory(rejectTaskId, {
step: "반려",
history_date: historyDate,
user_name: "팀장",
description: rejectReason,
});
if (!historyRes.success) {
toast.error(historyRes.message || "이력 추가에 실패했습니다.");
return;
}
const updateRes = await updateDesignRequest(rejectTaskId, {
status: "반려",
approval_step: -1,
review_memo: rejectReason,
});
if (!updateRes.success) {
toast.error(updateRes.message || "상태 업데이트에 실패했습니다.");
return;
}
setRejectModalOpen(false);
toast.success("반려 처리되었습니다.");
fetchTasks();
}, [rejectTaskId, rejectReason, fetchTasks]);
const handleOpenProjectModal = useCallback(
(dbId: string) => {
const task = allTasks.find((t) => t.dbId === dbId);
if (!task) return;
const year = new Date().getFullYear();
const existingProjects = allTasks.filter((t) => t.projectNo).length;
const projNo = `PJ-${year}-${String(existingProjects + 1).padStart(4, "0")}`;
setProjectTaskId(dbId);
setProjectForm({
projNo,
projName: task.targetName,
projSourceNo: task.id,
projStartDate: new Date().toISOString().split("T")[0],
projEndDate: task.dueDate,
projPM: task.designer || "",
projCustomer: task.customer || task.reqDept,
projDesc: task.sourceType === "dr" ? task.spec || "" : task.reason || "",
});
setProjectModalOpen(true);
},
[allTasks]
);
const handleCreateProject = useCallback(async () => {
if (!projectForm.projName.trim()) { toast.error("프로젝트명을 입력하세요."); return; }
if (!projectForm.projStartDate) { toast.error("시작일을 입력하세요."); return; }
if (!projectForm.projEndDate) { toast.error("종료예정일을 입력하세요."); return; }
if (!projectForm.projPM) { toast.error("PM을 선택하세요."); return; }
if (!projectTaskId) return;
const historyDate = new Date().toISOString().split("T")[0];
const historyRes = await addRequestHistory(projectTaskId, {
step: "프로젝트",
history_date: historyDate,
user_name: projectForm.projPM,
description: `${projectForm.projNo} 프로젝트 생성 - ${projectForm.projName}`,
});
if (!historyRes.success) {
toast.error(historyRes.message || "이력 추가에 실패했습니다.");
return;
}
const updateRes = await updateDesignRequest(projectTaskId, {
status: "프로젝트생성",
approval_step: 4,
project_id: projectForm.projNo,
});
if (!updateRes.success) {
toast.error(updateRes.message || "상태 업데이트에 실패했습니다.");
return;
}
setProjectModalOpen(false);
toast.success(`프로젝트 ${projectForm.projNo}가 생성되었습니다.`);
fetchTasks();
}, [projectForm, projectTaskId, fetchTasks]);
const handleSaveReviewMemo = useCallback(async () => {
if (!selectedTaskId) return;
const updateRes = await updateDesignRequest(selectedTaskId, {
review_memo: reviewMemoText,
});
if (!updateRes.success) {
toast.error(updateRes.message || "메모 저장에 실패했습니다.");
return;
}
toast.success("검토 메모가 저장되었습니다.");
fetchTasks();
}, [selectedTaskId, reviewMemoText, fetchTasks]);
const handleResetSearch = useCallback(() => {
setSearchStatus("all");
setSearchPriority("all");
setSearchReqDept("all");
setSearchKeyword("");
}, []);
const handleFilterByStatus = useCallback((status: TaskStatus) => {
setSearchStatus(status);
}, []);
// 납기 남은 일수 계산
const getDueDateInfo = (dueDate: string) => {
const due = new Date(dueDate);
const today = new Date();
const diffDays = Math.ceil((due.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
const color = diffDays < 0 ? "text-rose-600" : diffDays <= 7 ? "text-amber-600" : "text-emerald-600";
const text = diffDays < 0 ? `${Math.abs(diffDays)}일 초과` : diffDays === 0 ? "오늘" : `${diffDays}일 남음`;
return { color, text };
};
return (
<div className="flex h-full flex-col overflow-hidden bg-background">
{/* 탭 바 */}
<div className="flex items-center border-b-2 border-border bg-card px-5">
{([
{ key: "all" as MainTab, label: "전체", icon: <ClipboardList className="h-4 w-4" />, count: tabCounts.all, isNew: tabCounts.allIsNew },
{ key: "dr" as MainTab, label: "설계의뢰(DR)", icon: <Pencil className="h-4 w-4" />, count: tabCounts.dr, isNew: tabCounts.drIsNew },
{ key: "ecr" as MainTab, label: "설계변경(ECR)", icon: <RefreshCw className="h-4 w-4" />, count: tabCounts.ecr, isNew: tabCounts.ecrIsNew },
]).map((tab) => (
<button
key={tab.key}
className={cn(
"relative flex items-center gap-2 px-6 py-3.5 text-sm font-semibold transition-colors",
currentTab === tab.key
? "text-primary after:absolute after:bottom-[-2px] after:left-0 after:right-0 after:h-0.5 after:bg-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
onClick={() => {
setCurrentTab(tab.key);
setSelectedTaskId(null);
}}
>
{tab.icon}
{tab.label}
<span
className={cn(
"min-w-[20px] rounded-full px-1.5 py-0.5 text-center text-[11px] font-bold",
tab.isNew ? "bg-rose-500 text-white" : "bg-muted text-muted-foreground"
)}
>
{tab.count}
</span>
</button>
))}
<div className="flex-1" />
<div className="flex items-center gap-1.5 rounded-full bg-emerald-50 px-3 py-1 text-xs text-emerald-600 dark:bg-emerald-950/30 dark:text-emerald-400">
<span className="h-2 w-2 animate-pulse rounded-full bg-emerald-500" />
</div>
</div>
{/* 검색 섹션 */}
<div className="border-b border-border bg-card px-5 py-3">
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-1 flex-wrap items-center gap-3">
<Select value={searchStatus} onValueChange={setSearchStatus}>
<SelectTrigger className="h-9 w-[130px] text-xs">
<SelectValue placeholder="상태 전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
<SelectItem value="신규접수"></SelectItem>
<SelectItem value="검토중"></SelectItem>
<SelectItem value="승인완료"></SelectItem>
<SelectItem value="반려"></SelectItem>
<SelectItem value="프로젝트생성"></SelectItem>
</SelectContent>
</Select>
<Select value={searchPriority} onValueChange={setSearchPriority}>
<SelectTrigger className="h-9 w-[130px] text-xs">
<SelectValue placeholder="우선순위 전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
<SelectItem value="긴급"></SelectItem>
<SelectItem value="높음"></SelectItem>
<SelectItem value="보통"></SelectItem>
<SelectItem value="낮음"></SelectItem>
</SelectContent>
</Select>
<Select value={searchReqDept} onValueChange={setSearchReqDept}>
<SelectTrigger className="h-9 w-[130px] text-xs">
<SelectValue placeholder="의뢰부서 전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
<SelectItem value="영업팀"></SelectItem>
<SelectItem value="생산팀"></SelectItem>
<SelectItem value="품질팀"></SelectItem>
<SelectItem value="구매팀"></SelectItem>
<SelectItem value="기획팀"></SelectItem>
<SelectItem value="설계팀"></SelectItem>
</SelectContent>
</Select>
<div className="relative min-w-[280px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
placeholder="접수번호 / 설비명 / 품목명 / 고객명 검색"
className="h-9 pl-9 text-xs"
/>
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button size="sm" variant="outline" onClick={handleResetSearch}>
<RotateCcw className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
{/* 좌우 분할 패널 */}
<div className="flex-1 overflow-hidden p-3">
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border border-border">
{/* 왼쪽: 접수 목록 */}
<ResizablePanel defaultSize={58} minSize={35}>
<div className="flex h-full flex-col bg-card">
<div className="flex items-center justify-between border-b-2 border-border px-5 py-3">
<h2 className="text-base font-bold text-foreground">
({filteredData.length})
</h2>
<Button size="sm" variant="outline" onClick={fetchTasks} disabled={loading}>
{loading ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-1 h-3.5 w-3.5" />}
</Button>
</div>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10 bg-muted">
<TableRow>
<TableHead className="w-[60px] text-center text-xs"></TableHead>
<TableHead className="w-[130px] text-xs"></TableHead>
<TableHead className="w-[90px] text-center text-xs"></TableHead>
<TableHead className="w-[80px] text-center text-xs"></TableHead>
<TableHead className="min-w-[180px] text-xs">/</TableHead>
<TableHead className="w-[90px] text-xs"></TableHead>
<TableHead className="w-[80px] text-xs"></TableHead>
<TableHead className="w-[100px] text-xs"></TableHead>
<TableHead className="w-[100px] text-xs"></TableHead>
<TableHead className="w-[80px] text-xs"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && allTasks.length === 0 ? (
<TableRow>
<TableCell colSpan={10}>
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Loader2 className="mb-2 h-10 w-10 animate-spin" />
<span className="text-sm"> ...</span>
</div>
</TableCell>
</TableRow>
) : filteredData.length === 0 ? (
<TableRow>
<TableCell colSpan={10}>
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Inbox className="mb-2 h-10 w-10" />
<span className="text-sm"> </span>
</div>
</TableCell>
</TableRow>
) : (
filteredData.map((item) => (
<TableRow
key={item.id}
className={cn(
"cursor-pointer transition-colors",
selectedTaskId === item.id && "bg-primary/5",
item.status === "신규접수" && "bg-amber-50/50 dark:bg-amber-950/10"
)}
onClick={() => handleSelectTask(item.id)}
>
<TableCell className="text-center">
<Badge variant="outline" className={cn("text-[10px] font-bold", getSourceBadge(item.sourceType))}>
{item.sourceType === "dr" ? "DR" : "ECR"}
</Badge>
</TableCell>
<TableCell className={cn("text-xs font-semibold", item.sourceType === "dr" ? "text-blue-600" : "text-amber-600")}>
{item.id}
</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className={cn("text-[10px]", getStatusVariant(item.status))}>
{item.status}
</Badge>
</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className={cn("text-[10px]", getPriorityVariant(item.priority))}>
{item.priority}
</Badge>
</TableCell>
<TableCell className="max-w-[200px] truncate text-xs font-medium">{item.targetName}</TableCell>
<TableCell className="text-xs">{item.reqDept}</TableCell>
<TableCell className="text-xs">{item.requester}</TableCell>
<TableCell className="text-xs">{item.date}</TableCell>
<TableCell className="text-xs">{item.dueDate}</TableCell>
<TableCell className="text-xs">
{item.designer || <span className="text-muted-foreground"></span>}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 검토 및 승인 */}
<ResizablePanel defaultSize={42} minSize={30}>
<div className="flex h-full flex-col bg-card">
<div className="flex items-center justify-between border-b-2 border-border px-5 py-3">
<h2 className="text-base font-bold text-foreground"> </h2>
</div>
<div className="flex-1 overflow-auto p-4">
{/* 현황 카드 */}
<div className="mb-4 grid grid-cols-5 gap-2">
{STAT_CARDS.map((card) => (
<button
key={card.status}
className={cn(
"rounded-lg bg-linear-to-br p-3 text-center transition-all hover:-translate-y-0.5 hover:shadow-md",
card.color, card.textColor
)}
onClick={() => handleFilterByStatus(card.status)}
>
<div className="text-xs font-medium opacity-90">{card.label}</div>
<div className="text-2xl font-bold">{stats[card.status]}</div>
</button>
))}
</div>
{/* 상세 정보 */}
{!selectedTask ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<ChevronRight className="mb-2 h-10 w-10" />
<span className="text-sm"> </span>
</div>
) : (
<div className="space-y-4">
{/* 승인 프로세스 */}
<div className="rounded-lg border border-sky-200 bg-sky-50/50 p-4 dark:border-sky-800 dark:bg-sky-950/20">
<h3 className="mb-3 text-sm font-bold text-sky-700 dark:text-sky-400"> </h3>
<div className="mb-3 flex items-center gap-1">
{APPROVAL_STEPS.map((s, idx) => {
let stepClass = "border-border bg-card text-muted-foreground";
if (selectedTask.status === "반려" && idx >= 2) {
stepClass = idx === 2 ? "border-rose-400 bg-rose-50 text-rose-800 dark:bg-rose-950/30 dark:text-rose-300" : "border-border bg-card text-muted-foreground";
} else if (selectedTask.approvalStep > idx) {
stepClass = "border-emerald-400 bg-emerald-50 text-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300";
} else if (selectedTask.approvalStep === idx) {
stepClass = "border-primary bg-primary/5 text-primary ring-2 ring-primary/10";
}
return (
<React.Fragment key={s.label}>
<div className={cn("flex min-w-[70px] flex-col items-center gap-1 rounded-lg border-2 px-3 py-2 text-center text-xs font-semibold transition-all", stepClass)}>
{s.label}
</div>
{idx < APPROVAL_STEPS.length - 1 && (
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground/50" />
)}
</React.Fragment>
);
})}
</div>
{/* 액션 버튼 */}
<div className="flex flex-wrap gap-2">
{selectedTask.status === "신규접수" && (
<>
<Button size="sm" className="bg-emerald-600 text-white hover:bg-emerald-700" onClick={() => handleStartReview(selectedTask.dbId)}>
<Eye className="mr-1 h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="destructive" onClick={() => handleOpenRejectModal(selectedTask.dbId)}>
<XCircle className="mr-1 h-3.5 w-3.5" />
</Button>
</>
)}
{selectedTask.status === "검토중" && (
<>
<Button size="sm" className="bg-emerald-600 text-white hover:bg-emerald-700" onClick={() => handleApprove(selectedTask.dbId)}>
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="destructive" onClick={() => handleOpenRejectModal(selectedTask.dbId)}>
<XCircle className="mr-1 h-3.5 w-3.5" />
</Button>
</>
)}
{selectedTask.status === "승인완료" && (
<Button size="sm" className="bg-linear-to-r from-indigo-500 to-purple-600 text-white shadow-md hover:shadow-lg" onClick={() => handleOpenProjectModal(selectedTask.dbId)}>
<Rocket className="mr-1 h-3.5 w-3.5" />
</Button>
)}
{selectedTask.status === "프로젝트생성" && selectedTask.projectNo && (
<Button size="sm" variant="default" onClick={() => toast.info(`${selectedTask.projectNo} 프로젝트 → 설계프로젝트관리 메뉴에서 확인`)}>
({selectedTask.projectNo})
</Button>
)}
</div>
</div>
{/* 접수 정보 */}
<div className="rounded-lg bg-muted/50 p-4">
<h3 className="mb-3 border-b-2 border-border pb-2 text-sm font-semibold text-foreground"> </h3>
<div className="text-xs">
<table className="w-full border-collapse">
<tbody>
<tr>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className={cn("border border-muted px-2 py-1.5 font-semibold", selectedTask.sourceType === "dr" ? "text-blue-600" : "text-amber-600")}>
{selectedTask.id}
</td>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">
<Badge variant="outline" className={cn("text-[10px] font-bold", getSourceBadge(selectedTask.sourceType))}>
{selectedTask.sourceType === "dr" ? "설계의뢰(DR)" : "설계변경(ECR)"}
</Badge>
</td>
</tr>
<tr>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">
<Badge variant="outline" className={cn("text-[10px]", getStatusVariant(selectedTask.status))}>
{selectedTask.status}
</Badge>
</td>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">
<Badge variant="outline" className={cn("text-[10px]", getPriorityVariant(selectedTask.priority))}>
{selectedTask.priority}
</Badge>
</td>
</tr>
<tr>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground">
{selectedTask.sourceType === "dr" ? "설비/품목명" : "품목명"}
</td>
<td colSpan={3} className="border border-muted px-2 py-1.5 font-medium">{selectedTask.targetName}</td>
</tr>
<tr>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">{selectedTask.reqDept}</td>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">{selectedTask.requester}</td>
</tr>
{selectedTask.sourceType === "dr" ? (
<>
<tr>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">{selectedTask.designType || "-"}</td>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">{selectedTask.customer || "-"}</td>
</tr>
<tr>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">{selectedTask.orderNo || "-"}</td>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">{selectedTask.designer || <span className="text-muted-foreground"></span>}</td>
</tr>
</>
) : (
<>
<tr>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">{selectedTask.changeType || "-"}</td>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">{selectedTask.drawingNo || "-"}</td>
</tr>
<tr>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">{selectedTask.designer || <span className="text-muted-foreground"></span>}</td>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">
{selectedTask.impact ? (
<div className="flex flex-wrap gap-1">
{selectedTask.impact.map((i) => (
<Badge key={i} variant="outline" className="bg-blue-50 text-[10px] text-blue-700 dark:bg-blue-950/30 dark:text-blue-300">{i}</Badge>
))}
</div>
) : "-"}
</td>
</tr>
</>
)}
<tr>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">{selectedTask.date}</td>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">
{selectedTask.dueDate}{" "}
<span className={cn("text-[10px]", getDueDateInfo(selectedTask.dueDate).color)}>
({getDueDateInfo(selectedTask.dueDate).text})
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* 요구사양 / 변경사유 */}
<div className="rounded-lg bg-muted/50 p-4">
<h3 className="mb-3 border-b-2 border-border pb-2 text-sm font-semibold text-foreground">
{selectedTask.sourceType === "dr" ? "요구사양" : "변경 사유"}
</h3>
<pre className="whitespace-pre-wrap font-sans text-xs leading-relaxed text-foreground">
{selectedTask.sourceType === "dr" ? selectedTask.spec : selectedTask.reason}
</pre>
</div>
{/* 검토 메모 */}
<div className="rounded-lg border border-amber-200 bg-amber-50/50 p-4 dark:border-amber-800 dark:bg-amber-950/20">
<h3 className="mb-2 text-sm font-semibold text-amber-800 dark:text-amber-400"> / </h3>
<Textarea
value={reviewMemoText}
onChange={(e) => setReviewMemoText(e.target.value)}
placeholder="검토 의견을 기록하세요..."
className="min-h-[60px] border-amber-200 text-xs focus-visible:ring-amber-400 dark:border-amber-800"
/>
<div className="mt-2 text-right">
<Button size="sm" onClick={handleSaveReviewMemo}>
</Button>
</div>
</div>
{/* 처리 이력 */}
<div className="rounded-lg bg-muted/50 p-4">
<h3 className="mb-3 border-b-2 border-border pb-2 text-sm font-semibold text-foreground"> </h3>
<div className="relative pl-6">
<div className="absolute bottom-0 left-[7px] top-0 w-0.5 bg-border" />
{selectedTask.history.map((h, idx) => {
const isLast = idx === selectedTask.history.length - 1;
let dotColor = "bg-emerald-500 ring-emerald-500";
if (isLast) {
if (h.step === "반려") dotColor = "bg-rose-500 ring-rose-500";
else if (h.step !== "프로젝트") dotColor = "bg-amber-500 ring-amber-500 animate-pulse";
}
return (
<div key={idx} className={cn("relative pb-5", isLast && "pb-0")}>
<div className={cn("absolute -left-[17px] top-1 h-3 w-3 rounded-full border-2 border-white ring-2", dotColor)} />
<div className="pl-1">
<div className="text-xs font-semibold text-foreground">
{getStepIcon(h.step)}
{h.step}
</div>
<div className="text-[11px] text-muted-foreground">{h.desc}</div>
<div className="text-[10px] text-muted-foreground/70">{h.date} · {h.user}</div>
</div>
</div>
);
})}
</div>
</div>
</div>
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 반려 사유 모달 */}
<Dialog open={rejectModalOpen} onOpenChange={setRejectModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </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>
<Textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="반려 사유를 상세히 기술하세요"
className="mt-1 min-h-[120px] text-xs sm:text-sm"
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setRejectModalOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
<Button variant="destructive" onClick={handleConfirmReject} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 프로젝트 생성 모달 */}
<Dialog open={projectModalOpen} onOpenChange={setProjectModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[700px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm"> .</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div className="rounded-lg bg-muted/50 p-4">
<h4 className="mb-3 border-b border-border pb-2 text-sm font-semibold"> </h4>
<div className="space-y-3">
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Input value={projectForm.projNo} readOnly className="mt-1 h-8 bg-muted text-xs sm:h-10 sm:text-sm" />
</div>
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
value={projectForm.projName}
onChange={(e) => setProjectForm((p) => ({ ...p, projName: e.target.value }))}
placeholder="프로젝트명 입력"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Input value={projectForm.projSourceNo} readOnly className="mt-1 h-8 bg-muted 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={projectForm.projStartDate}
onChange={(e) => setProjectForm((p) => ({ ...p, projStartDate: e.target.value }))}
className="mt-1 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={projectForm.projEndDate}
onChange={(e) => setProjectForm((p) => ({ ...p, projEndDate: e.target.value }))}
className="mt-1 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={projectForm.projPM} onValueChange={(v) => setProjectForm((p) => ({ ...p, projPM: v }))}>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<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={projectForm.projCustomer} readOnly className="mt-1 h-8 bg-muted text-xs sm:h-10 sm:text-sm" />
</div>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Textarea
value={projectForm.projDesc}
onChange={(e) => setProjectForm((p) => ({ ...p, projDesc: e.target.value }))}
placeholder="프로젝트 개요 및 목표를 기술하세요"
className="mt-1 min-h-[80px] text-xs sm:text-sm"
/>
</div>
</div>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setProjectModalOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
<Button onClick={handleCreateProject} className="h-8 flex-1 bg-linear-to-r from-indigo-500 to-purple-600 text-xs text-white sm:h-10 sm:flex-none sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}