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

1656 lines
70 KiB
TypeScript

"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 (
<div className="space-y-0">
{history.map((h, idx) => {
const isLast = idx === history.length - 1;
const isRejected = h.status === "기각";
const isCompleted = h.status === "적용완료";
return (
<div key={idx} className="flex gap-3 relative">
<div className="flex flex-col items-center">
<div
className={cn(
"w-3 h-3 rounded-full border-2 mt-1.5 shrink-0",
isLast && isRejected
? "bg-rose-500 border-rose-300"
: isLast && isCompleted
? "bg-emerald-500 border-emerald-300"
: isLast
? "bg-primary border-primary/50 ring-4 ring-primary/10"
: "bg-emerald-500 border-emerald-300"
)}
/>
{!isLast && (
<div className="w-px flex-1 bg-border min-h-[24px]" />
)}
</div>
<div className="pb-4 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span
className={cn(
"px-2 py-0.5 rounded-full text-[10px] font-medium border",
h.status === "기각"
? "bg-slate-100 text-slate-800 border-slate-200"
: h.status === "적용완료"
? "bg-emerald-100 text-emerald-800 border-emerald-200"
: h.status === "ECN발행"
? "bg-emerald-100 text-emerald-800 border-emerald-200"
: h.status === "영향도분석"
? "bg-amber-100 text-amber-800 border-amber-200"
: h.status === "도면변경"
? "bg-purple-100 text-purple-800 border-purple-200"
: h.status === "통보완료"
? "bg-teal-100 text-teal-800 border-teal-200"
: "bg-blue-100 text-blue-800 border-blue-200"
)}
>
{h.status}
</span>
</div>
<p className="text-xs text-foreground">{h.desc}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">
{h.date} · {h.user}
</p>
</div>
</div>
);
})}
</div>
);
}
// --- Main Component ---
export default function DesignChangeManagementPage() {
const [currentTab, setCurrentTab] = useState<TabType>("ecr");
const [ecrData, setEcrData] = useState<EcrItem[]>([]);
const [ecnData, setEcnData] = useState<EcnItem[]>([]);
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState<string | null>(null);
// 검색 상태
const [searchDateFrom, setSearchDateFrom] = useState("");
const [searchDateTo, setSearchDateTo] = useState("");
const [searchStatus, setSearchStatus] = useState<string>("all");
const [searchChangeType, setSearchChangeType] = useState<string>("all");
const [searchKeyword, setSearchKeyword] = useState("");
// ECR 모달
const [isEcrModalOpen, setIsEcrModalOpen] = useState(false);
const [isEcrEditMode, setIsEcrEditMode] = useState(false);
const [ecrForm, setEcrForm] = useState<Partial<EcrItem>>({});
const [ecrImpactChecks, setEcrImpactChecks] = useState<Record<string, boolean>>({});
// ECN 모달
const [isEcnModalOpen, setIsEcnModalOpen] = useState(false);
const [ecnForm, setEcnForm] = useState<Partial<EcnItem>>({});
const [ecnNotifyChecks, setEcnNotifyChecks] = useState<Record<string, boolean>>({});
// 기각 모달
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<string, number> = {};
ECR_STATUSES.forEach((s) => (counts[s] = ecrData.filter((r) => r.status === s).length));
return counts;
}, [ecrData]);
const ecnStatusCounts = useMemo(() => {
const counts: Record<string, number> = {};
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<string, boolean> = {};
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 (
<div className="flex flex-col h-[calc(100vh-4rem)] bg-muted/30 p-4 gap-4 relative">
{loading && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center z-50">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)}
{/* 검색 섹션 */}
<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>
<div className="flex items-center gap-2">
<Input
type="date"
className="w-[140px] h-9"
value={searchDateFrom}
onChange={(e) => setSearchDateFrom(e.target.value)}
/>
<span className="text-muted-foreground">~</span>
<Input
type="date"
className="w-[140px] h-9"
value={searchDateTo}
onChange={(e) => setSearchDateTo(e.target.value)}
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={currentTab} onValueChange={(v) => handleTabSwitch(v as TabType)}>
<SelectTrigger className="w-[150px] h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ecr">ECR ()</SelectItem>
<SelectItem value="ecn">ECN ()</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchStatus} onValueChange={setSearchStatus}>
<SelectTrigger className="w-[130px] h-9">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{currentStatuses.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{currentTab === "ecr" && (
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchChangeType} onValueChange={setSearchChangeType}>
<SelectTrigger className="w-[140px] h-9">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{CHANGE_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input
placeholder="ECR/ECN번호 / 품목 / 요청자"
className="w-[260px] 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={65} minSize={35}>
<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">
<ClipboardList className="w-4 h-4" />
{currentTab === "ecr" ? "설계변경요청(ECR) 목록" : "설계변경통지(ECN) 목록"}
<Badge variant="secondary" className="font-normal">
{currentList.length}
</Badge>
</div>
{currentTab === "ecr" && (
<Button size="sm" onClick={openEcrRegisterModal}>
<Plus className="w-4 h-4 mr-1.5" /> ECR
</Button>
)}
</div>
<div className="flex-1 overflow-auto">
{currentTab === "ecr" ? (
<Table>
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<TableRow>
<TableHead className="w-[50px] text-center">No</TableHead>
<TableHead className="w-[140px]">ECR번호</TableHead>
<TableHead className="w-[90px] text-center"></TableHead>
<TableHead className="w-[90px] text-center"></TableHead>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead className="w-[200px]"> /</TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[70px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[130px]"> ECN</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredEcr.length === 0 ? (
<TableRow>
<TableCell colSpan={11} className="h-32 text-center text-muted-foreground">
<div className="flex flex-col items-center gap-2">
<Inbox className="w-8 h-8 text-muted-foreground/50" />
<span> ECR이 </span>
</div>
</TableCell>
</TableRow>
) : (
filteredEcr.map((item, idx) => (
<TableRow
key={item.id}
className={cn(
"cursor-pointer hover:bg-muted/50 transition-colors",
selectedId === item.id && "bg-primary/5"
)}
onClick={() => setSelectedId(item.id)}
>
<TableCell className="text-center text-muted-foreground">{idx + 1}</TableCell>
<TableCell className="font-semibold text-primary">{item.id}</TableCell>
<TableCell className="text-center">
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getChangeTypeStyle(item.changeType))}>
{item.changeType}
</span>
</TableCell>
<TableCell className="text-center">
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getEcrStatusStyle(item.status))}>
{item.status}
</span>
</TableCell>
<TableCell className="text-center">
{item.urgency === "긴급" ? (
<span className="px-2 py-0.5 rounded-full text-[11px] font-medium border bg-rose-100 text-rose-800 border-rose-200">
</span>
) : (
"-"
)}
</TableCell>
<TableCell className="font-medium">{item.target}</TableCell>
<TableCell className="text-xs text-muted-foreground">{item.drawingNo}</TableCell>
<TableCell>{item.reqDept}</TableCell>
<TableCell>{item.requester}</TableCell>
<TableCell>{item.date}</TableCell>
<TableCell>
{item.ecnNo ? (
<button
className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-blue-100 text-blue-800 border border-blue-200 hover:bg-blue-200 transition-colors"
onClick={(e) => {
e.stopPropagation();
navigateToLink(item.ecnNo);
}}
>
{item.ecnNo} <ArrowRight className="w-3 h-3 inline" />
</button>
) : (
"-"
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<TableRow>
<TableHead className="w-[50px] text-center">No</TableHead>
<TableHead className="w-[140px]">ECN번호</TableHead>
<TableHead className="w-[90px] text-center"></TableHead>
<TableHead className="w-[200px]"> /</TableHead>
<TableHead className="w-[160px]"> ( )</TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[140px]"> </TableHead>
<TableHead className="w-[130px]"> ECR</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredEcn.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="h-32 text-center text-muted-foreground">
<div className="flex flex-col items-center gap-2">
<Inbox className="w-8 h-8 text-muted-foreground/50" />
<span> ECN이 </span>
</div>
</TableCell>
</TableRow>
) : (
filteredEcn.map((item, idx) => (
<TableRow
key={item.id}
className={cn(
"cursor-pointer hover:bg-muted/50 transition-colors",
selectedId === item.id && "bg-primary/5"
)}
onClick={() => setSelectedId(item.id)}
>
<TableCell className="text-center text-muted-foreground">{idx + 1}</TableCell>
<TableCell className="font-semibold text-primary">{item.id}</TableCell>
<TableCell className="text-center">
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getEcnStatusStyle(item.status))}>
{item.status}
</span>
</TableCell>
<TableCell className="font-medium">{item.target}</TableCell>
<TableCell className="text-xs text-emerald-600 font-medium">{item.drawingAfter}</TableCell>
<TableCell>{item.designer}</TableCell>
<TableCell>{item.date}</TableCell>
<TableCell>{item.applyDate}</TableCell>
<TableCell className="text-xs text-muted-foreground">{item.notifyDepts.join(", ")}</TableCell>
<TableCell>
<button
className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-amber-100 text-amber-800 border border-amber-200 hover:bg-amber-200 transition-colors"
onClick={(e) => {
e.stopPropagation();
navigateToLink(item.ecrNo);
}}
>
{item.ecrNo} <ArrowRight className="w-3 h-3 inline" />
</button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 상세 */}
<ResizablePanel defaultSize={35} minSize={20}>
<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 flex items-center gap-2">
<FileText className="w-4 h-4" />
</span>
{selectedEcr && (
<div className="flex items-center gap-1.5">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => openEcrEditModal(selectedEcr.id)}>
<Pencil className="w-3 h-3 mr-1" />
</Button>
{selectedEcr.status === "영향도분석" && (
<>
<Button size="sm" className="h-7 text-xs" onClick={() => openEcnIssueModal(selectedEcr.id)}>
<FileText className="w-3 h-3 mr-1" /> ECN
</Button>
<Button variant="destructive" size="sm" className="h-7 text-xs" onClick={() => openRejectModal(selectedEcr.id)}>
<XCircle className="w-3 h-3 mr-1" />
</Button>
</>
)}
</div>
)}
</div>
<div className="flex-1 overflow-auto p-4 space-y-5">
{/* 현황 카드 */}
<div className="grid grid-cols-3 gap-3">
{currentStatCards.map((card) => (
<button
key={card.label}
onClick={() => handleFilterByStatus(card.label)}
className={cn(
"rounded-xl p-4 text-center bg-linear-to-br transition-all hover:-translate-y-0.5 hover:shadow-md cursor-pointer",
card.gradient,
card.textColor
)}
>
<div className="text-xs font-medium opacity-90 mb-1">{card.label}</div>
<div className="text-2xl font-bold">{card.value}</div>
</button>
))}
</div>
{/* ECR 상세 */}
{selectedEcr ? (
<div className="space-y-5">
<section>
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2 pt-2 border-t">
</h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
<span className="text-muted-foreground text-xs block mb-1">ECR번호</span>
<span className="font-medium text-primary">{selectedEcr.id}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border inline-block", getEcrStatusStyle(selectedEcr.status))}>
{selectedEcr.status}
</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"> </span>
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border inline-block", getChangeTypeStyle(selectedEcr.changeType))}>
{selectedEcr.changeType}
</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>
{selectedEcr.urgency === "긴급" ? (
<span className="px-2 py-0.5 rounded-full text-[11px] font-medium border bg-rose-100 text-rose-800 border-rose-200"></span>
) : (
"보통"
)}
</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"> /</span>
<span className="font-medium">{selectedEcr.target}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>{selectedEcr.drawingNo}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"> / </span>
<span>{selectedEcr.reqDept} / {selectedEcr.requester}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>{selectedEcr.date}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"> </span>
<span>{selectedEcr.applyTiming}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"> ECN</span>
{selectedEcr.ecnNo ? (
<button
className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-blue-100 text-blue-800 border border-blue-200 hover:bg-blue-200 transition-colors"
onClick={() => navigateToLink(selectedEcr.ecnNo)}
>
{selectedEcr.ecnNo} <ArrowRight className="w-3 h-3 inline" />
</button>
) : (
<span className="text-muted-foreground"></span>
)}
</div>
</div>
</section>
<section>
<h3 className="text-sm font-semibold mb-2"> </h3>
<div className="bg-muted/30 p-3 rounded-md border border-border/50 text-sm whitespace-pre-wrap min-h-[50px]">
{selectedEcr.reason}
</div>
</section>
<section>
<h3 className="text-sm font-semibold mb-2"> </h3>
<div className="bg-muted/30 p-3 rounded-md border border-border/50 text-sm whitespace-pre-wrap min-h-[50px]">
{selectedEcr.content}
</div>
</section>
<section>
<h3 className="text-sm font-semibold mb-2"> </h3>
<div className="flex flex-wrap gap-1.5">
{selectedEcr.impact.map((imp) => (
<span key={imp} className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getImpactBadgeStyle(imp))}>
{imp}
</span>
))}
</div>
</section>
<section>
<h3 className="text-sm font-semibold mb-3"> </h3>
<Timeline history={selectedEcr.history} />
</section>
</div>
) : selectedEcn ? (
<div className="space-y-5">
<section>
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2 pt-2 border-t">
ECN
</h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
<span className="text-muted-foreground text-xs block mb-1">ECN번호</span>
<span className="font-medium text-primary">{selectedEcn.id}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border inline-block", getEcnStatusStyle(selectedEcn.status))}>
{selectedEcn.status}
</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"> /</span>
<span className="font-medium">{selectedEcn.target}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>{selectedEcn.designer}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>{selectedEcn.date}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>{selectedEcn.applyDate}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"> ECR</span>
<button
className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-amber-100 text-amber-800 border border-amber-200 hover:bg-amber-200 transition-colors"
onClick={() => navigateToLink(selectedEcn.ecrNo)}
>
{selectedEcn.ecrNo} <ArrowRight className="w-3 h-3 inline" />
</button>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"> </span>
<span className="text-xs">{selectedEcn.notifyDepts.join(", ")}</span>
</div>
</div>
</section>
<section>
<h3 className="text-sm font-semibold mb-2"> / </h3>
<div className="grid grid-cols-2 gap-3">
<div className="bg-rose-50 p-3 rounded-md border border-rose-200">
<div className="text-xs font-semibold text-rose-800 mb-1.5">
({selectedEcn.drawingBefore})
</div>
<div className="text-sm whitespace-pre-wrap">{selectedEcn.before}</div>
</div>
<div className="bg-emerald-50 p-3 rounded-md border border-emerald-200">
<div className="text-xs font-semibold text-emerald-800 mb-1.5">
({selectedEcn.drawingAfter})
</div>
<div className="text-sm whitespace-pre-wrap">{selectedEcn.after}</div>
</div>
</div>
</section>
<section>
<h3 className="text-sm font-semibold mb-2"> </h3>
<div className="bg-muted/30 p-3 rounded-md border border-border/50 text-sm whitespace-pre-wrap">
{selectedEcn.reason}
</div>
{selectedEcn.remark && (
<p className="text-xs text-muted-foreground mt-2">: {selectedEcn.remark}</p>
)}
</section>
<section>
<h3 className="text-sm font-semibold mb-3"> </h3>
<Timeline history={selectedEcn.history} />
</section>
</div>
) : (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="w-14 h-14 rounded-full bg-muted flex items-center justify-center mb-3">
<FileText className="w-7 h-7 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground"> </p>
</div>
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* ECR 등록/수정 모달 */}
<Dialog open={isEcrModalOpen} onOpenChange={setIsEcrModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[950px] max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{isEcrEditMode ? "설계변경요청(ECR) 수정" : "설계변경요청(ECR) 등록"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{isEcrEditMode ? "ECR 정보를 수정합니다." : "새로운 설계변경요청을 등록합니다."}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto">
<div className="flex flex-col md:flex-row gap-5">
{/* 좌측: 요청 정보 */}
<div className="md:w-[380px] shrink-0 space-y-3 bg-muted/30 p-4 rounded-lg border border-border/50">
<h3 className="text-sm font-semibold pb-2 border-b"> </h3>
<div>
<Label className="text-xs sm:text-sm">ECR번호</Label>
<Input value={ecrForm.id || ""} readOnly className="h-8 text-xs sm:h-10 sm:text-sm bg-muted cursor-not-allowed" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
type="date"
value={ecrForm.date || ""}
onChange={(e) => setEcrForm((p) => ({ ...p, date: e.target.value }))}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select value={ecrForm.applyTiming || "즉시"} onValueChange={(v) => setEcrForm((p) => ({ ...p, applyTiming: v }))}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="즉시"> </SelectItem>
<SelectItem value="재고소진후"> </SelectItem>
<SelectItem value="특정일자"> </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>
<Select value={ecrForm.changeType || ""} onValueChange={(v) => setEcrForm((p) => ({ ...p, changeType: v as ChangeType }))}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{CHANGE_TYPES.map((t) => (
<SelectItem key={t} value={t}>{t}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Select value={ecrForm.urgency || "보통"} onValueChange={(v) => setEcrForm((p) => ({ ...p, urgency: v as "보통" | "긴급" }))}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="보통"></SelectItem>
<SelectItem value="긴급"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs sm:text-sm"> / <span className="text-destructive">*</span></Label>
<Input
value={ecrForm.target || ""}
onChange={(e) => setEcrForm((p) => ({ ...p, target: e.target.value }))}
placeholder="품목코드 / 설비명"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={ecrForm.drawingNo || ""}
onChange={(e) => setEcrForm((p) => ({ ...p, drawingNo: e.target.value }))}
placeholder="DWG-XXX-XXX"
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={ecrForm.reqDept || ""} onValueChange={(v) => setEcrForm((p) => ({ ...p, reqDept: v }))}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{DEPARTMENTS.map((d) => (
<SelectItem key={d} value={d}>{d}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={ecrForm.requester || ""}
onChange={(e) => setEcrForm((p) => ({ ...p, requester: e.target.value }))}
placeholder="요청자명"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
</div>
{/* 우측: 변경 내용 */}
<div className="flex-1 min-w-0 flex flex-col gap-4">
<div className="space-y-3 bg-muted/30 p-4 rounded-lg border border-border/50 flex-1">
<h3 className="text-sm font-semibold pb-2 border-b"> </h3>
<div>
<Label className="text-xs sm:text-sm"> <span className="text-destructive">*</span></Label>
<Textarea
value={ecrForm.reason || ""}
onChange={(e) => setEcrForm((p) => ({ ...p, reason: e.target.value }))}
placeholder="변경이 필요한 구체적인 사유를 기술하세요"
className="min-h-[80px] resize-y text-xs sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> <span className="text-destructive">*</span></Label>
<Textarea
value={ecrForm.content || ""}
onChange={(e) => setEcrForm((p) => ({ ...p, content: e.target.value }))}
placeholder="어떻게 변경해야 하는지 구체적으로 기술하세요"
className="min-h-[80px] resize-y text-xs sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> ( )</Label>
<div className="grid grid-cols-3 gap-2 mt-2">
{IMPACT_OPTIONS.map((opt) => (
<label
key={opt.key}
className="flex items-center gap-2 px-3 py-2 bg-background border rounded-md text-xs cursor-pointer hover:bg-muted/50 transition-colors"
>
<Checkbox
checked={!!ecrImpactChecks[opt.key]}
onCheckedChange={(c) =>
setEcrImpactChecks((prev) => ({ ...prev, [opt.key]: !!c }))
}
/>
{opt.label}
</label>
))}
</div>
</div>
</div>
<div className="space-y-2 bg-muted/30 p-4 rounded-lg border border-border/50">
<h3 className="text-sm font-semibold pb-2 border-b flex items-center gap-2">
<Paperclip className="w-3.5 h-3.5" />
</h3>
<div className="border-2 border-dashed border-border rounded-md p-4 text-center text-xs text-muted-foreground cursor-pointer hover:border-primary/50 hover:bg-muted/30 transition-colors">
<Upload className="w-5 h-5 mx-auto mb-1.5 text-muted-foreground" />
( , )
</div>
</div>
</div>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setIsEcrModalOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
<Button onClick={handleSaveEcr} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
<Save className="w-4 h-4 mr-2" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ECN 발행 모달 */}
<Dialog open={isEcnModalOpen} onOpenChange={setIsEcnModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[950px] max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">ECN ()</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">ECR .</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto">
<div className="flex flex-col md:flex-row gap-5">
{/* 좌측 */}
<div className="md:w-[380px] shrink-0 space-y-3 bg-muted/30 p-4 rounded-lg border border-border/50">
<h3 className="text-sm font-semibold pb-2 border-b">ECN </h3>
<div>
<Label className="text-xs sm:text-sm">ECN번호</Label>
<Input value={ecnForm.id || ""} readOnly className="h-8 text-xs sm:h-10 sm:text-sm bg-muted cursor-not-allowed" />
</div>
<div>
<Label className="text-xs sm:text-sm"> ECR번호</Label>
<Input value={ecnForm.ecrNo || ""} readOnly className="h-8 text-xs sm:h-10 sm:text-sm bg-muted cursor-not-allowed" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
type="date"
value={ecnForm.date || ""}
onChange={(e) => setEcnForm((p) => ({ ...p, date: 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={ecnForm.applyDate || ""}
onChange={(e) => setEcnForm((p) => ({ ...p, applyDate: 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>
<Input value={ecnForm.target || ""} readOnly className="h-8 text-xs sm:h-10 sm:text-sm bg-muted cursor-not-allowed" />
</div>
<div>
<Label className="text-xs sm:text-sm"> ( )</Label>
<Input
value={ecnForm.drawingAfter || ""}
onChange={(e) => setEcnForm((p) => ({ ...p, drawingAfter: e.target.value }))}
placeholder="변경된 도면번호 (Rev 포함)"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select value={ecnForm.designer || ""} onValueChange={(v) => setEcnForm((p) => ({ ...p, designer: v }))}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{DESIGNERS.map((d) => (
<SelectItem key={d} value={d}>{d}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 우측 */}
<div className="flex-1 min-w-0 flex flex-col gap-4">
<div className="space-y-3 bg-muted/30 p-4 rounded-lg border border-border/50">
<h3 className="text-sm font-semibold pb-2 border-b"> / </h3>
<div>
<Label className="text-xs sm:text-sm"> (AS-IS)</Label>
<Textarea
value={ecnForm.before || ""}
onChange={(e) => setEcnForm((p) => ({ ...p, before: e.target.value }))}
placeholder="변경 전 상태/사양/치수 등"
className="min-h-[80px] resize-y text-xs sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> (TO-BE) <span className="text-destructive">*</span></Label>
<Textarea
value={ecnForm.after || ""}
onChange={(e) => setEcnForm((p) => ({ ...p, after: e.target.value }))}
placeholder="변경 후 상태/사양/치수 등"
className="min-h-[80px] resize-y text-xs sm:text-sm"
/>
</div>
</div>
<div className="space-y-3 bg-muted/30 p-4 rounded-lg border border-border/50">
<h3 className="text-sm font-semibold pb-2 border-b"> </h3>
<div className="grid grid-cols-3 gap-2">
{NOTIFY_DEPTS.map((dept) => (
<label
key={dept.key}
className="flex items-center gap-2 px-3 py-2 bg-background border rounded-md text-xs cursor-pointer hover:bg-muted/50 transition-colors"
>
<Checkbox
checked={!!ecnNotifyChecks[dept.key]}
onCheckedChange={(c) =>
setEcnNotifyChecks((prev) => ({ ...prev, [dept.key]: !!c }))
}
/>
{dept.label}
</label>
))}
</div>
</div>
<div className="space-y-3 bg-muted/30 p-4 rounded-lg border border-border/50">
<h3 className="text-sm font-semibold pb-2 border-b"> </h3>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Textarea
value={ecnForm.reason || ""}
readOnly
className="min-h-[60px] resize-y text-xs sm:text-sm bg-muted cursor-not-allowed"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Textarea
value={ecnForm.remark || ""}
onChange={(e) => setEcnForm((p) => ({ ...p, remark: e.target.value }))}
placeholder="추가 참고사항"
className="min-h-[60px] resize-y text-xs sm:text-sm"
/>
</div>
</div>
</div>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setIsEcnModalOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
<Button onClick={handleSaveEcn} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
<FileText className="w-4 h-4 mr-2" /> ECN
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 기각 모달 */}
<Dialog open={isRejectModalOpen} onOpenChange={setIsRejectModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg flex items-center gap-2">
<XCircle className="w-5 h-5 text-destructive" />
ECR
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{rejectTargetId} .
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<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="min-h-[100px] resize-y text-xs sm:text-sm"
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setIsRejectModalOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
<Button variant="destructive" onClick={handleRejectSubmit} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
<XCircle className="w-4 h-4 mr-2" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}