From 3e935792d49af24789fd124901f935601cc96646 Mon Sep 17 00:00:00 2001 From: kjs Date: Sun, 29 Mar 2026 13:41:46 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20COMPANY=5F29=20=ED=95=98=EB=93=9C?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(=EC=84=A4=EA=B3=84/=EC=98=81=EC=97=85/=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=A0=95=EB=B3=B4/=EC=83=9D=EC=82=B0=2027=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../design/change-management/page.tsx | 1655 ++++++++++++++ .../COMPANY_29/design/design-request/page.tsx | 839 ++++++++ .../(main)/COMPANY_29/design/my-work/page.tsx | 1897 +++++++++++++++++ .../(main)/COMPANY_29/design/project/page.tsx | 1512 +++++++++++++ .../design/task-management/page.tsx | 1344 ++++++++++++ .../(main)/COMPANY_29/equipment/info/page.tsx | 752 +++++++ .../logistics/material-status/page.tsx | 597 ++++++ .../COMPANY_29/logistics/outbound/page.tsx | 1195 +++++++++++ .../COMPANY_29/logistics/packaging/page.tsx | 926 ++++++++ .../COMPANY_29/logistics/receiving/page.tsx | 1240 +++++++++++ .../master-data/department/page.tsx | 498 +++++ .../COMPANY_29/master-data/item-info/page.tsx | 517 +++++ .../outsourcing/subcontractor-item/page.tsx | 534 +++++ .../outsourcing/subcontractor/page.tsx | 1166 ++++++++++ .../production/plan-management/page.tsx | 1791 ++++++++++++++++ .../process-info/ItemRoutingTab.tsx | 1111 ++++++++++ .../process-info/ProcessMasterTab.tsx | 845 ++++++++ .../process-info/ProcessWorkStandardTab.tsx | 17 + .../production/process-info/page.tsx | 56 + .../WorkStandardEditModal.tsx | 539 +++++ .../production/work-instruction/page.tsx | 782 +++++++ .../(main)/COMPANY_29/sales/claim/page.tsx | 1039 +++++++++ .../(main)/COMPANY_29/sales/customer/page.tsx | 1301 +++++++++++ .../(main)/COMPANY_29/sales/order/page.tsx | 947 ++++++++ .../COMPANY_29/sales/sales-item/page.tsx | 917 ++++++++ .../COMPANY_29/sales/shipping-order/page.tsx | 845 ++++++++ .../COMPANY_29/sales/shipping-plan/page.tsx | 557 +++++ 27 files changed, 25419 insertions(+) create mode 100644 frontend/app/(main)/COMPANY_29/design/change-management/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/design/design-request/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/design/my-work/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/design/project/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/design/task-management/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/equipment/info/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/logistics/material-status/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/logistics/outbound/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/logistics/packaging/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/logistics/receiving/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/master-data/department/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/master-data/item-info/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/outsourcing/subcontractor-item/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/outsourcing/subcontractor/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/production/plan-management/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/production/process-info/ItemRoutingTab.tsx create mode 100644 frontend/app/(main)/COMPANY_29/production/process-info/ProcessMasterTab.tsx create mode 100644 frontend/app/(main)/COMPANY_29/production/process-info/ProcessWorkStandardTab.tsx create mode 100644 frontend/app/(main)/COMPANY_29/production/process-info/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/production/work-instruction/WorkStandardEditModal.tsx create mode 100644 frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/sales/claim/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/sales/customer/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/sales/order/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/sales/sales-item/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/sales/shipping-order/page.tsx create mode 100644 frontend/app/(main)/COMPANY_29/sales/shipping-plan/page.tsx diff --git a/frontend/app/(main)/COMPANY_29/design/change-management/page.tsx b/frontend/app/(main)/COMPANY_29/design/change-management/page.tsx new file mode 100644 index 00000000..8879ba8a --- /dev/null +++ b/frontend/app/(main)/COMPANY_29/design/change-management/page.tsx @@ -0,0 +1,1655 @@ +"use client"; + +import React, { useState, useMemo, useEffect, useCallback } 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 { Checkbox } from "@/components/ui/checkbox"; +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, + Plus, + Save, + ClipboardList, + Inbox, + Pencil, + FileText, + XCircle, + ArrowRight, + Paperclip, + Upload, + Loader2, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { + getDesignRequestList, + createDesignRequest, + updateDesignRequest, + addRequestHistory, + getEcnList, + createEcn, + updateEcn, +} from "@/lib/api/design"; + +// --- Types --- +type ChangeType = "설계오류" | "원가절감" | "고객요청" | "공정개선" | "법규대응"; +type EcrStatus = "요청접수" | "영향도분석" | "ECN발행" | "기각"; +type EcnStatus = "ECN발행" | "도면변경" | "통보완료" | "적용완료"; +type TabType = "ecr" | "ecn"; + +interface EcrHistory { + status: string; + date: string; + user: string; + desc: string; +} + +interface EcrItem { + id: string; + _id?: string; + date: string; + changeType: ChangeType; + urgency: "보통" | "긴급"; + status: EcrStatus; + target: string; + drawingNo: string; + reqDept: string; + requester: string; + reason: string; + content: string; + impact: string[]; + applyTiming: string; + ecnNo: string; + history: EcrHistory[]; +} + +interface EcnItem { + id: string; + _id?: string; + ecrNo: string; + ecrId?: string; + date: string; + applyDate: string; + status: EcnStatus; + target: string; + drawingBefore: string; + drawingAfter: string; + designer: string; + before: string; + after: string; + reason: string; + notifyDepts: string[]; + remark: string; + history: EcrHistory[]; +} + +// --- Style Helpers --- +const getChangeTypeStyle = (type: ChangeType) => { + switch (type) { + case "설계오류": + return "bg-rose-100 text-rose-800 border-rose-200"; + case "원가절감": + return "bg-emerald-100 text-emerald-800 border-emerald-200"; + case "고객요청": + return "bg-blue-100 text-blue-800 border-blue-200"; + case "공정개선": + return "bg-amber-100 text-amber-800 border-amber-200"; + case "법규대응": + return "bg-purple-100 text-purple-800 border-purple-200"; + default: + return "bg-gray-100 text-gray-800 border-gray-200"; + } +}; + +const getEcrStatusStyle = (status: EcrStatus) => { + switch (status) { + case "요청접수": + return "bg-blue-100 text-blue-800 border-blue-200"; + case "영향도분석": + return "bg-amber-100 text-amber-800 border-amber-200"; + case "ECN발행": + return "bg-emerald-100 text-emerald-800 border-emerald-200"; + case "기각": + return "bg-slate-100 text-slate-800 border-slate-200"; + default: + return "bg-gray-100 text-gray-800 border-gray-200"; + } +}; + +const getEcnStatusStyle = (status: EcnStatus) => { + switch (status) { + case "ECN발행": + return "bg-blue-100 text-blue-800 border-blue-200"; + case "도면변경": + return "bg-purple-100 text-purple-800 border-purple-200"; + case "통보완료": + return "bg-teal-100 text-teal-800 border-teal-200"; + case "적용완료": + return "bg-emerald-100 text-emerald-800 border-emerald-200"; + default: + return "bg-gray-100 text-gray-800 border-gray-200"; + } +}; + +const getImpactBadgeStyle = (impact: string) => { + switch (impact) { + case "BOM": + return "bg-blue-100 text-blue-800 border-blue-200"; + case "공정": + return "bg-amber-100 text-amber-800 border-amber-200"; + case "금형": + return "bg-rose-100 text-rose-800 border-rose-200"; + case "검사기준": + return "bg-purple-100 text-purple-800 border-purple-200"; + case "구매": + case "원가": + return "bg-emerald-100 text-emerald-800 border-emerald-200"; + default: + return "bg-gray-100 text-gray-800 border-gray-200"; + } +}; + +// --- Constants --- +const CHANGE_TYPES: ChangeType[] = ["설계오류", "원가절감", "고객요청", "공정개선", "법규대응"]; +const ECR_STATUSES: EcrStatus[] = ["요청접수", "영향도분석", "ECN발행", "기각"]; +const ECN_STATUSES: EcnStatus[] = ["ECN발행", "도면변경", "통보완료", "적용완료"]; +const DEPARTMENTS = ["품질팀", "생산팀", "영업팀", "구매팀", "설계팀"]; +const DESIGNERS = ["이설계", "박도면", "최기구", "김전장"]; +const IMPACT_OPTIONS = [ + { key: "BOM", label: "BOM 변경" }, + { key: "공정", label: "공정 변경" }, + { key: "금형", label: "금형 변경" }, + { key: "검사기준", label: "검사기준 변경" }, + { key: "구매", label: "구매 변경" }, + { key: "원가", label: "원가 영향" }, +]; +const NOTIFY_DEPTS = [ + { key: "생산팀", label: "생산팀" }, + { key: "품질팀", label: "품질팀" }, + { key: "구매팀", label: "구매팀" }, + { key: "영업팀", label: "영업팀" }, + { key: "물류팀", label: "물류팀" }, + { key: "금형팀", label: "금형팀" }, +]; + +// --- API Response Mapping --- +function mapEcrFromApi(raw: any): EcrItem { + const history = (raw.history || []).map((h: any) => ({ + status: h.step || h.status || "", + date: h.history_date || "", + user: h.user_name || "", + desc: h.description || "", + })); + return { + id: raw.request_no || raw.id || "", + _id: raw.id, + date: raw.request_date || "", + changeType: (raw.change_type as ChangeType) || "설계오류", + urgency: (raw.urgency as "보통" | "긴급") || "보통", + status: (raw.status as EcrStatus) || "요청접수", + target: raw.target_name || "", + drawingNo: raw.drawing_no || "", + reqDept: raw.req_dept || "", + requester: raw.requester || "", + reason: raw.reason || "", + content: raw.content || "", + impact: Array.isArray(raw.impact) ? raw.impact : [], + applyTiming: raw.apply_timing || "", + ecnNo: raw.ecn_no || "", + history, + }; +} + +function mapEcnFromApi(raw: any, ecrData: EcrItem[]): EcnItem { + const history = (raw.history || []).map((h: any) => ({ + status: h.status || "", + date: h.history_date || "", + user: h.user_name || "", + desc: h.description || "", + })); + const ecrNo = raw.ecr_id + ? ecrData.find((e) => e._id === raw.ecr_id)?.id ?? raw.ecr_id + : ""; + return { + id: raw.ecn_no || raw.id || "", + _id: raw.id, + ecrNo, + ecrId: raw.ecr_id, + date: raw.ecn_date || "", + applyDate: raw.apply_date || "", + status: (raw.status as EcnStatus) || "ECN발행", + target: raw.target || "", + drawingBefore: raw.drawing_before || "", + drawingAfter: raw.drawing_after || "", + designer: raw.designer || "", + before: raw.before_content || "", + after: raw.after_content || "", + reason: raw.reason || "", + notifyDepts: Array.isArray(raw.notify_depts) ? raw.notify_depts : [], + remark: raw.remark || "", + history, + }; +} + +// --- Timeline Component --- +function Timeline({ history }: { history: EcrHistory[] }) { + return ( +
+ {history.map((h, idx) => { + const isLast = idx === history.length - 1; + const isRejected = h.status === "기각"; + const isCompleted = h.status === "적용완료"; + return ( +
+
+
+ {!isLast && ( +
+ )} +
+
+
+ + {h.status} + +
+

{h.desc}

+

+ {h.date} · {h.user} +

+
+
+ ); + })} +
+ ); +} + +// --- Main Component --- +export default function DesignChangeManagementPage() { + const [currentTab, setCurrentTab] = useState("ecr"); + const [ecrData, setEcrData] = useState([]); + const [ecnData, setEcnData] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedId, setSelectedId] = useState(null); + + // 검색 상태 + const [searchDateFrom, setSearchDateFrom] = useState(""); + const [searchDateTo, setSearchDateTo] = useState(""); + const [searchStatus, setSearchStatus] = useState("all"); + const [searchChangeType, setSearchChangeType] = useState("all"); + const [searchKeyword, setSearchKeyword] = useState(""); + + // ECR 모달 + const [isEcrModalOpen, setIsEcrModalOpen] = useState(false); + const [isEcrEditMode, setIsEcrEditMode] = useState(false); + const [ecrForm, setEcrForm] = useState>({}); + const [ecrImpactChecks, setEcrImpactChecks] = useState>({}); + + // ECN 모달 + const [isEcnModalOpen, setIsEcnModalOpen] = useState(false); + const [ecnForm, setEcnForm] = useState>({}); + const [ecnNotifyChecks, setEcnNotifyChecks] = useState>({}); + + // 기각 모달 + const [isRejectModalOpen, setIsRejectModalOpen] = useState(false); + const [rejectReason, setRejectReason] = useState(""); + const [rejectTargetId, setRejectTargetId] = useState(""); + + useEffect(() => { + const today = new Date(); + const threeMonthsAgo = new Date(today); + threeMonthsAgo.setMonth(today.getMonth() - 3); + setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]); + setSearchDateTo(today.toISOString().split("T")[0]); + }, []); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const [ecrRes, ecnRes] = await Promise.all([ + getDesignRequestList({ source_type: "ecr" }), + getEcnList(), + ]); + if (ecrRes.success && ecrRes.data) { + setEcrData((ecrRes.data as any[]).map(mapEcrFromApi)); + } + if (ecnRes.success && ecnRes.data) { + const ecrList = ecrRes.success && ecrRes.data ? (ecrRes.data as any[]).map(mapEcrFromApi) : []; + setEcnData((ecnRes.data as any[]).map((r) => mapEcnFromApi(r, ecrList))); + } + } catch { + toast.error("데이터를 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // --- Filtered Data --- + const filteredEcr = useMemo(() => { + return ecrData + .filter((item) => { + if (searchDateFrom && item.date < searchDateFrom) return false; + if (searchDateTo && item.date > searchDateTo) return false; + if (searchStatus !== "all" && item.status !== searchStatus) return false; + if (searchChangeType !== "all" && item.changeType !== searchChangeType) return false; + if (searchKeyword) { + const kw = searchKeyword.toLowerCase(); + const str = [item.id, item.target, item.requester, item.drawingNo].join(" ").toLowerCase(); + if (!str.includes(kw)) return false; + } + return true; + }) + .sort((a, b) => b.date.localeCompare(a.date)); + }, [ecrData, searchDateFrom, searchDateTo, searchStatus, searchChangeType, searchKeyword]); + + const filteredEcn = useMemo(() => { + return ecnData + .filter((item) => { + if (searchDateFrom && item.date < searchDateFrom) return false; + if (searchDateTo && item.date > searchDateTo) return false; + if (searchStatus !== "all" && item.status !== searchStatus) return false; + if (searchKeyword) { + const kw = searchKeyword.toLowerCase(); + const str = [item.id, item.target, item.designer, item.ecrNo].join(" ").toLowerCase(); + if (!str.includes(kw)) return false; + } + return true; + }) + .sort((a, b) => b.date.localeCompare(a.date)); + }, [ecnData, searchDateFrom, searchDateTo, searchStatus, searchKeyword]); + + // --- Status Counts --- + const ecrStatusCounts = useMemo(() => { + const counts: Record = {}; + ECR_STATUSES.forEach((s) => (counts[s] = ecrData.filter((r) => r.status === s).length)); + return counts; + }, [ecrData]); + + const ecnStatusCounts = useMemo(() => { + const counts: Record = {}; + ECN_STATUSES.forEach((s) => (counts[s] = ecnData.filter((r) => r.status === s).length)); + return counts; + }, [ecnData]); + + // --- Selected Items --- + const selectedEcr = useMemo( + () => (currentTab === "ecr" ? ecrData.find((r) => r.id === selectedId) : null), + [ecrData, selectedId, currentTab] + ); + const selectedEcn = useMemo( + () => (currentTab === "ecn" ? ecnData.find((r) => r.id === selectedId) : null), + [ecnData, selectedId, currentTab] + ); + + // --- Tab Switch --- + const handleTabSwitch = (tab: TabType) => { + setCurrentTab(tab); + setSelectedId(null); + setSearchStatus("all"); + }; + + // --- Search --- + const handleResetSearch = () => { + const today = new Date(); + const threeMonthsAgo = new Date(today); + threeMonthsAgo.setMonth(today.getMonth() - 3); + setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]); + setSearchDateTo(today.toISOString().split("T")[0]); + setSearchStatus("all"); + setSearchChangeType("all"); + setSearchKeyword(""); + }; + + const handleFilterByStatus = (status: string) => { + setSearchStatus(status); + }; + + // --- ECR/ECN Navigation --- + const navigateToLink = (targetId: string) => { + if (targetId.startsWith("ECN")) { + setCurrentTab("ecn"); + setSelectedId(targetId); + setSearchStatus("all"); + } else if (targetId.startsWith("ECR")) { + setCurrentTab("ecr"); + setSelectedId(targetId); + setSearchStatus("all"); + } + }; + + // --- ECR Number Generator --- + const generateEcrNo = useCallback(() => { + const year = new Date().getFullYear(); + const prefix = `ECR-${year}-`; + const existing = ecrData.filter((r) => r.id.startsWith(prefix)); + const maxNum = existing.reduce((max, r) => { + const num = parseInt(r.id.split("-")[2]); + return num > max ? num : max; + }, 0); + return `${prefix}${String(maxNum + 1).padStart(4, "0")}`; + }, [ecrData]); + + const generateEcnNo = useCallback(() => { + const year = new Date().getFullYear(); + const prefix = `ECN-${year}-`; + const existing = ecnData.filter((r) => r.id.startsWith(prefix)); + const maxNum = existing.reduce((max, r) => { + const num = parseInt(r.id.split("-")[2]); + return num > max ? num : max; + }, 0); + return `${prefix}${String(maxNum + 1).padStart(4, "0")}`; + }, [ecnData]); + + // --- ECR Modal --- + const openEcrRegisterModal = () => { + setIsEcrEditMode(false); + setEcrForm({ + id: generateEcrNo(), + date: new Date().toISOString().split("T")[0], + changeType: undefined, + urgency: "보통", + target: "", + drawingNo: "", + reqDept: "", + requester: "", + reason: "", + content: "", + applyTiming: "즉시", + }); + setEcrImpactChecks({}); + setIsEcrModalOpen(true); + }; + + const openEcrEditModal = (id: string) => { + const item = ecrData.find((r) => r.id === id); + if (!item) return; + setIsEcrEditMode(true); + setEcrForm({ ...item }); + const checks: Record = {}; + IMPACT_OPTIONS.forEach((opt) => { + checks[opt.key] = item.impact.includes(opt.key); + }); + setEcrImpactChecks(checks); + setIsEcrModalOpen(true); + }; + + const handleSaveEcr = async () => { + if (!ecrForm.changeType) { + toast.error("변경 유형을 선택하세요."); + return; + } + if (!ecrForm.target?.trim()) { + toast.error("대상 품목/설비를 입력하세요."); + return; + } + if (!ecrForm.reason?.trim()) { + toast.error("변경 사유를 입력하세요."); + return; + } + if (!ecrForm.content?.trim()) { + toast.error("변경 요구 내용을 입력하세요."); + return; + } + + const impact = IMPACT_OPTIONS.filter((opt) => ecrImpactChecks[opt.key]).map((opt) => opt.key); + const reqDate = ecrForm.date || new Date().toISOString().split("T")[0]; + const historyEntry = { + step: "요청접수", + history_date: reqDate, + user_name: ecrForm.requester || "시스템", + description: `${ecrForm.reqDept || ""}에서 ECR 등록`, + }; + + if (isEcrEditMode && ecrForm._id) { + const res = await updateDesignRequest(ecrForm._id, { + request_no: ecrForm.id, + request_date: reqDate, + change_type: ecrForm.changeType, + urgency: ecrForm.urgency || "보통", + target_name: ecrForm.target, + drawing_no: ecrForm.drawingNo || "", + req_dept: ecrForm.reqDept || "", + requester: ecrForm.requester || "", + reason: ecrForm.reason, + content: ecrForm.content, + impact, + apply_timing: ecrForm.applyTiming || "즉시", + }); + if (res.success) { + toast.success("ECR이 수정되었습니다."); + setIsEcrModalOpen(false); + fetchData(); + } else { + toast.error(res.message || "ECR 수정에 실패했습니다."); + } + } else { + const res = await createDesignRequest({ + request_no: ecrForm.id || generateEcrNo(), + source_type: "ecr", + request_date: reqDate, + change_type: ecrForm.changeType, + urgency: ecrForm.urgency || "보통", + status: "요청접수", + target_name: ecrForm.target, + drawing_no: ecrForm.drawingNo || "", + req_dept: ecrForm.reqDept || "", + requester: ecrForm.requester || "", + reason: ecrForm.reason, + content: ecrForm.content, + impact, + apply_timing: ecrForm.applyTiming || "즉시", + history: [historyEntry], + }); + if (res.success) { + toast.success("ECR이 등록되었습니다."); + setIsEcrModalOpen(false); + fetchData(); + } else { + toast.error(res.message || "ECR 등록에 실패했습니다."); + } + } + }; + + // --- ECN Modal --- + const openEcnIssueModal = (ecrId: string) => { + const ecr = ecrData.find((r) => r.id === ecrId); + if (!ecr) return; + + setEcnForm({ + id: generateEcnNo(), + ecrNo: ecrId, + ecrId: ecr._id, + date: new Date().toISOString().split("T")[0], + target: ecr.target, + reason: ecr.reason, + drawingBefore: ecr.drawingNo, + drawingAfter: "", + designer: "", + before: "", + after: "", + applyDate: "", + remark: "", + }); + setEcnNotifyChecks({}); + setIsEcnModalOpen(true); + }; + + const handleSaveEcn = async () => { + if (!ecnForm.after?.trim()) { + toast.error("변경 후(TO-BE) 내용을 입력하세요."); + return; + } + if (!ecnForm.applyDate) { + toast.error("적용일자를 입력하세요."); + return; + } + if (!ecnForm.ecrId) { + toast.error("관련 ECR 정보가 없습니다."); + return; + } + + const notifyDepts = NOTIFY_DEPTS.filter((d) => ecnNotifyChecks[d.key]).map((d) => d.key); + const ecnDate = ecnForm.date || new Date().toISOString().split("T")[0]; + const historyEntry = { + status: "ECN발행", + history_date: ecnDate, + user_name: ecnForm.designer || "시스템", + description: "ECN 발행", + }; + + const ecnNo = ecnForm.id || generateEcnNo(); + const res = await createEcn({ + ecn_no: ecnNo, + ecr_id: ecnForm.ecrId, + ecn_date: ecnDate, + apply_date: ecnForm.applyDate, + status: "ECN발행", + target: ecnForm.target || "", + drawing_before: ecnForm.drawingBefore || "", + drawing_after: ecnForm.drawingAfter || "(미정)", + designer: ecnForm.designer || "", + before_content: ecnForm.before || "", + after_content: ecnForm.after || "", + reason: ecnForm.reason || "", + remark: ecnForm.remark || "", + notify_depts: notifyDepts, + history: [historyEntry], + }); + + if (res.success) { + await updateDesignRequest(ecnForm.ecrId!, { + status: "ECN발행", + ecn_no: ecnNo, + }); + await addRequestHistory(ecnForm.ecrId!, { + step: "ECN발행", + history_date: ecnDate, + user_name: ecnForm.designer || "시스템", + description: `${ecnNo} 발행`, + }); + toast.success("ECN이 발행되었습니다."); + setIsEcnModalOpen(false); + fetchData(); + } else { + toast.error(res.message || "ECN 발행에 실패했습니다."); + } + }; + + // --- ECR Reject --- + const openRejectModal = (id: string) => { + setRejectTargetId(id); + setRejectReason(""); + setIsRejectModalOpen(true); + }; + + const handleRejectSubmit = async () => { + if (!rejectReason.trim()) { + toast.error("기각 사유를 입력하세요."); + return; + } + + const ecr = ecrData.find((r) => r.id === rejectTargetId); + if (!ecr?._id) { + toast.error("ECR 정보를 찾을 수 없습니다."); + return; + } + + const updateRes = await updateDesignRequest(ecr._id, { status: "기각", review_memo: rejectReason }); + if (!updateRes.success) { + toast.error(updateRes.message || "ECR 기각에 실패했습니다."); + return; + } + await addRequestHistory(ecr._id, { + step: "기각", + history_date: new Date().toISOString().split("T")[0], + user_name: "설계팀", + description: rejectReason, + }); + toast.success("ECR이 기각되었습니다."); + setIsRejectModalOpen(false); + fetchData(); + }; + + // --- Stat Cards --- + const ecrStatCards = [ + { label: "요청접수", value: ecrStatusCounts["요청접수"] || 0, gradient: "from-indigo-500 to-blue-600", textColor: "text-white" }, + { label: "영향도분석", value: ecrStatusCounts["영향도분석"] || 0, gradient: "from-amber-400 to-orange-500", textColor: "text-white" }, + { label: "ECN발행", value: ecrStatusCounts["ECN발행"] || 0, gradient: "from-emerald-400 to-green-600", textColor: "text-white" }, + ]; + + const ecnStatCards = [ + { label: "도면변경", value: ecnStatusCounts["도면변경"] || 0, gradient: "from-purple-400 to-violet-600", textColor: "text-white" }, + { label: "통보완료", value: ecnStatusCounts["통보완료"] || 0, gradient: "from-teal-400 to-cyan-600", textColor: "text-white" }, + { label: "적용완료", value: ecnStatusCounts["적용완료"] || 0, gradient: "from-emerald-400 to-green-600", textColor: "text-white" }, + ]; + + const currentStatCards = currentTab === "ecr" ? ecrStatCards : ecnStatCards; + const currentList = currentTab === "ecr" ? filteredEcr : filteredEcn; + const currentStatuses = currentTab === "ecr" ? ECR_STATUSES : ECN_STATUSES; + + return ( +
+ {loading && ( +
+ +
+ )} + {/* 검색 섹션 */} + + +
+ +
+ setSearchDateFrom(e.target.value)} + /> + ~ + setSearchDateTo(e.target.value)} + /> +
+
+ +
+ + +
+ +
+ + +
+ + {currentTab === "ecr" && ( +
+ + +
+ )} + +
+ + setSearchKeyword(e.target.value)} + /> +
+ +
+ +
+ +
+ + + + {/* 메인 분할 레이아웃 */} +
+ + {/* 왼쪽: 목록 */} + +
+
+
+ + {currentTab === "ecr" ? "설계변경요청(ECR) 목록" : "설계변경통지(ECN) 목록"} + + {currentList.length}건 + +
+ {currentTab === "ecr" && ( + + )} +
+ +
+ {currentTab === "ecr" ? ( + + + + No + ECR번호 + 변경유형 + 상태 + 긴급 + 대상 품목/설비 + 도면번호 + 요청부서 + 요청자 + 요청일자 + 관련 ECN + + + + {filteredEcr.length === 0 ? ( + + +
+ + 조건에 맞는 ECR이 없습니다 +
+
+
+ ) : ( + filteredEcr.map((item, idx) => ( + setSelectedId(item.id)} + > + {idx + 1} + {item.id} + + + {item.changeType} + + + + + {item.status} + + + + {item.urgency === "긴급" ? ( + + 긴급 + + ) : ( + "-" + )} + + {item.target} + {item.drawingNo} + {item.reqDept} + {item.requester} + {item.date} + + {item.ecnNo ? ( + + ) : ( + "-" + )} + + + )) + )} +
+
+ ) : ( + + + + No + ECN번호 + 상태 + 대상 품목/설비 + 도면 (변경 후) + 설계담당 + 발행일자 + 적용일자 + 통보 부서 + 관련 ECR + + + + {filteredEcn.length === 0 ? ( + + +
+ + 조건에 맞는 ECN이 없습니다 +
+
+
+ ) : ( + filteredEcn.map((item, idx) => ( + setSelectedId(item.id)} + > + {idx + 1} + {item.id} + + + {item.status} + + + {item.target} + {item.drawingAfter} + {item.designer} + {item.date} + {item.applyDate} + {item.notifyDepts.join(", ")} + + + + + )) + )} +
+
+ )} +
+
+
+ + + + {/* 오른쪽: 상세 */} + +
+
+ + + 상세 정보 + + {selectedEcr && ( +
+ + {selectedEcr.status === "영향도분석" && ( + <> + + + + )} +
+ )} +
+ +
+ {/* 현황 카드 */} +
+ {currentStatCards.map((card) => ( + + ))} +
+ + {/* ECR 상세 */} + {selectedEcr ? ( +
+
+

+ 기본 정보 +

+
+
+ ECR번호 + {selectedEcr.id} +
+
+ 상태 + + {selectedEcr.status} + +
+
+ 변경 유형 + + {selectedEcr.changeType} + +
+
+ 긴급도 + + {selectedEcr.urgency === "긴급" ? ( + 긴급 + ) : ( + "보통" + )} + +
+
+ 대상 품목/설비 + {selectedEcr.target} +
+
+ 도면번호 + {selectedEcr.drawingNo} +
+
+ 요청부서 / 요청자 + {selectedEcr.reqDept} / {selectedEcr.requester} +
+
+ 요청일자 + {selectedEcr.date} +
+
+ 희망 적용시점 + {selectedEcr.applyTiming} +
+
+ 관련 ECN + {selectedEcr.ecnNo ? ( + + ) : ( + 미발행 + )} +
+
+
+ +
+

변경 사유

+
+ {selectedEcr.reason} +
+
+ +
+

변경 요구 내용

+
+ {selectedEcr.content} +
+
+ +
+

영향 범위

+
+ {selectedEcr.impact.map((imp) => ( + + {imp} + + ))} +
+
+ +
+

처리 이력

+ +
+
+ ) : selectedEcn ? ( +
+
+

+ ECN 기본 정보 +

+
+
+ ECN번호 + {selectedEcn.id} +
+
+ 상태 + + {selectedEcn.status} + +
+
+ 대상 품목/설비 + {selectedEcn.target} +
+
+ 설계담당 + {selectedEcn.designer} +
+
+ 발행일자 + {selectedEcn.date} +
+
+ 적용일자 + {selectedEcn.applyDate} +
+
+ 관련 ECR + +
+
+ 통보 부서 + {selectedEcn.notifyDepts.join(", ")} +
+
+
+ +
+

변경 전/후 비교

+
+
+
+ 변경 전 ({selectedEcn.drawingBefore}) +
+
{selectedEcn.before}
+
+
+
+ 변경 후 ({selectedEcn.drawingAfter}) +
+
{selectedEcn.after}
+
+
+
+ +
+

변경 사유

+
+ {selectedEcn.reason} +
+ {selectedEcn.remark && ( +

비고: {selectedEcn.remark}

+ )} +
+ +
+

처리 이력

+ +
+
+ ) : ( +
+
+ +
+

좌측 목록에서 항목을 선택하세요

+
+ )} +
+
+
+
+
+ + {/* ECR 등록/수정 모달 */} + + + + + {isEcrEditMode ? "설계변경요청(ECR) 수정" : "설계변경요청(ECR) 등록"} + + + {isEcrEditMode ? "ECR 정보를 수정합니다." : "새로운 설계변경요청을 등록합니다."} + + + +
+
+ {/* 좌측: 요청 정보 */} +
+

변경 요청 정보

+ +
+ + +
+ +
+
+ + setEcrForm((p) => ({ ...p, date: e.target.value }))} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + setEcrForm((p) => ({ ...p, target: e.target.value }))} + placeholder="품목코드 / 설비명" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ +
+ + setEcrForm((p) => ({ ...p, drawingNo: e.target.value }))} + placeholder="DWG-XXX-XXX" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ +
+
+ + +
+
+ + setEcrForm((p) => ({ ...p, requester: e.target.value }))} + placeholder="요청자명" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+
+ + {/* 우측: 변경 내용 */} +
+
+

변경 내용

+ +
+ +