From 663a51e94d3879090dae01f25f1369a7975e25e5 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 11 May 2026 15:06:13 +0900 Subject: [PATCH] Add All BOM Substitutes API and Update BOM Management - Introduced a new API endpoint to retrieve all substitutes for a given BOM ID, allowing for bulk retrieval of substitute items. - Enhanced the BOM service to support the new functionality, including company code filtering and versioning options. - Updated the BOM management page to integrate the new substitute retrieval feature, enabling users to manage substitutes more effectively during the copy process. - Added necessary state management and UI elements for handling substitutes in the copy modal. (TASK: ERP-028) --- backend-node/src/controllers/bomController.ts | 14 + backend-node/src/routes/bomRoutes.ts | 1 + backend-node/src/services/bomService.ts | 68 +- .../(main)/COMPANY_7/production/bom/page.tsx | 838 ++++++++++++++---- 4 files changed, 753 insertions(+), 168 deletions(-) diff --git a/backend-node/src/controllers/bomController.ts b/backend-node/src/controllers/bomController.ts index 7651813a..70941987 100644 --- a/backend-node/src/controllers/bomController.ts +++ b/backend-node/src/controllers/bomController.ts @@ -306,6 +306,20 @@ export async function getBomDetailSubstitutes(req: Request, res: Response) { } } +// bom_id 전체의 대체품 일괄 조회 (복사 모달이 detail_id별 매핑 만들 때 사용) +export async function getAllBomSubstitutes(req: Request, res: Response) { + try { + const { bomId } = req.params; + const companyCode = (req as any).user?.companyCode || "*"; + const versionId = (req.query.versionId as string) || undefined; + const data = await bomService.getAllBomSubstitutes(bomId, companyCode, versionId); + res.json({ success: true, data }); + } catch (error: any) { + logger.error("BOM 전체 대체품 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + export async function getBomSubstituteCounts(req: Request, res: Response) { try { const { bomId } = req.params; diff --git a/backend-node/src/routes/bomRoutes.ts b/backend-node/src/routes/bomRoutes.ts index 70afe132..03394b16 100644 --- a/backend-node/src/routes/bomRoutes.ts +++ b/backend-node/src/routes/bomRoutes.ts @@ -35,6 +35,7 @@ router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion); // 대체품 (Substitute) router.get("/:bomId/substitute-counts", bomController.getBomSubstituteCounts); +router.get("/:bomId/substitutes-all", bomController.getAllBomSubstitutes); router.get("/details/:detailId/substitutes", bomController.getBomDetailSubstitutes); router.post("/details/:detailId/substitutes", bomController.createBomDetailSubstitute); router.put("/substitutes/:id", bomController.updateBomDetailSubstitute); diff --git a/backend-node/src/services/bomService.ts b/backend-node/src/services/bomService.ts index e71ef3aa..1724d8b6 100644 --- a/backend-node/src/services/bomService.ts +++ b/backend-node/src/services/bomService.ts @@ -848,6 +848,40 @@ export async function deleteBomVersion( // ─── 대체품 (Substitute) ───────────────────────────── +/** + * bom_id 하나에 속한 모든 detail의 대체품을 일괄 조회. + * 반환: detail_id 기준으로 grouping된 substitute 행 배열 (회사 단위 필터). + * 복사 모달이 트리 전체의 substitute를 한 번에 미리 받아오기 위해 추가 (TASK:ERP-028 옵션 B). + */ +export async function getAllBomSubstitutes(bomId: string, companyCode: string, versionId?: string) { + const params: any[] = [bomId]; + let sql = ` + SELECT s.*, d.bom_id, d.version_id, + i.item_name AS substitute_item_name, + i.item_number AS substitute_item_number, + i.unit AS substitute_unit, + i.inventory_unit AS substitute_inventory_unit + FROM bom_detail_substitute s + JOIN bom_detail d ON d.id = s.bom_detail_id + LEFT JOIN item_info i ON s.substitute_item_id = i.id + WHERE d.bom_id = $1 + `; + if (companyCode !== "*") { + params.push(companyCode); + sql += ` AND d.company_code = $${params.length}`; + } + if (versionId) { + params.push(versionId); + sql += ` AND d.version_id = $${params.length}`; + } + sql += ` + ORDER BY + CASE WHEN s.priority ~ '^[0-9]+$' THEN s.priority::int ELSE 9999 END ASC, + s.created_date ASC + `; + return query(sql, params); +} + export async function getBomDetailSubstitutes(detailId: string, companyCode: string) { const sql = ` SELECT s.*, @@ -971,6 +1005,15 @@ export interface BomTreeNodeInput { processType?: string | null; lossRate?: number | string | null; remark?: string | null; + /** 이 자품목의 대체품 목록 — 복사 시 새 detail_id로 INSERT (TASK:ERP-028 옵션 B) */ + substitutes?: BomSubstituteInput[]; +} + +export interface BomSubstituteInput { + substituteItemId: string; + priority?: string | number | null; + ratio?: string | number | null; + remark?: string | null; } export interface CopyBomToItemsParams { @@ -1153,7 +1196,30 @@ export async function insertVersionAndDetails( params.companyCode, ], ); - tempToNewId[node.tempId] = insertRes.rows[0].id; + const newDetailId = insertRes.rows[0].id; + tempToNewId[node.tempId] = newDetailId; + + // 대체품 INSERT — 페이로드에 substitutes가 있으면 새 detail_id로 매핑하여 추가 (TASK:ERP-028 옵션 B) + if (Array.isArray(node.substitutes) && node.substitutes.length > 0) { + for (const s of node.substitutes) { + if (!s.substituteItemId) continue; + await client.query( + `INSERT INTO bom_detail_substitute + (bom_detail_id, substitute_item_id, priority, ratio, remark, status, company_code, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + newDetailId, + s.substituteItemId, + s.priority != null ? String(s.priority) : null, + s.ratio != null ? String(s.ratio) : null, + s.remark || null, + "active", + params.companyCode, + params.createdBy, + ], + ); + } + } } return { versionId: newVersionId, insertedCount: params.sortedTree.length }; diff --git a/frontend/app/(main)/COMPANY_7/production/bom/page.tsx b/frontend/app/(main)/COMPANY_7/production/bom/page.tsx index 4ad087c2..45bdb0df 100644 --- a/frontend/app/(main)/COMPANY_7/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/bom/page.tsx @@ -218,9 +218,21 @@ interface CopyTreeRow { processType: string; lossRate: string; remark: string; + /** 이 행에 등록된 대체품 (편집 가능). 복사 실행 시 페이로드로 전송 */ + substitutes: CopySubstituteRow[]; _invalid?: boolean; // 수량 0 이하 차단 하이라이트 } +interface CopySubstituteRow { + tempId: string; // 프론트 임시 id + substituteItemId: string; + substituteItemNumber?: string; + substituteItemName?: string; + priority: string; + ratio: string; + remark: string; +} + // ─── 트리 구성 헬퍼 ───────────────────────────── function buildTree(details: BomDetail[]): TreeNode[] { const nodeMap = new Map(); @@ -416,11 +428,33 @@ export default function BomManagementPage() { const [copyResult, setCopyResult] = useState({ new: 0, versioned: 0, skipped: 0, failed: 0 }); // 우측: 편집 트리 (평탄화된 행 목록) const [copyTreeRows, setCopyTreeRows] = useState([]); + // 복사 모달 트리에서 선택된 행 (null이면 가상 루트 L0 선택). [하위 품목 추가]의 부모로 사용. + const [selectedCopyRowTempId, setSelectedCopyRowTempId] = useState(null); + // 복사 대체품 편집 서브모달 + const [copySubstituteModalOpen, setCopySubstituteModalOpen] = useState(false); + const [copySubstituteTargetTempId, setCopySubstituteTargetTempId] = useState(null); + const [copySubstituteSearchKw, setCopySubstituteSearchKw] = useState(""); + const [copySubstituteSearchResults, setCopySubstituteSearchResults] = useState([]); + const [copySubstituteSearchLoading, setCopySubstituteSearchLoading] = useState(false); + const [copySubstituteSearchPage, setCopySubstituteSearchPage] = useState(1); + const [copySubstituteSearchTotal, setCopySubstituteSearchTotal] = useState(0); + const COPY_SUB_PAGE_SIZE = 20; + // 복사 모달 전용 공정 옵션 — 라벨에서 "(코드)" 부분 제거하여 process_name만 표시 + const copyProcessOptions = useMemo( + () => processOptions.map((o) => ({ + code: o.code, + label: o.label.replace(/\s*\([^)]*\)\s*$/, "").trim() || o.label, + })), + [processOptions] + ); // 자식 추가용 품목 검색 모달 const [copyChildSearchOpen, setCopyChildSearchOpen] = useState(false); const [copyChildSearchKw, setCopyChildSearchKw] = useState(""); const [copyChildSearchResults, setCopyChildSearchResults] = useState([]); const [copyChildSearchLoading, setCopyChildSearchLoading] = useState(false); + const [copyChildSearchPage, setCopyChildSearchPage] = useState(1); + const [copyChildSearchTotal, setCopyChildSearchTotal] = useState(0); + const COPY_CHILD_PAGE_SIZE = 20; const [copyChildAddTarget, setCopyChildAddTarget] = useState<{ mode: "sibling" | "child" | "root"; rowTempId: string | null }>({ mode: "root", rowTempId: null }); const copyPageSize = 20; const copyTotalPages = Math.max(1, Math.ceil(copyTotal / copyPageSize)); @@ -1135,8 +1169,19 @@ export default function BomManagementPage() { // ─── BOM 삭제 ──────────────────────────────── // ─── BOM 복사 모달 핸들러 (TASK:ERP-028) ─────────── // 트리(TreeNode[]) → 평탄화된 CopyTreeRow[] - const flattenTreeForCopy = useCallback((nodes: TreeNode[], parentTempId: string | null = null, level = 0): CopyTreeRow[] => { + // 기존 BOM의 수량/단위/공정/손실율/비고/대체품을 그대로 끌어오고, 사용자가 우측 폼에서 수정 가능 + const flattenTreeForCopy = useCallback(( + nodes: TreeNode[], + parentTempId: string | null = null, + level = 0, + substituteMap: Record = {}, + ): CopyTreeRow[] => { const out: CopyTreeRow[] = []; + const toStr = (v: any, fallback: string) => { + if (v === null || v === undefined) return fallback; + const s = String(v).trim(); + return s === "" ? fallback : s; + }; for (const n of nodes) { const tempId = crypto.randomUUID(); out.push({ @@ -1146,14 +1191,15 @@ export default function BomManagementPage() { childItemId: n.child_item_id || "", childItemNumber: n.item_number || "", childItemName: n.item_name || "", - quantity: n.quantity || "1", - unit: n.unit || "", - processType: n.process_type || "", - lossRate: n.loss_rate || "0", - remark: n.remark || "", + quantity: toStr(n.quantity, "1"), + unit: toStr(n.unit, ""), + processType: toStr(n.process_type, ""), + lossRate: toStr(n.loss_rate, "0"), + remark: toStr(n.remark, ""), + substitutes: n.id && substituteMap[n.id] ? substituteMap[n.id].map((s) => ({ ...s, tempId: crypto.randomUUID() })) : [], }); if (n.children && n.children.length > 0) { - out.push(...flattenTreeForCopy(n.children, tempId, level + 1)); + out.push(...flattenTreeForCopy(n.children, tempId, level + 1, substituteMap)); } } return out; @@ -1222,7 +1268,31 @@ export default function BomManagementPage() { } // 트리 초기값: 현재 화면의 트리(편집본 우선, 없으면 원본) const baseTree = editingTree.length > 0 ? editingTree : treeNodes; - const flat = flattenTreeForCopy(baseTree, null, 0); + + // 기준 BOM의 대체품 일괄 조회 → detail_id별로 그룹핑 + const substituteMap: Record = {}; + try { + const subRes = await apiClient.get(`/bom/${selectedBomId}/substitutes-all`); + const rows: any[] = subRes.data?.data || []; + for (const r of rows) { + const did = r.bom_detail_id; + if (!did) continue; + if (!substituteMap[did]) substituteMap[did] = []; + substituteMap[did].push({ + tempId: "", + substituteItemId: r.substitute_item_id || "", + substituteItemNumber: r.substitute_item_number || "", + substituteItemName: r.substitute_item_name || "", + priority: r.priority || "", + ratio: r.ratio || "", + remark: r.remark || "", + }); + } + } catch { + // 대체품 조회 실패해도 트리 복사는 진행 + } + + const flat = flattenTreeForCopy(baseTree, null, 0, substituteMap); setCopyTreeRows(flat); setCopySearchKeyword(""); setCopyPage(1); @@ -1230,6 +1300,7 @@ export default function BomManagementPage() { setCopyConflictStrategy("skip"); setCopyProgress({ current: 0, total: 0 }); setCopyResult({ new: 0, versioned: 0, skipped: 0, failed: 0 }); + setSelectedCopyRowTempId(null); // 디폴트 = 가상 루트(L0) 선택 setCopyModalOpen(true); await fetchExistingBomItemIds(); searchCopyTargets(1, ""); @@ -1245,6 +1316,76 @@ export default function BomManagementPage() { }; // 트리 행 수정 + // ─── 복사 모달 대체품 편집 핸들러 ────────────────── + // 검색어 없으면 전체 페이지네이션. 검색어 있으면 품명 like → 0건이면 품목코드 재시도 (좌측 대상 품목 검색과 동일 패턴) + const searchCopySubstituteItems = useCallback(async (page: number, kw: string) => { + setCopySubstituteSearchLoading(true); + try { + const keyword = kw.trim(); + const baseReq = (filters: any[]) => apiClient.post(`/table-management/tables/item_info/data`, { + page, + size: COPY_SUB_PAGE_SIZE, + autoFilter: true, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + }); + // 1차: 품명 (없으면 전체) + let res = await baseReq(keyword ? [{ columnName: "item_name", operator: "contains", value: keyword }] : []); + let resData = res.data?.data; + let items = resData?.data || resData?.rows || []; + let total = resData?.total || resData?.totalCount || items.length; + // 2차: 품명으로 못 찾으면 품목코드 재시도 + if (keyword && items.length === 0) { + res = await baseReq([{ columnName: "item_number", operator: "contains", value: keyword }]); + resData = res.data?.data; + items = resData?.data || resData?.rows || []; + total = resData?.total || resData?.totalCount || items.length; + } + setCopySubstituteSearchResults(items); + setCopySubstituteSearchTotal(total); + } catch { + setCopySubstituteSearchResults([]); + setCopySubstituteSearchTotal(0); + } finally { + setCopySubstituteSearchLoading(false); + } + }, []); + + const addCopySubstitute = (item: any) => { + if (!copySubstituteTargetTempId) return; + setCopyTreeRows((prev) => prev.map((r) => { + if (r.tempId !== copySubstituteTargetTempId) return r; + // 중복 차단 + if (r.substitutes.some((s) => s.substituteItemId === item.id)) return r; + const newSub: CopySubstituteRow = { + tempId: crypto.randomUUID(), + substituteItemId: item.id, + substituteItemNumber: item.item_number || "", + substituteItemName: item.item_name || "", + priority: String(r.substitutes.length + 1), + ratio: "1", + remark: "", + }; + return { ...r, substitutes: [...r.substitutes, newSub] }; + })); + // 검색어/결과는 유지 — 같은 목록에서 연속 추가 가능. 이미 추가한 항목은 아래 "등록된 대체품"에서 확인 + }; + + const updateCopySubstitute = (subTempId: string, patch: Partial) => { + if (!copySubstituteTargetTempId) return; + setCopyTreeRows((prev) => prev.map((r) => { + if (r.tempId !== copySubstituteTargetTempId) return r; + return { ...r, substitutes: r.substitutes.map((s) => s.tempId === subTempId ? { ...s, ...patch } : s) }; + })); + }; + + const deleteCopySubstitute = (subTempId: string) => { + if (!copySubstituteTargetTempId) return; + setCopyTreeRows((prev) => prev.map((r) => { + if (r.tempId !== copySubstituteTargetTempId) return r; + return { ...r, substitutes: r.substitutes.filter((s) => s.tempId !== subTempId) }; + })); + }; + const updateCopyTreeRow = (tempId: string, patch: Partial) => { setCopyTreeRows((prev) => prev.map((r) => r.tempId === tempId ? { ...r, ...patch, _invalid: false } : r)); }; @@ -1263,6 +1404,8 @@ export default function BomManagementPage() { } } } + // 삭제된 행이 선택 상태였다면 가상 루트(L0)로 fallback + setSelectedCopyRowTempId((cur) => (cur && toRemove.has(cur)) ? null : cur); return prev.filter((r) => !toRemove.has(r.tempId)); }); }; @@ -1272,38 +1415,41 @@ export default function BomManagementPage() { setCopyChildAddTarget({ mode, rowTempId }); setCopyChildSearchKw(""); setCopyChildSearchResults([]); + setCopyChildSearchPage(1); + setCopyChildSearchTotal(0); setCopyChildSearchOpen(true); // 빈 검색 즉시 1페이지 로드 - void searchCopyChildItems(""); + void searchCopyChildItems(1, ""); }; - const searchCopyChildItems = async (kw: string) => { + // 검색어 없으면 전체 페이지네이션. 검색어 있으면 품명 → 0건이면 품목코드 재시도. + const searchCopyChildItems = async (page: number, kw: string) => { setCopyChildSearchLoading(true); try { - const filters: any[] = []; const keyword = kw.trim(); - if (keyword) { - filters.push({ columnName: "item_name", operator: "contains", value: keyword }); - } - const res = await apiClient.post(`/table-management/tables/item_info/data`, { - page: 1, size: 30, - dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + const baseReq = (filters: any[]) => apiClient.post(`/table-management/tables/item_info/data`, { + page, + size: COPY_CHILD_PAGE_SIZE, autoFilter: true, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, }); - let rows = res.data?.data?.data || res.data?.data?.rows || []; + let res = await baseReq(keyword ? [{ columnName: "item_name", operator: "contains", value: keyword }] : []); + let resData = res.data?.data; + let rows: any[] = resData?.data || resData?.rows || []; + let total = resData?.total || resData?.totalCount || rows.length; if (keyword && rows.length === 0) { - const res2 = await apiClient.post(`/table-management/tables/item_info/data`, { - page: 1, size: 30, - dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "contains", value: keyword }] }, - autoFilter: true, - }); - rows = res2.data?.data?.data || res2.data?.data?.rows || []; + res = await baseReq([{ columnName: "item_number", operator: "contains", value: keyword }]); + resData = res.data?.data; + rows = resData?.data || resData?.rows || []; + total = resData?.total || resData?.totalCount || rows.length; } // 기준 BOM의 item_id는 자식 후보에서 제외 (재귀 차단) const sourceItemId = bomHeader?.item_id || ""; setCopyChildSearchResults(rows.filter((r: any) => r.id !== sourceItemId)); + setCopyChildSearchTotal(total); } catch { setCopyChildSearchResults([]); + setCopyChildSearchTotal(0); } finally { setCopyChildSearchLoading(false); } @@ -1325,6 +1471,7 @@ export default function BomManagementPage() { processType: "", lossRate: "0", remark: "", + substitutes: [], }; setCopyTreeRows((prev) => { @@ -1428,6 +1575,14 @@ export default function BomManagementPage() { processType: r.processType || null, lossRate: r.lossRate ? Number(r.lossRate) : null, remark: r.remark || null, + substitutes: (r.substitutes || []) + .filter((s) => s.substituteItemId) + .map((s) => ({ + substituteItemId: s.substituteItemId, + priority: s.priority || null, + ratio: s.ratio || null, + remark: s.remark || null, + })), })), }; const res = await apiClient.post(`/bom/${selectedBomId}/copy-to-items`, body); @@ -1465,16 +1620,28 @@ export default function BomManagementPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [copySearchKeyword, copyModalOpen]); - // 복사 모달 — 자식 추가 검색 디바운스 (200ms) + // 복사 모달 — 자식 추가 검색 디바운스 (200ms). 검색어 변경 시 page 1로 리셋 useEffect(() => { if (!copyChildSearchOpen) return; const handle = setTimeout(() => { - void searchCopyChildItems(copyChildSearchKw); + setCopyChildSearchPage(1); + void searchCopyChildItems(1, copyChildSearchKw); }, 200); return () => clearTimeout(handle); // eslint-disable-next-line react-hooks/exhaustive-deps }, [copyChildSearchKw, copyChildSearchOpen]); + // 복사 모달 — 대체품 검색 디바운스 (200ms). 검색어 변경 시 page를 1로 리셋 + useEffect(() => { + if (!copySubstituteModalOpen) return; + const handle = setTimeout(() => { + setCopySubstituteSearchPage(1); + void searchCopySubstituteItems(1, copySubstituteSearchKw); + }, 200); + return () => clearTimeout(handle); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [copySubstituteSearchKw, copySubstituteModalOpen]); + const handleDeleteBom = async () => { if (checkedIds.length === 0) { toast.error("삭제할 BOM을 선택해주세요"); @@ -3382,11 +3549,15 @@ export default function BomManagementPage() { {/* ─── BOM 복사 모달 (TASK:ERP-028) ─── */} { if (!copying) setCopyModalOpen(o); }}> - + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > BOM 복사 — 기준 BOM을 다른 품목들로 복제 - 좌측에서 대상 품목을 선택하고, 중앙에서 충돌 처리 방식을 고른 뒤, 우측 트리를 편집하여 복사합니다. + 좌측에서 대상 품목을 선택하고, 우측에서 트리를 편집하여 복사합니다. 충돌 처리 방식은 하단에서 선택하세요. @@ -3463,152 +3634,242 @@ export default function BomManagementPage() { - {/* 중앙: 복사 옵션 */} -
-
- -
-
-
- - setCopyConflictStrategy(v as "skip" | "new_version")} - className="space-y-1.5" - > -
- - -
-
- - -
-
-
- -
- -
- 새 버전1.0 - 상태draft - 적용일TODAY - 만료일없음 -
-
- - {/* 진행률 + 결과 */} - {(copying || copyProgress.total > 0) && ( -
- - 0 ? (copyProgress.current / copyProgress.total) * 100 : 0} className="h-1.5" /> -
- {copyProgress.current} / {copyProgress.total}건 {copying && "처리 중..."} -
- {(copyResult.new + copyResult.versioned + copyResult.skipped + copyResult.failed) > 0 && ( -
- 신규 {copyResult.new}건 - 새 버전 {copyResult.versioned}건 - 스킵 {copyResult.skipped}건 - 실패 {copyResult.failed}건 -
- )} -
- )} -
-
- - {/* 우측: 트리 편집기 */} + {/* 우측: 트리 편집기 (중앙 옵션 영역은 하단 Footer로 통합) */}
-
- -
- {/* 기준 BOM 정보 readonly 카드 */} -
-
품목코드
{bomHeader?.item_code || "-"}
-
품명
{bomHeader?.item_name || "-"}
-
BOM 유형
{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader?.bom_type)?.label || bomHeader?.bom_type || "-"}
-
기준수량/단위
{bomHeader?.base_qty || "-"} {bomHeader?.unit || ""}
-
-
- {copyTreeRows.length === 0 ? ( + {!bomHeader && copyTreeRows.length === 0 ? (
- 트리 행이 비어 있어요. 우상단 [루트 행 추가]로 시작하세요. + 기준 BOM을 먼저 선택해주세요.
) : ( - +
+ + + + + + + + + - 품목 - 수량 - 단위 - 공정 - 손실율(%) - 비고 - 작업 + 레벨 / 품목 + 수량 + 단위 + 공정 + 손실율(%) + 비고 + 작업 - {copyTreeRows.map((r) => ( - - -
- {r.childItemNumber || "-"} - {r.childItemName || r.childItemId} + {/* 가상 루트 (L0) — BOM 마스터, 항상 첫 줄에 readonly로 표시. 클릭으로 선택 */} + {bomHeader && ( + setSelectedCopyRowTempId(null)} + > + +
+
+ + {copyTreeRows.length > 0 ? ( + + ) : ( + + )} + + 0 +
+ {bomHeader.item_code || "-"} + {bomHeader.item_name || "-"} +
- - updateCopyTreeRow(r.tempId, { quantity: e.target.value })} /> + {bomHeader.base_qty || "-"} + {bomHeader.unit || "-"} + - + - + + {selectedCopyRowTempId === null ? "← 선택됨 (이 행 아래 추가)" : "클릭하여 선택"} - - updateCopyTreeRow(r.tempId, { unit: e.target.value })} /> + + + )} + {/* 자품목 0개일 때 안내 행 */} + {bomHeader && copyTreeRows.length === 0 && ( + + + 자품목이 없어요. 우상단 [하위 품목 추가] 버튼으로 L1을 시작하세요. - + + )} + {/* 자품목 행 — 표시 레벨 = r.level + 1 */} + {copyTreeRows.map((r) => { + const hasChildren = copyTreeRows.some((o) => o.parentTempId === r.tempId); + const isSelected = selectedCopyRowTempId === r.tempId; + const displayLevel = r.level + 1; + const depthBarColor = + displayLevel === 1 ? "bg-emerald-400" + : displayLevel === 2 ? "bg-amber-400" + : displayLevel === 3 ? "bg-purple-400" + : "bg-blue-400"; + const rowBg = isSelected + ? "bg-primary/10 hover:bg-primary/15" + : displayLevel >= 3 ? "bg-muted/40 hover:bg-muted/50" + : "bg-background hover:bg-muted/60"; + return ( + + {/* 레벨 + 좌측 색상바 + chevron/점 + 품번/품명 (우측 트리뷰 패턴). 클릭으로 선택 */} + setSelectedCopyRowTempId(r.tempId)} + > +
+
+ + {hasChildren ? ( + + ) : ( + + )} + + {displayLevel} +
+ {r.childItemNumber || "-"} + {r.childItemName || r.childItemId} +
+ {/* 대체품 배지 — 클릭하면 편집 서브모달 */} + +
+ + + updateCopyTreeRow(r.tempId, { quantity: e.target.value })} + /> + + + {/* 단위는 기준 BOM에서 끌어온 값 readonly */} +
+ {r.unit || "-"} +
+
+ updateCopyTreeRow(r.tempId, { processType: v })} - options={processOptions} - placeholder="공정" + options={copyProcessOptions} + placeholder="공정 선택" /> - - updateCopyTreeRow(r.tempId, { lossRate: e.target.value })} /> + + updateCopyTreeRow(r.tempId, { lossRate: e.target.value })} + /> - - updateCopyTreeRow(r.tempId, { remark: e.target.value })} /> + + updateCopyTreeRow(r.tempId, { remark: e.target.value })} + /> - -
- - -
- ))} + ); + })}
)} @@ -3616,17 +3877,66 @@ export default function BomManagementPage() {
- -
-
- 대상 {copyCheckedIds.length}건 · 트리 {copyTreeRows.length}행 · 충돌 처리 {copyConflictStrategy === "skip" ? "스킵" : "새 버전"} -
-
- - + +
+ {/* 진행률 + 결과 (실행 중에만 표시) */} + {(copying || copyProgress.total > 0) && ( +
+ 0 ? (copyProgress.current / copyProgress.total) * 100 : 0} + className="h-1.5 flex-1" + /> + + {copyProgress.current} / {copyProgress.total}건 {copying && "처리 중..."} + + {(copyResult.new + copyResult.versioned + copyResult.skipped + copyResult.failed) > 0 && ( +
+ 신규 {copyResult.new} + 새 버전 {copyResult.versioned} + 스킵 {copyResult.skipped} + 실패 {copyResult.failed} +
+ )} +
+ )} +
+ {/* 좌: 충돌 처리 라디오 + 헤더 미리보기 */} +
+
+ + setCopyConflictStrategy(v as "skip" | "new_version")} + className="flex items-center gap-3" + > +
+ + +
+
+ + +
+
+
+
+ 신규 헤더: + v1.0 + draft + 적용일=오늘 +
+
+ {/* 우: 카운트 + 액션 */} +
+ + 대상 {copyCheckedIds.length}건 · 트리 {copyTreeRows.length}행 + + + +
@@ -3645,9 +3955,13 @@ export default function BomManagementPage() {
+
+ 목록에서 클릭하여 추가 + 총 {copyChildSearchTotal}건 +
- setCopyChildSearchKw(e.target.value)} />
@@ -3656,24 +3970,73 @@ export default function BomManagementPage() { 불러오는 중...
) : copyChildSearchResults.length === 0 ? ( -
검색 결과가 없어요
+
결과가 없어요
) : ( - +
+ + + + + + - + + + + + + - {copyChildSearchResults.map((it: any) => ( - selectCopyChildItem(it)}> - - - - - ))} + {copyChildSearchResults.map((it: any) => { + const rawUnit = it.inventory_unit || it.unit || ""; + const mapped = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label; + const unitLabel = mapped + ? mapped + : rawUnit && rawUnit.length <= 5 && !/^CAT[_-]/i.test(rawUnit) + ? rawUnit + : "-"; + return ( + selectCopyChildItem(it)}> + + + + + + ); + })}
품번품명
품번품명단위
{it.item_number || "-"}{it.item_name || "-"}
{it.item_number || "-"}{it.item_name || "-"}{unitLabel}
)}
+ {/* 페이지네이션 */} + {copyChildSearchTotal > COPY_CHILD_PAGE_SIZE && ( +
+ + 페이지 {copyChildSearchPage} / {Math.max(1, Math.ceil(copyChildSearchTotal / COPY_CHILD_PAGE_SIZE))} + +
+ + +
+
+ )}
@@ -3681,6 +4044,147 @@ export default function BomManagementPage() {
+ {/* 복사 모달 — 대체품 편집 서브모달 (선택된 트리 행에 대한 대체품 추가/수정/삭제) */} + + + + 대체품 편집 + + {(() => { + const r = copyTreeRows.find((x) => x.tempId === copySubstituteTargetTempId); + return r ? `${r.childItemNumber} / ${r.childItemName} — 이 행의 대체품을 관리합니다 (복사 시 함께 INSERT)` : "행을 찾을 수 없습니다"; + })()} + + + + {/* 검색 + 추가 (검색어 없어도 페이지네이션으로 항상 목록 표시) */} +
+
+ + 총 {copySubstituteSearchTotal}건 +
+
+ + setCopySubstituteSearchKw(e.target.value)} /> +
+
+ {copySubstituteSearchLoading ? ( +
+ 불러오는 중... +
+ ) : copySubstituteSearchResults.length === 0 ? ( +
결과가 없어요
+ ) : ( + copySubstituteSearchResults.map((it: any) => { + const rawUnit = it.inventory_unit || it.unit || ""; + const mapped = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label; + // 카테고리 매핑 안 된 raw 값(CAT_XXX 같은 ID나 5자 초과 문자열)은 "-" 폴백 + const unitLabel = mapped + ? mapped + : rawUnit && rawUnit.length <= 5 && !/^CAT[_-]/i.test(rawUnit) + ? rawUnit + : "-"; + return ( +
addCopySubstitute(it)}> + {it.item_number} + {it.item_name} + {unitLabel} + +
+ ); + }) + )} +
+ {/* 페이지네이션 */} + {copySubstituteSearchTotal > COPY_SUB_PAGE_SIZE && ( +
+ + 페이지 {copySubstituteSearchPage} / {Math.max(1, Math.ceil(copySubstituteSearchTotal / COPY_SUB_PAGE_SIZE))} + +
+ + +
+
+ )} +
+ + {/* 현재 등록된 대체품 */} +
+ + {(() => { + const r = copyTreeRows.find((x) => x.tempId === copySubstituteTargetTempId); + if (!r) return null; + if (r.substitutes.length === 0) { + return
아직 대체품이 없어요. 위에서 검색해 추가하세요.
; + } + return ( + + + + + + + + + + + + + {r.substitutes.map((s) => ( + + + + + + + + + ))} + +
품번품명우선순위비율비고
{s.substituteItemNumber || "-"}{s.substituteItemName || s.substituteItemId} + updateCopySubstitute(s.tempId, { priority: e.target.value })} /> + + updateCopySubstitute(s.tempId, { ratio: e.target.value })} /> + + updateCopySubstitute(s.tempId, { remark: e.target.value })} /> + + +
+ ); + })()} +
+ + + + +
+
+ {ConfirmDialogComponent}