1525 lines
63 KiB
TypeScript
1525 lines
63 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 { 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 {
|
|
Plus,
|
|
Save,
|
|
Inbox,
|
|
Pencil,
|
|
FileText,
|
|
XCircle,
|
|
ArrowRight,
|
|
Paperclip,
|
|
Upload,
|
|
Loader2,
|
|
Settings2,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { toast } from "sonner";
|
|
import {
|
|
getDesignRequestList,
|
|
createDesignRequest,
|
|
updateDesignRequest,
|
|
addRequestHistory,
|
|
getEcnList,
|
|
createEcn,
|
|
updateEcn,
|
|
} from "@/lib/api/design";
|
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
|
|
|
// --- Types ---
|
|
type 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-destructive/10 text-destructive border-destructive/20";
|
|
case "원가절감":
|
|
return "bg-success/10 text-success border-success/20";
|
|
case "고객요청":
|
|
return "bg-info/10 text-info border-info/20";
|
|
case "공정개선":
|
|
return "bg-warning/10 text-warning border-warning/20";
|
|
case "법규대응":
|
|
return "bg-primary/10 text-primary border-primary/20";
|
|
default:
|
|
return "bg-muted text-muted-foreground border-border";
|
|
}
|
|
};
|
|
|
|
const getEcrStatusStyle = (status: EcrStatus) => {
|
|
switch (status) {
|
|
case "요청접수":
|
|
return "bg-info/10 text-info border-info/20";
|
|
case "영향도분석":
|
|
return "bg-warning/10 text-warning border-warning/20";
|
|
case "ECN발행":
|
|
return "bg-success/10 text-success border-success/20";
|
|
case "기각":
|
|
return "bg-muted text-muted-foreground border-border";
|
|
default:
|
|
return "bg-muted text-muted-foreground border-border";
|
|
}
|
|
};
|
|
|
|
const getEcnStatusStyle = (status: EcnStatus) => {
|
|
switch (status) {
|
|
case "ECN발행":
|
|
return "bg-info/10 text-info border-info/20";
|
|
case "도면변경":
|
|
return "bg-primary/10 text-primary border-primary/20";
|
|
case "통보완료":
|
|
return "bg-warning/10 text-warning border-warning/20";
|
|
case "적용완료":
|
|
return "bg-success/10 text-success border-success/20";
|
|
default:
|
|
return "bg-muted text-muted-foreground border-border";
|
|
}
|
|
};
|
|
|
|
const getImpactBadgeStyle = (impact: string) => {
|
|
switch (impact) {
|
|
case "BOM":
|
|
return "bg-info/10 text-info border-info/20";
|
|
case "공정":
|
|
return "bg-warning/10 text-warning border-warning/20";
|
|
case "금형":
|
|
return "bg-destructive/10 text-destructive border-destructive/20";
|
|
case "검사기준":
|
|
return "bg-primary/10 text-primary border-primary/20";
|
|
case "구매":
|
|
case "원가":
|
|
return "bg-success/10 text-success border-success/20";
|
|
default:
|
|
return "bg-muted text-muted-foreground border-border";
|
|
}
|
|
};
|
|
|
|
const getTimelineStatusStyle = (status: string) => {
|
|
switch (status) {
|
|
case "기각":
|
|
return "bg-muted text-muted-foreground border-border";
|
|
case "적용완료":
|
|
case "ECN발행":
|
|
return "bg-success/10 text-success border-success/20";
|
|
case "영향도분석":
|
|
return "bg-warning/10 text-warning border-warning/20";
|
|
case "도면변경":
|
|
return "bg-primary/10 text-primary border-primary/20";
|
|
case "통보완료":
|
|
return "bg-info/10 text-info border-info/20";
|
|
default:
|
|
return "bg-info/10 text-info border-info/20";
|
|
}
|
|
};
|
|
|
|
// --- 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-destructive border-destructive/60"
|
|
: isLast && isCompleted
|
|
? "bg-success border-success/60"
|
|
: isLast
|
|
? "bg-primary border-primary/50 ring-4 ring-primary/10"
|
|
: "bg-success border-success/60"
|
|
)}
|
|
/>
|
|
{!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",
|
|
getTimelineStatusStyle(h.status)
|
|
)}
|
|
>
|
|
{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>
|
|
);
|
|
}
|
|
|
|
// --- Grid Columns ---
|
|
const ECR_GRID_COLUMNS = [
|
|
{ key: "request_no", label: "ECR번호" },
|
|
{ key: "change_type", label: "변경유형" },
|
|
{ key: "status", label: "상태" },
|
|
{ key: "urgency", label: "긴급" },
|
|
{ key: "target_name", label: "대상 품목/설비" },
|
|
{ key: "drawing_no", label: "도면번호" },
|
|
{ key: "req_dept", label: "요청부서" },
|
|
{ key: "requester", label: "요청자" },
|
|
{ key: "request_date", label: "요청일자" },
|
|
{ key: "ecn_no", label: "관련 ECN" },
|
|
];
|
|
|
|
const ECN_GRID_COLUMNS = [
|
|
{ key: "ecn_no", label: "ECN번호" },
|
|
{ key: "status", label: "상태" },
|
|
{ key: "target", label: "대상 품목/설비" },
|
|
{ key: "drawing_after", label: "도면 (변경 후)" },
|
|
{ key: "designer", label: "설계담당" },
|
|
{ key: "ecn_date", label: "발행일자" },
|
|
{ key: "apply_date", label: "적용일자" },
|
|
{ key: "notify_depts", label: "통보 부서" },
|
|
{ key: "ecr_id", label: "관련 ECR" },
|
|
];
|
|
|
|
// --- Main Component ---
|
|
export default function DesignChangeManagementPage() {
|
|
const tsEcr = useTableSettings("c16-change-management-ecr", "dsn_design_request", ECR_GRID_COLUMNS);
|
|
const tsEcn = useTableSettings("c16-change-management-ecn", "dsn_ecn", ECN_GRID_COLUMNS);
|
|
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 [detailOpen, setDetailOpen] = useState(false);
|
|
|
|
// 검색 필터 (DynamicSearchFilter)
|
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
|
|
|
// 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("");
|
|
|
|
|
|
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]);
|
|
|
|
// snake_case → camelCase 매핑 (ECR)
|
|
const ecrFieldMap: Record<string, string> = {
|
|
request_no: "id",
|
|
request_date: "date",
|
|
change_type: "changeType",
|
|
target_name: "target",
|
|
drawing_no: "drawingNo",
|
|
req_dept: "reqDept",
|
|
ecn_no: "ecnNo",
|
|
apply_timing: "applyTiming",
|
|
};
|
|
// snake_case → camelCase 매핑 (ECN)
|
|
const ecnFieldMap: Record<string, string> = {
|
|
ecn_no: "id",
|
|
ecn_date: "date",
|
|
apply_date: "applyDate",
|
|
drawing_before: "drawingBefore",
|
|
drawing_after: "drawingAfter",
|
|
ecr_id: "ecrNo",
|
|
notify_depts: "notifyDepts",
|
|
};
|
|
const getFieldValue = (obj: any, colName: string, map: Record<string, string>): string => {
|
|
const key = map[colName] || colName;
|
|
const val = obj[key];
|
|
if (Array.isArray(val)) return val.join(",");
|
|
return val !== undefined && val !== null ? String(val) : "";
|
|
};
|
|
|
|
const applyFilters = (items: any[], map: Record<string, string>) => {
|
|
if (searchFilters.length === 0) return items;
|
|
return items.filter((item) => {
|
|
for (const f of searchFilters) {
|
|
const val = getFieldValue(item, f.columnName, map);
|
|
if (f.operator === "contains") {
|
|
if (!val.toLowerCase().includes(f.value.toLowerCase())) return false;
|
|
} else if (f.operator === "equals") {
|
|
if (val !== f.value) return false;
|
|
} else if (f.operator === "in") {
|
|
const allowed = f.value.split("|");
|
|
if (!allowed.includes(val)) return false;
|
|
} else if (f.operator === "between") {
|
|
const [from, to] = f.value.split("|");
|
|
if (from && val < from) return false;
|
|
if (to && val > to) return false;
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
};
|
|
|
|
// --- Filtered Data ---
|
|
const filteredEcr = useMemo(() => {
|
|
return applyFilters(ecrData, ecrFieldMap)
|
|
.sort((a: EcrItem, b: EcrItem) => b.date.localeCompare(a.date));
|
|
}, [ecrData, searchFilters]);
|
|
|
|
const filteredEcn = useMemo(() => {
|
|
return applyFilters(ecnData, ecnFieldMap)
|
|
.sort((a: EcnItem, b: EcnItem) => b.date.localeCompare(a.date));
|
|
}, [ecnData, searchFilters]);
|
|
|
|
// --- 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);
|
|
};
|
|
|
|
const handleFilterByStatus = (_status: string) => {
|
|
// Status filter now handled by DynamicSearchFilter
|
|
};
|
|
|
|
// --- ECR/ECN Navigation ---
|
|
const navigateToLink = (targetId: string) => {
|
|
setDetailOpen(false);
|
|
if (targetId.startsWith("ECN")) {
|
|
setCurrentTab("ecn");
|
|
setSelectedId(targetId);
|
|
} else if (targetId.startsWith("ECR")) {
|
|
setCurrentTab("ecr");
|
|
setSelectedId(targetId);
|
|
}
|
|
};
|
|
|
|
// --- 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, color: "text-info" },
|
|
{ label: "영향도분석", value: ecrStatusCounts["영향도분석"] || 0, color: "text-warning" },
|
|
{ label: "ECN발행", value: ecrStatusCounts["ECN발행"] || 0, color: "text-success" },
|
|
];
|
|
|
|
const ecnStatCards = [
|
|
{ label: "도면변경", value: ecnStatusCounts["도면변경"] || 0, color: "text-primary" },
|
|
{ label: "통보완료", value: ecnStatusCounts["통보완료"] || 0, color: "text-info" },
|
|
{ label: "적용완료", value: ecnStatusCounts["적용완료"] || 0, color: "text-success" },
|
|
];
|
|
|
|
const currentStatCards = currentTab === "ecr" ? ecrStatCards : ecnStatCards;
|
|
const currentList = currentTab === "ecr" ? filteredEcr : filteredEcn;
|
|
|
|
const handleRowClick = (id: string) => {
|
|
setSelectedId(id);
|
|
setDetailOpen(true);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full gap-3 p-3 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>
|
|
)}
|
|
|
|
{/* 탭 선택 + 검색 필터 */}
|
|
<div className="shrink-0 flex flex-col gap-2">
|
|
<div className="flex items-center gap-3 px-1">
|
|
<Select value={currentTab} onValueChange={(v) => handleTabSwitch(v as TabType)}>
|
|
<SelectTrigger className="w-[170px] h-9">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="ecr">ECR (변경요청)</SelectItem>
|
|
<SelectItem value="ecn">ECN (변경통지)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{currentTab === "ecr" ? (
|
|
<DynamicSearchFilter
|
|
tableName="dsn_design_request"
|
|
filterId="c16-change-management-ecr"
|
|
onFilterChange={setSearchFilters}
|
|
externalFilterConfig={tsEcr.filterConfig}
|
|
dataCount={filteredEcr.length}
|
|
/>
|
|
) : (
|
|
<DynamicSearchFilter
|
|
tableName="dsn_ecn"
|
|
filterId="c16-change-management-ecn"
|
|
onFilterChange={setSearchFilters}
|
|
externalFilterConfig={tsEcn.filterConfig}
|
|
dataCount={filteredEcn.length}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* 현황 카드 */}
|
|
<div className="grid grid-cols-3 gap-3 shrink-0">
|
|
{currentStatCards.map((card) => (
|
|
<button
|
|
key={card.label}
|
|
onClick={() => handleFilterByStatus(card.label)}
|
|
className="rounded-lg border bg-card px-3 py-2 text-left transition-colors hover:bg-accent/50"
|
|
>
|
|
<div className="text-[10px] text-muted-foreground">{card.label}</div>
|
|
<div className={cn("text-xl font-bold", card.color)}>{card.value}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 액션 바 */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-lg font-semibold">
|
|
{currentTab === "ecr" ? "설계변경요청(ECR) 목록" : "설계변경통지(ECN) 목록"}
|
|
</h2>
|
|
<Badge variant="secondary" className="font-mono">{currentList.length}건</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{currentTab === "ecr" && (
|
|
<Button size="sm" onClick={openEcrRegisterModal}>
|
|
<Plus className="w-4 h-4 mr-1.5" /> ECR 등록
|
|
</Button>
|
|
)}
|
|
<Button variant="ghost" size="sm" onClick={() => (currentTab === "ecr" ? tsEcr : tsEcn).setOpen(true)} title="테이블 설정">
|
|
<Settings2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 테이블 영역 */}
|
|
<div className="flex-1 flex flex-col overflow-hidden border rounded-lg bg-card">
|
|
<div className="flex-1 overflow-auto">
|
|
{currentTab === "ecr" ? (
|
|
<EDataTable
|
|
columns={[
|
|
{ key: "id", label: "ECR번호", width: "w-[140px]", render: (val: any) => <span className="font-semibold text-primary">{val}</span> },
|
|
{ key: "changeType", label: "변경유형", width: "w-[90px]", align: "center" as const, render: (val: any) => <span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getChangeTypeStyle(val))}>{val}</span> },
|
|
{ key: "status", label: "상태", width: "w-[90px]", align: "center" as const, render: (val: any) => <span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getEcrStatusStyle(val))}>{val}</span> },
|
|
{ key: "urgency", label: "긴급", width: "w-[60px]", align: "center" as const, render: (val: any) => val === "긴급" ? <span className="px-2 py-0.5 rounded-full text-[11px] font-medium border bg-destructive/10 text-destructive border-destructive/20">긴급</span> : <span>-</span> },
|
|
{ key: "target", label: "대상 품목/설비", width: "w-[200px]" },
|
|
{ key: "drawingNo", label: "도면번호", width: "w-[150px]" },
|
|
{ key: "reqDept", label: "요청부서", width: "w-[80px]" },
|
|
{ key: "requester", label: "요청자", width: "w-[70px]" },
|
|
{ key: "date", label: "요청일자", width: "w-[100px]" },
|
|
{ key: "ecnNo", label: "관련 ECN", width: "w-[130px]", render: (val: any) => val ? <button className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-info/10 text-info border border-info/20 hover:bg-info/20 transition-colors" onClick={(e) => { e.stopPropagation(); navigateToLink(val); }}>{val} <ArrowRight className="w-3 h-3 inline" /></button> : <span>-</span> },
|
|
] as EDataTableColumn<EcrItem>[]}
|
|
data={tsEcr.groupData(filteredEcr)}
|
|
rowKey={(row) => row.id}
|
|
selectedId={selectedId}
|
|
onSelect={(id) => { if (id) handleRowClick(id); }}
|
|
onRowClick={(row) => handleRowClick(row.id)}
|
|
emptyMessage="조건에 맞는 ECR이 없어요"
|
|
showRowNumber
|
|
showPagination={false}
|
|
draggableColumns={false}
|
|
/>
|
|
) : (
|
|
<EDataTable
|
|
columns={[
|
|
{ key: "id", label: "ECN번호", width: "w-[140px]", render: (val: any) => <span className="font-semibold text-primary">{val}</span> },
|
|
{ key: "status", label: "상태", width: "w-[90px]", align: "center" as const, render: (val: any) => <span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getEcnStatusStyle(val))}>{val}</span> },
|
|
{ key: "target", label: "대상 품목/설비", width: "w-[200px]" },
|
|
{ key: "drawingAfter", label: "도면 (변경 후)", width: "w-[160px]", render: (val: any) => <span className="text-[13px] text-success font-medium">{val}</span> },
|
|
{ key: "designer", label: "설계담당", width: "w-[80px]" },
|
|
{ key: "date", label: "발행일자", width: "w-[100px]" },
|
|
{ key: "applyDate", label: "적용일자", width: "w-[100px]" },
|
|
{ key: "notifyDepts", label: "통보 부서", width: "w-[140px]", render: (val: any) => <span className="text-[13px] text-muted-foreground">{Array.isArray(val) ? val.join(", ") : val}</span> },
|
|
{ key: "ecrNo", label: "관련 ECR", width: "w-[130px]", render: (val: any) => <button className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-warning/10 text-warning border border-warning/20 hover:bg-warning/20 transition-colors" onClick={(e) => { e.stopPropagation(); navigateToLink(val); }}>{val} <ArrowRight className="w-3 h-3 inline" /></button> },
|
|
] as EDataTableColumn<EcnItem>[]}
|
|
data={tsEcn.groupData(filteredEcn)}
|
|
rowKey={(row) => row.id}
|
|
selectedId={selectedId}
|
|
onSelect={(id) => { if (id) handleRowClick(id); }}
|
|
onRowClick={(row) => handleRowClick(row.id)}
|
|
emptyMessage="조건에 맞는 ECN이 없어요"
|
|
showRowNumber
|
|
showPagination={false}
|
|
draggableColumns={false}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 상세 정보 다이얼로그 */}
|
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
|
<DialogContent className="max-w-[900px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
|
<DialogHeader>
|
|
<DialogTitle>{currentTab === "ecr" ? `ECR 상세 — ${selectedEcr?.id || ""}` : `ECN 상세 — ${selectedEcn?.id || ""}`}</DialogTitle>
|
|
<DialogDescription>{currentTab === "ecr" ? "설계변경요청의 상세 정보를 확인해요." : "설계변경통지의 상세 정보를 확인해요."}</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="space-y-5">
|
|
{/* ECR 상세 */}
|
|
{selectedEcr ? (
|
|
<>
|
|
<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-destructive/10 text-destructive border-destructive/20">긴급</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-info/10 text-info border border-info/20 hover:bg-info/20 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>
|
|
</>
|
|
) : selectedEcn ? (
|
|
<>
|
|
<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-warning/10 text-warning border border-warning/20 hover:bg-warning/20 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-destructive/5 p-3 rounded-md border border-destructive/20">
|
|
<div className="text-xs font-semibold text-destructive mb-1.5">
|
|
변경 전 ({selectedEcn.drawingBefore})
|
|
</div>
|
|
<div className="text-sm whitespace-pre-wrap">{selectedEcn.before}</div>
|
|
</div>
|
|
<div className="bg-success/5 p-3 rounded-md border border-success/20">
|
|
<div className="text-xs font-semibold text-success 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>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
{selectedEcr && (
|
|
<>
|
|
<Button variant="outline" size="sm" onClick={() => { setDetailOpen(false); openEcrEditModal(selectedEcr.id); }}>
|
|
<Pencil className="w-3.5 h-3.5 mr-1" /> 수정
|
|
</Button>
|
|
{selectedEcr.status === "영향도분석" && (
|
|
<>
|
|
<Button size="sm" onClick={() => { setDetailOpen(false); openEcnIssueModal(selectedEcr.id); }}>
|
|
<FileText className="w-3.5 h-3.5 mr-1" /> ECN 발행
|
|
</Button>
|
|
<Button variant="destructive" size="sm" onClick={() => { setDetailOpen(false); openRejectModal(selectedEcr.id); }}>
|
|
<XCircle className="w-3.5 h-3.5 mr-1" /> 기각
|
|
</Button>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ECR 등록/수정 모달 */}
|
|
<Dialog open={isEcrModalOpen} onOpenChange={setIsEcrModalOpen}>
|
|
<DialogContent className="max-w-5xl w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
|
<DialogHeader>
|
|
<DialogTitle>{isEcrEditMode ? "설계변경요청(ECR) 수정" : "설계변경요청(ECR) 등록"}</DialogTitle>
|
|
<DialogDescription>{isEcrEditMode ? "ECR 정보를 수정해요." : "새로운 설계변경요청을 등록해요."}</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="flex flex-col md:flex-row gap-5 p-6">
|
|
{/* 좌측: 요청 정보 */}
|
|
<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 font-semibold uppercase tracking-wide text-muted-foreground">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 font-semibold uppercase tracking-wide text-muted-foreground">요청일자</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 font-semibold uppercase tracking-wide text-muted-foreground">희망 적용시점</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 font-semibold uppercase tracking-wide text-muted-foreground">변경 유형 <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 font-semibold uppercase tracking-wide text-muted-foreground">긴급도</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 font-semibold uppercase tracking-wide text-muted-foreground">대상 품목/설비 <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 font-semibold uppercase tracking-wide text-muted-foreground">대상 도면번호</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 font-semibold uppercase tracking-wide text-muted-foreground">요청부서</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 font-semibold uppercase tracking-wide text-muted-foreground">요청자</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 font-semibold uppercase tracking-wide text-muted-foreground">변경 사유 <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 font-semibold uppercase tracking-wide text-muted-foreground">변경 요구 내용 <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 font-semibold uppercase tracking-wide text-muted-foreground">영향 범위 (해당 항목 체크)</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>
|
|
<Button variant="outline" onClick={() => setIsEcrModalOpen(false)}>취소</Button>
|
|
<Button onClick={handleSaveEcr}>
|
|
<Save className="w-4 h-4 mr-1.5" /> 저장
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ECN 발행 모달 */}
|
|
<Dialog open={isEcnModalOpen} onOpenChange={setIsEcnModalOpen}>
|
|
<DialogContent className="max-w-5xl w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
|
<DialogHeader>
|
|
<DialogTitle>ECN 발행 (설계변경통지)</DialogTitle>
|
|
<DialogDescription>ECR 기반으로 설계변경통지를 발행해요.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="flex flex-col md:flex-row gap-5 p-6">
|
|
{/* 좌측 */}
|
|
<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 font-semibold uppercase tracking-wide text-muted-foreground">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 font-semibold uppercase tracking-wide text-muted-foreground">관련 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 font-semibold uppercase tracking-wide text-muted-foreground">발행일자</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 font-semibold uppercase tracking-wide text-muted-foreground">적용일자 <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 font-semibold uppercase tracking-wide text-muted-foreground">대상 품목/설비</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 font-semibold uppercase tracking-wide text-muted-foreground">도면번호 (변경 후)</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 font-semibold uppercase tracking-wide text-muted-foreground">설계 담당</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 font-semibold uppercase tracking-wide text-muted-foreground">변경 전 (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 font-semibold uppercase tracking-wide text-muted-foreground">변경 후 (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 font-semibold uppercase tracking-wide text-muted-foreground">변경 사유 요약</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 font-semibold uppercase tracking-wide text-muted-foreground">비고</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>
|
|
<Button variant="outline" onClick={() => setIsEcnModalOpen(false)}>취소</Button>
|
|
<Button onClick={handleSaveEcn}>
|
|
<FileText className="w-4 h-4 mr-1.5" /> 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 font-semibold uppercase tracking-wide text-muted-foreground">기각 사유 <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>
|
|
|
|
<TableSettingsModal
|
|
open={tsEcr.open}
|
|
onOpenChange={tsEcr.setOpen}
|
|
tableName={tsEcr.tableName}
|
|
settingsId={tsEcr.settingsId}
|
|
defaultVisibleKeys={tsEcr.defaultVisibleKeys}
|
|
onSave={tsEcr.applySettings}
|
|
/>
|
|
<TableSettingsModal
|
|
open={tsEcn.open}
|
|
onOpenChange={tsEcn.setOpen}
|
|
tableName={tsEcn.tableName}
|
|
settingsId={tsEcn.settingsId}
|
|
defaultVisibleKeys={tsEcn.defaultVisibleKeys}
|
|
onSave={tsEcn.applySettings}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|