From 1ebd9348aeb76a1e352eab46ff23ce359362a050 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 21 May 2026 10:01:41 +0900 Subject: [PATCH] Enhance Work Instruction Functionality with Material Overrides - Added validation for `materialOverrides` in the `save` function of the work instruction controller, ensuring proper structure and required fields. - Implemented logic to handle the insertion and deletion of material input details based on the provided `materialOverrides`, maintaining data integrity during edits. - Introduced new routes for retrieving BOM tree and material overrides, enhancing the work instruction management process. - Updated the frontend to support new material mapping features, including a structured approach for handling BOM substitutes and material inputs. (TASK: ERP-node-090) --- .../controllers/workInstructionController.ts | 350 +- .../src/routes/workInstructionRoutes.ts | 6 + .../production/work-instruction/page.tsx | 2929 +++++++++++++---- .../components/common/ExcelUploadModal.tsx | 165 +- frontend/lib/api/workInstruction.ts | 107 +- 5 files changed, 2938 insertions(+), 619 deletions(-) diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index 28f610be..fb417780 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -280,12 +280,42 @@ export async function save(req: AuthenticatedRequest, res: Response) { await ensureDetailRoutingColumn(); const companyCode = req.user!.companyCode; const userId = req.user!.userId; - const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId, batchNo, cuttingPlanId } = req.body; + const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId, batchNo, cuttingPlanId, materialOverrides } = req.body; if (!items || items.length === 0) { return res.status(400).json({ success: false, message: "품목을 선택해주세요" }); } + // ── materialOverrides 사전 검증 (옵셔널) ── + // 형식: [{ itemNumber, routingVersionId, materials: [{ bomItemId, bomItemName?, bomQty, bomUnit?, routingDetailId, content?, isOverride?, originalBomItemId? }] }] + // 비어있거나 materials 빈 배열이면 wi_* INSERT 자체를 건너뛰어 기존 마스터 폴백 동작 유지. + if (materialOverrides != null && !Array.isArray(materialOverrides)) { + return res.status(400).json({ success: false, message: "materialOverrides는 배열이어야 합니다" }); + } + if (Array.isArray(materialOverrides)) { + for (const ov of materialOverrides) { + if (!ov || typeof ov !== "object") { + return res.status(400).json({ success: false, message: "materialOverrides 항목 형식 오류" }); + } + if (!Array.isArray(ov.materials)) continue; // materials 없으면 스킵 (해당 품목 건너뛴 효과) + for (const m of ov.materials) { + if (!m || typeof m !== "object") { + return res.status(400).json({ success: false, message: "materialOverrides.materials 항목 형식 오류" }); + } + if (!m.bomItemId || String(m.bomItemId).trim() === "") { + return res.status(400).json({ success: false, message: "자재 품목(bomItemId)을 선택해주세요" }); + } + if (!m.routingDetailId || String(m.routingDetailId).trim() === "") { + return res.status(400).json({ success: false, message: "자재 투입 공정(routingDetailId)을 선택해주세요" }); + } + const qtyNum = Number(m.bomQty); + if (!Number.isFinite(qtyNum) || qtyNum <= 0) { + return res.status(400).json({ success: false, message: "수량은 0보다 커야 합니다" }); + } + } + } + } + const pool = getPool(); const client = await pool.connect(); try { @@ -356,6 +386,137 @@ export async function save(req: AuthenticatedRequest, res: Response) { [String(totalQty), effectiveRouting, wiId] ); + // ── materialOverrides 적용: wi_process_work_item + wi_process_work_item_detail INSERT ── + // 정책: + // - 수정(editId) 진입 시 이 작업지시의 기존 wi_* 자재(material_input) 데이터를 모두 삭제 후 재구축. + // - materialOverrides가 빈 배열/없음이면 wi_* INSERT 자체를 안 함 → 마스터 process_work_item 폴백 그대로. + // - 같은 (routingDetailId) 묶음마다 wi_process_work_item 1행을 만들고, 자재들은 detail_type='material_input'으로 묶음. + if (Array.isArray(materialOverrides) && materialOverrides.length > 0) { + // 1) 편집 모드: 기존 wi_* (material_input 한정) 삭제 — 마스터 체크리스트(검사/조건 등) 보존 위해 detail_type 한정 삭제 후, 빈 work_item은 따로 cleanup + if (editId) { + await client.query( + `DELETE FROM wi_process_work_item_detail + WHERE company_code = $1 + AND detail_type = 'material_input' + AND wi_work_item_id IN ( + SELECT id FROM wi_process_work_item WHERE work_instruction_no = $2 AND company_code = $1 + )`, + [companyCode, wiNo] + ); + } + + // 2) routingDetailId 단위 묶음 — 동일 공정 자재는 한 work_item 아래로 + for (const ov of materialOverrides) { + if (!ov || !Array.isArray(ov.materials) || ov.materials.length === 0) continue; + const byRouting = new Map(); + for (const m of ov.materials) { + const rdId = String(m.routingDetailId); + if (!byRouting.has(rdId)) byRouting.set(rdId, []); + byRouting.get(rdId)!.push(m); + } + + for (const [routingDetailId, mats] of byRouting.entries()) { + // 동일 (work_instruction_no, routing_detail_id)에 자재투입 전용 work_item이 이미 있으면 재사용, 없으면 신규 INSERT. + // title은 식별용 고정 라벨 '자재투입' 사용. 사용자 화면(공정작업기준)과 충돌하지 않도록 별도 work_phase('material'). + const existing = await client.query( + `SELECT id FROM wi_process_work_item + WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3 AND work_phase = 'material' + LIMIT 1`, + [wiNo, routingDetailId, companyCode] + ); + let wiWorkItemId: string; + if (existing.rowCount && existing.rowCount > 0) { + wiWorkItemId = existing.rows[0].id; + // 이 work_item의 기존 material_input detail 정리 (편집 모드 위 1차 삭제와 중복돼도 무해) + await client.query( + `DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2 AND detail_type = 'material_input'`, + [wiWorkItemId, companyCode] + ); + } else { + const ins = await client.query( + `INSERT INTO wi_process_work_item (id, company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, + [companyCode, wiNo, routingDetailId, "material", "자재투입", "N", 0, "작업지시 등록 시 매핑된 자재투입", null, userId] + ); + wiWorkItemId = ins.rows[0].id; + } + + // 3) 자재 detail INSERT + let sortOrder = 0; + for (const m of mats) { + sortOrder++; + const qtyStr = String(Number(m.bomQty)); + const isOverride = m.isOverride === true || (m.originalBomItemId && String(m.originalBomItemId) !== String(m.bomItemId)); + const contentMemo = m.content + ? String(m.content) + : isOverride + ? `대체${m.originalBomItemId ? `:${m.originalBomItemId}` : ""}` + : "원본"; + await client.query( + `INSERT INTO wi_process_work_item_detail (id, company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, bom_item_id, bom_item_name, bom_qty, bom_unit, work_qty_auto_collect, work_qty_plc_data, defect_qty_auto_collect, defect_qty_plc_data, good_qty_auto_collect, good_qty_plc_data, loss_qty_auto_collect, loss_qty_plc_data, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36)`, + [ + companyCode, + wiWorkItemId, + "material_input", + contentMemo, + "N", + sortOrder, + m.remark || null, + null, null, null, null, null, + null, null, null, null, + null, null, + null, null, null, + null, null, + String(m.bomItemId), + m.bomItemName || null, + qtyStr, + m.bomUnit || null, + null, null, + null, null, + null, null, + null, null, + userId, + ] + ); + } + } + } + + // 4) cleanup: material_input detail이 하나도 없는 work_phase='material' work_item 제거 (이번 페이로드에서 빠진 공정) + await client.query( + `DELETE FROM wi_process_work_item + WHERE company_code = $1 AND work_instruction_no = $2 AND work_phase = 'material' + AND NOT EXISTS ( + SELECT 1 FROM wi_process_work_item_detail d + WHERE d.wi_work_item_id = wi_process_work_item.id AND d.company_code = $1 + )`, + [companyCode, wiNo] + ); + } else if (editId) { + // 편집 시 명시적으로 빈 materialOverrides가 들어오면 기존 자재 매핑 비움 (사용자가 다시 폴백으로 돌리려는 의도) + if (Array.isArray(materialOverrides)) { + await client.query( + `DELETE FROM wi_process_work_item_detail + WHERE company_code = $1 + AND detail_type = 'material_input' + AND wi_work_item_id IN ( + SELECT id FROM wi_process_work_item WHERE work_instruction_no = $2 AND company_code = $1 + )`, + [companyCode, wiNo] + ); + await client.query( + `DELETE FROM wi_process_work_item + WHERE company_code = $1 AND work_instruction_no = $2 AND work_phase = 'material' + AND NOT EXISTS ( + SELECT 1 FROM wi_process_work_item_detail d + WHERE d.wi_work_item_id = wi_process_work_item.id AND d.company_code = $1 + )`, + [companyCode, wiNo] + ); + } + } + await client.query("COMMIT"); return res.json({ success: true, data: { id: wiId, workInstructionNo: wiNo } }); } catch (txErr) { await client.query("ROLLBACK"); throw txErr; } @@ -676,8 +837,9 @@ export async function getWorkStandard(req: AuthenticatedRequest, res: Response) ); // 커스텀 작업기준이 있는지 확인 + // 주의: work_phase='material' 은 작업지시별 BOM 자재투입 매핑 전용 항목이므로 작업기준 화면 표시 대상에서 제외. const customCheck = await pool.query( - `SELECT COUNT(*) AS cnt FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`, + `SELECT COUNT(*) AS cnt FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2 AND COALESCE(work_phase, '') <> 'material'`, [wiNo, companyCode] ); const hasCustom = parseInt(customCheck.rows[0].cnt) > 0; @@ -687,12 +849,12 @@ export async function getWorkStandard(req: AuthenticatedRequest, res: Response) let workItems; if (hasCustom) { - // 커스텀 버전에서 조회 + // 커스텀 버전에서 조회 (work_phase='material'은 자재투입 매핑 전용이므로 제외) const wiResult = await pool.query( `SELECT wi.id, wi.routing_detail_id, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description, (SELECT COUNT(*) FROM wi_process_work_item_detail d WHERE d.wi_work_item_id = wi.id AND d.company_code = wi.company_code)::integer AS detail_count FROM wi_process_work_item wi - WHERE wi.work_instruction_no = $1 AND wi.routing_detail_id = $2 AND wi.company_code = $3 + WHERE wi.work_instruction_no = $1 AND wi.routing_detail_id = $2 AND wi.company_code = $3 AND COALESCE(wi.work_phase, '') <> 'material' ORDER BY wi.work_phase, wi.sort_order`, [wiNo, proc.routing_detail_id, companyCode] ); @@ -853,9 +1015,9 @@ export async function copyWorkStandard(req: AuthenticatedRequest, res: Response) try { await client.query("BEGIN"); - // 기존 커스텀 데이터 삭제 + // 기존 커스텀 데이터 삭제 (work_phase='material' 자재투입 매핑은 별도 흐름이므로 보존) const existingItems = await client.query( - `SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`, + `SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2 AND COALESCE(work_phase, '') <> 'material'`, [wiNo, companyCode] ); for (const row of existingItems.rows) { @@ -865,7 +1027,7 @@ export async function copyWorkStandard(req: AuthenticatedRequest, res: Response) ); } await client.query( - `DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`, + `DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2 AND COALESCE(work_phase, '') <> 'material'`, [wiNo, companyCode] ); @@ -936,9 +1098,9 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response) try { await client.query("BEGIN"); - // 해당 공정의 기존 커스텀 데이터 삭제 + // 해당 공정의 기존 커스텀 데이터 삭제 (work_phase='material' 자재투입 매핑은 별도 흐름이므로 보존) const existing = await client.query( - `SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3`, + `SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3 AND COALESCE(work_phase, '') <> 'material'`, [wiNo, routingDetailId, companyCode] ); for (const row of existing.rows) { @@ -948,7 +1110,7 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response) ); } await client.query( - `DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3`, + `DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3 AND COALESCE(work_phase, '') <> 'material'`, [wiNo, routingDetailId, companyCode] ); @@ -1009,8 +1171,9 @@ export async function resetWorkStandard(req: AuthenticatedRequest, res: Response try { await client.query("BEGIN"); + // 원본 초기화 — work_phase='material' 자재투입 매핑은 별도 흐름이므로 보존 const items = await client.query( - `SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`, + `SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2 AND COALESCE(work_phase, '') <> 'material'`, [wiNo, companyCode] ); for (const row of items.rows) { @@ -1020,7 +1183,7 @@ export async function resetWorkStandard(req: AuthenticatedRequest, res: Response ); } await client.query( - `DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`, + `DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2 AND COALESCE(work_phase, '') <> 'material'`, [wiNo, companyCode] ); const sync = await syncMasterChecklistFromWi(client, wiNo, null, companyCode, userId); @@ -1093,3 +1256,166 @@ export async function getBomBaseQtyMap(req: AuthenticatedRequest, res: Response) return res.status(500).json({ success: false, message: error.message }); } } + +// ─── BOM 트리 조회 (작업지시 등록/수정 모달의 자재 트리 섹션용, TASK:ERP-node-090 트리화) ─── +// GET /bom-tree/:itemCode +// itemCode → bom 헤더(현재 활성 버전) → bom_detail 전체를 parent_detail_id로 재귀 트리 구성하여 응답. +// 응답: { success, hasBom, treeRoots: [{ ...node, children }] } — 평면 호환 필드도 포함(child_item_id, child_item_name, child_item_code, quantity, unit, item_unit). +// 활성 버전이 없는 경우(미초기화 BOM): version_id IS NULL 인 detail을 폴백 조회 — 기존 process-info/bom-materials 동작과 정합. +export async function getBomTree(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { itemCode } = req.params; + if (!itemCode) { + return res.status(400).json({ success: false, message: "itemCode는 필수입니다" }); + } + + const pool = getPool(); + // 1) BOM 헤더 찾기 — bom.item_code 우선, 없으면 item_info.id 경유 + // 루트 노드(품목 자체)를 트리 0레벨로 표시하기 위해 item_info도 함께 조회 (BomTreeComponent 패턴 동일) + const bomRes = await pool.query( + `SELECT b.id AS bom_id, b.current_version_id, b.base_qty, + COALESCE(b.item_code, i.item_number) AS root_item_code, + i.id AS root_item_id, + i.item_number AS root_item_number, + i.item_name AS root_item_name, + i.unit AS root_unit + FROM bom b + LEFT JOIN item_info i ON i.id = b.item_id AND i.company_code = b.company_code + WHERE b.company_code = $1 + AND (b.item_code = $2 OR i.item_number = $2) + LIMIT 1`, + [companyCode, itemCode] + ); + if (bomRes.rowCount === 0) { + return res.json({ success: true, hasBom: false, treeRoots: [], rootItem: null }); + } + const bomRow = bomRes.rows[0]; + const bomId = bomRow.bom_id; + const versionId = bomRow.current_version_id || null; + const rootItem = { + itemId: bomRow.root_item_id || null, + itemCode: bomRow.root_item_number || bomRow.root_item_code || itemCode, + itemName: bomRow.root_item_name || "", + baseQty: bomRow.base_qty || "1", + unit: bomRow.root_unit || "", + }; + + // 2) bom_detail 전체 조회 — 활성 버전 또는 version_id IS NULL 폴백 + const detailParams: any[] = [bomId, companyCode]; + let whereVersion = "AND bd.version_id IS NULL"; + if (versionId) { + whereVersion = "AND bd.version_id = $3"; + detailParams.push(versionId); + } + const detailRes = await pool.query( + `SELECT bd.id, bd.bom_id, bd.parent_detail_id, bd.child_item_id, + bd.quantity, bd.unit AS detail_unit, bd.process_type, + bd.level, bd.seq_no, + ii.item_number AS child_item_code, ii.item_name AS child_item_name, + ii.unit AS item_unit + FROM bom_detail bd + LEFT JOIN item_info ii ON ii.id = bd.child_item_id AND ii.company_code = bd.company_code + WHERE bd.bom_id = $1 AND bd.company_code = $2 ${whereVersion} + ORDER BY bd.parent_detail_id NULLS FIRST, NULLIF(bd.seq_no, '')::int NULLS LAST, bd.id`, + detailParams + ); + + // 3) 클라이언트가 트리 빌드도 가능하지만, 백엔드에서 재귀 빌드까지 해 응답 (회사 표준 패턴) + type Node = Record & { children: Node[] }; + const nodeMap = new Map(); + const roots: Node[] = []; + for (const r of detailRes.rows) { + nodeMap.set(r.id, { ...r, children: [] }); + } + for (const r of detailRes.rows) { + const node = nodeMap.get(r.id)!; + if (r.parent_detail_id && nodeMap.has(r.parent_detail_id)) { + nodeMap.get(r.parent_detail_id)!.children.push(node); + } else { + roots.push(node); + } + } + + return res.json({ success: true, hasBom: true, treeRoots: roots, rootItem }); + } catch (error: any) { + logger.error("BOM 트리 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 작업지시 단위 자재투입 매핑 조회 (편집 모달 복원용) ─── +// GET /:wiNo/material-overrides +// 응답: { success, data: [{ routingDetailId, processCode, processName, materials: [{ bomItemId, bomItemName, bomQty, bomUnit, content, isOverride, originalBomItemId }] }] } +// work_phase='material' 인 wi_process_work_item과 그 하위 detail_type='material_input' detail만 묶어 반환. +export async function getMaterialOverrides(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { wiNo } = req.params; + if (!wiNo) return res.status(400).json({ success: false, message: "wiNo 필요" }); + + const pool = getPool(); + const rows = await pool.query( + `SELECT wi.id AS wi_work_item_id, + wi.routing_detail_id, + rd.process_code, + COALESCE(p.process_name, rd.process_code, '') AS process_name, + d.id AS detail_id, + d.bom_item_id, + d.bom_item_name, + d.bom_qty, + d.bom_unit, + d.content, + d.sort_order + FROM wi_process_work_item wi + LEFT JOIN wi_process_work_item_detail d + ON d.wi_work_item_id = wi.id AND d.company_code = wi.company_code AND d.detail_type = 'material_input' + LEFT JOIN item_routing_detail rd + ON rd.id = wi.routing_detail_id AND rd.company_code = wi.company_code + LEFT JOIN process_mng p + ON p.process_code = rd.process_code AND p.company_code = rd.company_code + WHERE wi.work_instruction_no = $1 + AND wi.company_code = $2 + AND wi.work_phase = 'material' + ORDER BY wi.routing_detail_id, d.sort_order NULLS LAST, d.id`, + [wiNo, companyCode] + ); + + // routingDetailId 단위로 그룹핑 + const byRouting = new Map(); + for (const r of rows.rows) { + const rdId = r.routing_detail_id; + if (!rdId) continue; + if (!byRouting.has(rdId)) { + byRouting.set(rdId, { + routingDetailId: rdId, + processCode: r.process_code || "", + processName: r.process_name || "", + materials: [], + }); + } + if (r.detail_id) { + const contentStr = String(r.content || ""); + const isOverride = contentStr.startsWith("대체"); + const originalBomItemId = isOverride && contentStr.includes(":") + ? contentStr.split(":", 2)[1] || null + : null; + byRouting.get(rdId).materials.push({ + detailId: r.detail_id, + bomItemId: r.bom_item_id || "", + bomItemName: r.bom_item_name || "", + bomQty: r.bom_qty || "", + bomUnit: r.bom_unit || "", + content: contentStr, + isOverride, + originalBomItemId, + }); + } + } + + return res.json({ success: true, data: Array.from(byRouting.values()) }); + } catch (error: any) { + logger.error("작업지시 자재투입 매핑 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/routes/workInstructionRoutes.ts b/backend-node/src/routes/workInstructionRoutes.ts index 1bec3b2d..749126d4 100644 --- a/backend-node/src/routes/workInstructionRoutes.ts +++ b/backend-node/src/routes/workInstructionRoutes.ts @@ -21,6 +21,9 @@ router.post("/routing-versions-bulk", ctrl.getRoutingVersionsBulk); // BOM 기준수 일괄 조회 (작업지시 등록 모달 기준수/배치수 산출용) router.post("/bom-base-qty", ctrl.getBomBaseQtyMap); +// BOM 트리 조회 (작업지시 등록/수정 모달의 자재 트리 섹션용 — TASK:ERP-node-090 트리화) +router.get("/bom-tree/:itemCode", ctrl.getBomTree); + // 라우팅 & 공정작업기준 router.get("/:wiNo/routing-versions/:itemCode", ctrl.getRoutingVersions); router.put("/:wiNo/routing", ctrl.updateRouting); @@ -29,4 +32,7 @@ router.post("/:wiNo/work-standard/copy", ctrl.copyWorkStandard); router.put("/:wiNo/work-standard/save", ctrl.saveWorkStandard); router.delete("/:wiNo/work-standard/reset", ctrl.resetWorkStandard); +// 작업지시별 자재투입(BOM 대체) 매핑 조회 — 편집 모달 복원용 +router.get("/:wiNo/material-overrides", ctrl.getMaterialOverrides); + export default router; diff --git a/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx index a1c29b44..afd438dd 100644 --- a/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx @@ -7,18 +7,60 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ 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 { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; -import { Plus, Trash2, Save, X, ChevronLeft, ChevronRight, Search, Loader2, Wrench, Pencil, CheckCircle2, ArrowRight, Check, ChevronsUpDown, ClipboardCheck, Inbox, Settings2 } from "lucide-react"; +import { + Plus, + Trash2, + Save, + X, + ChevronLeft, + ChevronRight, + ChevronDown, + Layers, + Search, + Loader2, + Wrench, + Pencil, + CheckCircle2, + ArrowRight, + Check, + ChevronsUpDown, + ClipboardCheck, + Inbox, + Settings2, +} from "lucide-react"; import { cn } from "@/lib/utils"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; // API: /work-instruction/* import { - getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions, getBomBaseQtyMap, - getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList, - getRoutingVersions, RoutingVersionData, + getWorkInstructionList, + previewWorkInstructionNo, + saveWorkInstruction, + deleteWorkInstructions, + getBomBaseQtyMap, + getWIItemSource, + getWISalesOrderSource, + getWIProductionPlanSource, + getEquipmentList, + getEmployeeList, + getRoutingVersions, + RoutingVersionData, + getWIBomSubstitutes, + getWIMaterialOverrides, + getWIBomTree, + WIBomSubstitute, + WIBomTreeNode, } from "@/lib/api/workInstruction"; +import { SmartSelect } from "@/components/common/SmartSelect"; import { WorkStandardEditModal } from "./WorkStandardEditModal"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; @@ -44,21 +86,36 @@ const GRID_COLUMNS = [ type SourceType = "production" | "order" | "item"; const STATUS_BADGE: Record = { - "일반": { label: "일반", cls: "bg-primary/10 text-primary border-primary/20" }, - "긴급": { label: "긴급", cls: "bg-destructive/10 text-destructive border-destructive/20" }, + 일반: { label: "일반", cls: "bg-primary/10 text-primary border-primary/20" }, + 긴급: { label: "긴급", cls: "bg-destructive/10 text-destructive border-destructive/20" }, }; const PROGRESS_BADGE: Record = { - "대기": { label: "대기", cls: "bg-warning/10 text-warning" }, - "진행중": { label: "진행중", cls: "bg-primary/10 text-primary" }, - "완료": { label: "완료", cls: "bg-success/10 text-success" }, + 대기: { label: "대기", cls: "bg-warning/10 text-warning" }, + 진행중: { label: "진행중", cls: "bg-primary/10 text-primary" }, + 완료: { label: "완료", cls: "bg-success/10 text-success" }, }; -interface EquipmentOption { id: string; equipment_code: string; equipment_name: string; } -interface EmployeeOption { user_id: string; user_name: string; dept_name: string | null; } +interface EquipmentOption { + id: string; + equipment_code: string; + equipment_name: string; +} +interface EmployeeOption { + user_id: string; + user_name: string; + dept_name: string | null; +} interface SelectedItem { - itemCode: string; itemName: string; spec: string; qty: number; remark: string; - sourceType: SourceType; sourceTable: string; sourceId: string | number; - routing?: string; routingOptions?: RoutingVersionData[]; + itemCode: string; + itemName: string; + spec: string; + qty: number; + remark: string; + sourceType: SourceType; + sourceTable: string; + sourceId: string | number; + routing?: string; + routingOptions?: RoutingVersionData[]; // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중선택 지원) startDate?: string; endDate?: string; @@ -74,6 +131,34 @@ interface SelectedItem { manualBatch?: number; } +// ── BOM 자재 매핑 행 (TASK:ERP-node-090, 트리화) ── +// 작업지시 등록/수정 모달에서 자재 단위로 대체품/수량/공정 매핑을 관리. +// 한 행이 곧 wi_process_work_item_detail 1행으로 저장됨. +// 트리 표시를 위해 parentDetailId/depth/hasChildren를 함께 보관 (저장 페이로드에는 영향 없음). +interface MaterialRow { + bomDetailId: string; // bom_detail.id (대체품 후보 조회 키, 트리 노드 id). 루트는 "root:{itemCode}". + parentDetailId: string | null; // 상위 bom_detail.id (트리 인덴트/접힘용). 루트는 null. + depth: number; // 트리 깊이 (0=루트(품목 자체), 1=직접 자재, 2+=하위 자재) — BomTreeComponent 동일 패턴 + hasChildren: boolean; // 자식 자재 존재 여부 (펼침 아이콘 표시용) + isRoot?: boolean; // 0레벨 루트 노드 (작업지시 대상 품목 자체) — 편집 컬럼 비활성, 저장 페이로드에서도 제외 + bomItemId: string; // 현재 사용할 자재(item_info.id) — 대체 시 substitute_item_id로 교체. 루트는 itemId. + bomItemName: string; // 표시용 자재명 + bomItemCode?: string; // 표시용 자재 코드 + originalBomItemId: string; // 원본 자재 id (대체 판정용) + originalBomItemName?: string; + originalBomItemCode?: string; + bomQty: number; // 사용량 (루트는 base_qty) + bomUnit: string; // 단위 + routingDetailId: string; // 매핑할 공정 (item_routing_detail.id). 루트는 항상 빈 문자열. + isOverride: boolean; // 대체 여부 (UI 토글 + 저장 content 메모 산정). 루트는 항상 false. + substituteOptions?: WIBomSubstitute[]; // 마스터 대체품 후보 (lazy 로드) +} + +// 품목 식별 키 (등록 모달은 itemCode + sourceTable + sourceId, 수정은 detail row가 1:1) +function makeItemKey(itemCode: string, sourceTable: string, sourceId: string | number): string { + return `${itemCode}|${sourceTable}|${String(sourceId)}`; +} + // 배치수 산출: baseQty>0 && qty>baseQty면 ceil(qty/baseQty), 아니면 1 function calcBatchCount(qty: number, baseQty: number | null | undefined): number { const b = Number(baseQty || 0); @@ -100,7 +185,12 @@ function splitQty(qty: number, baseQty: number, batchCount: number, mode: "even" // 품목의 실효 배치수 산출 // - 배치 미사용("N"): 사용자 수동 배치수(manualBatch, 기본 1 = 분할 없음). 자동분할 안 함. // - 배치 사용("Y", 기본): BOM 기준수 기반 자동 산출 (현행 유지) -function effectiveBatchCount(item: { qty?: number; baseQty?: number | null; batchUse?: "Y" | "N"; manualBatch?: number }): number { +function effectiveBatchCount(item: { + qty?: number; + baseQty?: number | null; + batchUse?: "Y" | "N"; + manualBatch?: number; +}): number { if (item.batchUse === "N") { const mb = Math.floor(Number(item.manualBatch || 1)); return mb >= 1 ? mb : 1; @@ -109,7 +199,11 @@ function effectiveBatchCount(item: { qty?: number; baseQty?: number | null; batc } // 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용) -interface MultiSelectOption { value: string; label: string; sub?: string; } +interface MultiSelectOption { + value: string; + label: string; + sub?: string; +} interface MultiSelectPopoverProps { options: MultiSelectOption[]; value: string[]; @@ -120,27 +214,35 @@ interface MultiSelectPopoverProps { emptyMessage?: string; } -function MultiSelectPopover({ options, value, onChange, placeholder = "선택", searchable = false, triggerClassName, emptyMessage = "항목이 없어요" }: MultiSelectPopoverProps) { +function MultiSelectPopover({ + options, + value, + onChange, + placeholder = "선택", + searchable = false, + triggerClassName, + emptyMessage = "항목이 없어요", +}: MultiSelectPopoverProps) { const [open, setOpen] = useState(false); const [keyword, setKeyword] = useState(""); const selectedSet = useMemo(() => new Set(value), [value]); const toggle = (val: string) => { - if (selectedSet.has(val)) onChange(value.filter(v => v !== val)); + if (selectedSet.has(val)) onChange(value.filter((v) => v !== val)); else onChange([...value, val]); }; const filtered = useMemo(() => { if (!searchable || !keyword.trim()) return options; const k = keyword.trim().toLowerCase(); - return options.filter(o => o.label.toLowerCase().includes(k) || (o.sub || "").toLowerCase().includes(k)); + return options.filter((o) => o.label.toLowerCase().includes(k) || (o.sub || "").toLowerCase().includes(k)); }, [options, keyword, searchable]); const display = useMemo(() => { if (value.length === 0) return placeholder; - if (value.length === 1) return options.find(o => o.value === value[0])?.label || value[0]; + if (value.length === 1) return options.find((o) => o.value === value[0])?.label || value[0]; if (value.length === 2) { - const labels = value.map(v => options.find(o => o.value === v)?.label || v); + const labels = value.map((v) => options.find((o) => o.value === v)?.label || v); return labels.join(", "); } return `${value.length}개 선택`; @@ -149,36 +251,63 @@ function MultiSelectPopover({ options, value, onChange, placeholder = "선택", return ( - - + {searchable && ( -
- setKeyword(e.target.value)} className="h-7 text-xs" /> +
+ setKeyword(e.target.value)} + className="h-7 text-xs" + />
)}
{filtered.length === 0 ? ( -
{emptyMessage}
- ) : filtered.map(opt => ( - - ))} +
{emptyMessage}
+ ) : ( + filtered.map((opt) => ( + + )) + )}
{value.length > 0 && ( -
- {value.length}개 선택됨 - +
+ {value.length}개 선택됨 +
)} @@ -216,7 +345,7 @@ export default function WorkInstructionPage() { const [confirmStartDate, setConfirmStartDate] = useState(""); const [confirmEndDate, setConfirmEndDate] = useState(""); const nv = (v: string) => v || "none"; - const fromNv = (v: string) => v === "none" ? "" : v; + const fromNv = (v: string) => (v === "none" ? "" : v); const [confirmEquipmentId, setConfirmEquipmentId] = useState(""); const [confirmWorkTeam, setConfirmWorkTeam] = useState(""); const [confirmWorker, setConfirmWorker] = useState(""); @@ -252,6 +381,22 @@ export default function WorkInstructionPage() { const [editRouting, setEditRouting] = useState(""); const [editRoutingOptions, setEditRoutingOptions] = useState([]); + // ─── BOM 자재 매핑 (TASK:ERP-node-090, 트리화) ─── + // 등록확인 모달 / 수정 모달 각각 분리해서 관리. key=itemKey(품목 식별 키) + // materialMap 값은 DFS 평탄화된 MaterialRow[] (depth/parentDetailId/hasChildren 보유) + // expandedNodes 는 트리 노드 단위 펼침/접힘 (bomDetailId 기준) + const [confirmMaterialMap, setConfirmMaterialMap] = useState>({}); + const [confirmExpandedItems, setConfirmExpandedItems] = useState>(new Set()); + const [confirmBomLoading, setConfirmBomLoading] = useState>(new Set()); + const [confirmBomMissing, setConfirmBomMissing] = useState>(new Set()); + const [confirmExpandedNodes, setConfirmExpandedNodes] = useState>(new Set()); + + const [editMaterialMap, setEditMaterialMap] = useState>({}); + const [editExpandedItems, setEditExpandedItems] = useState>(new Set()); + const [editBomLoading, setEditBomLoading] = useState>(new Set()); + const [editBomMissing, setEditBomMissing] = useState>(new Set()); + const [editExpandedNodes, setEditExpandedNodes] = useState>(new Set()); + // 공정작업기준 모달 상태 const [wsModalOpen, setWsModalOpen] = useState(false); const [wsModalWiNo, setWsModalWiNo] = useState(""); @@ -261,8 +406,12 @@ export default function WorkInstructionPage() { const [wsModalItemCode, setWsModalItemCode] = useState(""); useEffect(() => { - getEquipmentList().then(r => { if (r.success) setEquipmentOptions(r.data || []); }); - getEmployeeList().then(r => { if (r.success) setEmployeeOptions(r.data || []); }); + getEquipmentList().then((r) => { + if (r.success) setEquipmentOptions(r.data || []); + }); + getEmployeeList().then((r) => { + if (r.success) setEmployeeOptions(r.data || []); + }); }, []); const fetchOrders = useCallback(async () => { @@ -286,58 +435,148 @@ export default function WorkInstructionPage() { } const r = await getWorkInstructionList(params); if (r.success) setOrders(r.data || []); - } catch {} finally { setLoading(false); } + } catch { + } finally { + setLoading(false); + } }, [searchFilters]); - useEffect(() => { fetchOrders(); }, [fetchOrders]); + useEffect(() => { + fetchOrders(); + }, [fetchOrders]); // ─── 1단계 등록 ─── const openRegModal = () => { - setRegSourceType("production"); setRegSourceData([]); setRegKeyword(""); setRegCheckedIds(new Set()); - setRegPage(1); setRegTotalCount(0); setRegMergeSameItem(true); setIsRegModalOpen(true); + setRegSourceType("production"); + setRegSourceData([]); + setRegKeyword(""); + setRegCheckedIds(new Set()); + setRegPage(1); + setRegTotalCount(0); + setRegMergeSameItem(true); + setIsRegModalOpen(true); }; - const fetchRegSource = useCallback(async (pageOverride?: number) => { - if (!regSourceType) return; - setRegSourceLoading(true); - try { - const p = pageOverride ?? regPage; - const params: any = { page: p, pageSize: regPageSize }; - if (regKeyword.trim()) params.keyword = regKeyword.trim(); - let r; - switch (regSourceType) { - case "production": r = await getWIProductionPlanSource(params); break; - case "order": r = await getWISalesOrderSource(params); break; - case "item": r = await getWIItemSource(params); break; + const fetchRegSource = useCallback( + async (pageOverride?: number) => { + if (!regSourceType) return; + setRegSourceLoading(true); + try { + const p = pageOverride ?? regPage; + const params: any = { page: p, pageSize: regPageSize }; + if (regKeyword.trim()) params.keyword = regKeyword.trim(); + let r; + switch (regSourceType) { + case "production": + r = await getWIProductionPlanSource(params); + break; + case "order": + r = await getWISalesOrderSource(params); + break; + case "item": + r = await getWIItemSource(params); + break; + } + if (r?.success) { + setRegSourceData(r.data || []); + setRegTotalCount(r.totalCount || 0); + } + } catch { + } finally { + setRegSourceLoading(false); } - if (r?.success) { setRegSourceData(r.data || []); setRegTotalCount(r.totalCount || 0); } - } catch {} finally { setRegSourceLoading(false); } - }, [regSourceType, regKeyword, regPage, regPageSize]); + }, + [regSourceType, regKeyword, regPage, regPageSize], + ); - useEffect(() => { if (isRegModalOpen && regSourceType) { setRegPage(1); setRegCheckedIds(new Set()); fetchRegSource(1); } }, [regSourceType]); + // 회귀 방지(2026-05-20): 등록 모달 close 후 재오픈 시 regSourceType이 같은 값("production")으로 + // setState되면 [regSourceType]만 dependency였을 때 effect가 트리거되지 않아 초기 데이터가 안 뜨던 버그. + // isRegModalOpen 변화도 dependency에 포함시켜 첫 오픈/재오픈 모두에서 fetch가 1회 트리거되도록 보장. + // (regSourceType은 모달 외부에서 바뀔 일이 없고, 모달 내부에서 사용자가 근거를 변경하면 동일 effect가 재실행됨) + useEffect(() => { + if (isRegModalOpen && regSourceType) { + setRegPage(1); + setRegCheckedIds(new Set()); + fetchRegSource(1); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isRegModalOpen, regSourceType]); - const getRegId = (item: any) => regSourceType === "item" ? (item.item_code || item.id) : String(item.id); - const toggleRegItem = (id: string) => { setRegCheckedIds(prev => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; }); }; - const toggleRegAll = () => { if (regCheckedIds.size === regSourceData.length) setRegCheckedIds(new Set()); else setRegCheckedIds(new Set(regSourceData.map(getRegId))); }; + const getRegId = (item: any) => (regSourceType === "item" ? item.item_code || item.id : String(item.id)); + const toggleRegItem = (id: string) => { + setRegCheckedIds((prev) => { + const n = new Set(prev); + if (n.has(id)) n.delete(id); + else n.add(id); + return n; + }); + }; + const toggleRegAll = () => { + if (regCheckedIds.size === regSourceData.length) setRegCheckedIds(new Set()); + else setRegCheckedIds(new Set(regSourceData.map(getRegId))); + }; const applyRegistration = () => { - if (regCheckedIds.size === 0) { alert("품목을 선택해주세요."); return; } + if (regCheckedIds.size === 0) { + alert("품목을 선택해주세요."); + return; + } const today = new Date().toISOString().split("T")[0]; const items: SelectedItem[] = []; for (const item of regSourceData) { if (!regCheckedIds.has(getRegId(item))) continue; - const baseExtra = { startDate: today, endDate: "", equipmentIds: [], workTeams: [], workers: [] } as Pick; - if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code, ...baseExtra }); - else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id, ...baseExtra }); + const baseExtra = { startDate: today, endDate: "", equipmentIds: [], workTeams: [], workers: [] } as Pick< + SelectedItem, + "startDate" | "endDate" | "equipmentIds" | "workTeams" | "workers" + >; + if (regSourceType === "item") + items.push({ + itemCode: item.item_code, + itemName: item.item_name || "", + spec: item.spec || "", + qty: 1, + remark: "", + sourceType: "item", + sourceTable: "item_info", + sourceId: item.item_code, + ...baseExtra, + }); + else if (regSourceType === "order") + items.push({ + itemCode: item.item_code || "", + itemName: item.item_name || "", + spec: item.spec || "", + qty: Number(item.qty || 1), + remark: "", + sourceType: "order", + sourceTable: "sales_order_detail", + sourceId: item.id, + ...baseExtra, + }); else { // 생산계획: 잔량(remain_qty)이 있으면 잔량 기반으로 기본 수량 제안 (0/음수 허용 — 계획 초과 가능) - const defaultQty = item.remain_qty !== undefined && item.remain_qty !== null - ? Number(item.remain_qty) - : Number(item.plan_qty || 1); + const defaultQty = + item.remain_qty !== undefined && item.remain_qty !== null + ? Number(item.remain_qty) + : Number(item.plan_qty || 1); // 생산계획: 일정이 있으면 기본값으로 전달 const planStart = item.start_date ? String(item.start_date).split("T")[0] : today; const planEnd = item.end_date ? String(item.end_date).split("T")[0] : ""; - items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id, startDate: planStart, endDate: planEnd, equipmentIds: [], workTeams: [], workers: [] }); + items.push({ + itemCode: item.item_code || "", + itemName: item.item_name || "", + spec: "", + qty: defaultQty, + remark: "", + sourceType: "production", + sourceTable: "production_plan_mng", + sourceId: item.id, + startDate: planStart, + endDate: planEnd, + equipmentIds: [], + workTeams: [], + workers: [], + }); } } @@ -346,8 +585,11 @@ export default function WorkInstructionPage() { const merged = new Map(); for (const it of items) { const key = it.itemCode; - if (merged.has(key)) { merged.get(key)!.qty += it.qty; } - else { merged.set(key, { ...it }); } + if (merged.has(key)) { + merged.get(key)!.qty += it.qty; + } else { + merged.set(key, { ...it }); + } } setConfirmItems(Array.from(merged.values())); } else { @@ -355,65 +597,376 @@ export default function WorkInstructionPage() { } setConfirmWiNo("불러오는 중..."); - setConfirmStatus("일반"); setConfirmStartDate(new Date().toISOString().split("T")[0]); - setConfirmEndDate(""); setConfirmEquipmentId(""); setConfirmWorkTeam(""); setConfirmWorker(""); - setConfirmRouting(""); setConfirmRoutingOptions([]); - previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)")); + setConfirmStatus("일반"); + setConfirmStartDate(new Date().toISOString().split("T")[0]); + setConfirmEndDate(""); + setConfirmEquipmentId(""); + setConfirmWorkTeam(""); + setConfirmWorker(""); + setConfirmRouting(""); + setConfirmRoutingOptions([]); + // BOM 자재 매핑 state 초기화 (TASK:ERP-node-090) + setConfirmMaterialMap({}); + setConfirmExpandedItems(new Set()); + setConfirmBomMissing(new Set()); + setConfirmExpandedNodes(new Set()); + previewWorkInstructionNo() + .then((r) => { + if (r.success) setConfirmWiNo(r.instructionNo); + else setConfirmWiNo("(자동생성)"); + }) + .catch(() => setConfirmWiNo("(자동생성)")); // 품목별 라우팅 옵션 로드 - const finalItems = regMergeSameItem ? Array.from(new Map(items.map(i => [i.itemCode, i])).values()) : items; - const uniqueItemCodes = [...new Set(finalItems.map(i => i.itemCode).filter(Boolean))]; + const finalItems = regMergeSameItem ? Array.from(new Map(items.map((i) => [i.itemCode, i])).values()) : items; + const uniqueItemCodes = [...new Set(finalItems.map((i) => i.itemCode).filter(Boolean))]; for (const ic of uniqueItemCodes) { - getRoutingVersions("__new__", ic).then(r => { - if (r.success && r.data) { - setConfirmItems(prev => prev.map(it => { - if (it.itemCode !== ic) return it; - const defaultRv = r.data.find(rv => rv.is_default); - return { ...it, routingOptions: r.data, routing: defaultRv?.id || "" }; - })); - } - }).catch(() => {}); + getRoutingVersions("__new__", ic) + .then((r) => { + if (r.success && r.data) { + setConfirmItems((prev) => + prev.map((it) => { + if (it.itemCode !== ic) return it; + const defaultRv = r.data.find((rv) => rv.is_default); + return { ...it, routingOptions: r.data, routing: defaultRv?.id || "" }; + }), + ); + } + }) + .catch(() => {}); } // BOM 기준수 일괄 조회 (품목별 base_qty 매핑) if (uniqueItemCodes.length > 0) { - getBomBaseQtyMap(uniqueItemCodes).then(r => { - if (r.success && r.data) { - setConfirmItems(prev => prev.map(it => { - const bu: "Y" | "N" = r.batchUse?.[it.itemCode] === "N" ? "N" : "Y"; - return { - ...it, - baseQty: r.data[it.itemCode] ?? null, - splitMode: it.splitMode || "even", - batchUse: bu, - manualBatch: it.manualBatch ?? 1, - }; - })); - } - }).catch(() => {}); + getBomBaseQtyMap(uniqueItemCodes) + .then((r) => { + if (r.success && r.data) { + setConfirmItems((prev) => + prev.map((it) => { + const bu: "Y" | "N" = r.batchUse?.[it.itemCode] === "N" ? "N" : "Y"; + return { + ...it, + baseQty: r.data[it.itemCode] ?? null, + splitMode: it.splitMode || "even", + batchUse: bu, + manualBatch: it.manualBatch ?? 1, + }; + }), + ); + } + }) + .catch(() => {}); } - setIsRegModalOpen(false); setIsConfirmModalOpen(true); + setIsRegModalOpen(false); + setIsConfirmModalOpen(true); }; // 등록 확인 모달 — 인라인 품목 추가 const addConfirmItem = () => { - if (!confirmAddQty || Number(confirmAddQty) <= 0) { alert("수량을 입력해주세요."); return; } + if (!confirmAddQty || Number(confirmAddQty) <= 0) { + alert("수량을 입력해주세요."); + return; + } const firstItem = confirmItems[0]; - setConfirmItems(prev => [...prev, { - itemCode: firstItem?.itemCode || "", itemName: firstItem?.itemName || "", spec: firstItem?.spec || "", - qty: Number(confirmAddQty), remark: "", - sourceType: "item" as SourceType, sourceTable: "item_info", sourceId: firstItem?.itemCode || "", - startDate: firstItem?.startDate || new Date().toISOString().split("T")[0], - endDate: firstItem?.endDate || "", - equipmentIds: [], workTeams: [], workers: [], - }]); + setConfirmItems((prev) => [ + ...prev, + { + itemCode: firstItem?.itemCode || "", + itemName: firstItem?.itemName || "", + spec: firstItem?.spec || "", + qty: Number(confirmAddQty), + remark: "", + sourceType: "item" as SourceType, + sourceTable: "item_info", + sourceId: firstItem?.itemCode || "", + startDate: firstItem?.startDate || new Date().toISOString().split("T")[0], + endDate: firstItem?.endDate || "", + equipmentIds: [], + workTeams: [], + workers: [], + }, + ]); setConfirmAddQty(""); }; + // ─── BOM 자재 매핑 헬퍼 (TASK:ERP-node-090) ─── + // 등록 / 수정 모달이 같은 로직을 공유하지만 상태가 다르므로 mode로 분기. + type MaterialMode = "confirm" | "edit"; + const getMaterialMap = (mode: MaterialMode) => (mode === "confirm" ? confirmMaterialMap : editMaterialMap); + const setMaterialMap = ( + mode: MaterialMode, + updater: (prev: Record) => Record, + ) => { + if (mode === "confirm") setConfirmMaterialMap(updater); + else setEditMaterialMap(updater); + }; + const setExpanded = (mode: MaterialMode, updater: (prev: Set) => Set) => { + if (mode === "confirm") setConfirmExpandedItems(updater); + else setEditExpandedItems(updater); + }; + const setBomLoading = (mode: MaterialMode, updater: (prev: Set) => Set) => { + if (mode === "confirm") setConfirmBomLoading(updater); + else setEditBomLoading(updater); + }; + const setBomMissing = (mode: MaterialMode, updater: (prev: Set) => Set) => { + if (mode === "confirm") setConfirmBomMissing(updater); + else setEditBomMissing(updater); + }; + + // BOM 트리(다중 레벨)를 DFS 평탄화하여 MaterialRow[] 로 변환. + // 0레벨 = 루트(작업지시 대상 품목 자체, isRoot=true), 1레벨+ = 자재. + // 자식이 있는 노드는 hasChildren=true. 자식 품목 없는 더미 행은 스킵. + const flattenBomTree = useCallback( + (roots: WIBomTreeNode[], rootItem: { itemId: string | null; itemCode: string; itemName: string; baseQty: string | number | null; unit: string } | null): MaterialRow[] => { + const out: MaterialRow[] = []; + + // 루트 노드(BomTreeComponent의 _isVirtualRoot 패턴과 동일) + if (rootItem) { + const rootId = `root:${rootItem.itemCode}`; + const hasChildren = (roots || []).some((n) => !!n.child_item_id); + out.push({ + bomDetailId: rootId, + parentDetailId: null, + depth: 0, + hasChildren, + isRoot: true, + bomItemId: rootItem.itemId || rootItem.itemCode, + bomItemName: rootItem.itemName || "", + bomItemCode: rootItem.itemCode || "", + originalBomItemId: rootItem.itemId || rootItem.itemCode, + originalBomItemName: rootItem.itemName || "", + originalBomItemCode: rootItem.itemCode || "", + bomQty: Number(rootItem.baseQty || 1), + bomUnit: rootItem.unit || "", + routingDetailId: "", + isOverride: false, + }); + } + + // BOM 자재 — 루트 자식부터 depth=1 + const dfs = (nodes: WIBomTreeNode[], depth: number, parentId: string | null) => { + for (const n of nodes) { + if (!n.child_item_id) continue; // 자식 품목 없는 더미 행 스킵 + const hasChildren = Array.isArray(n.children) && n.children.some((c) => !!c.child_item_id); + out.push({ + bomDetailId: n.id, + parentDetailId: parentId, + depth, + hasChildren, + bomItemId: String(n.child_item_id), + bomItemName: n.child_item_name || "", + bomItemCode: n.child_item_code || "", + originalBomItemId: String(n.child_item_id), + originalBomItemName: n.child_item_name || "", + originalBomItemCode: n.child_item_code || "", + bomQty: Number(n.quantity || 0), + bomUnit: (n.detail_unit || n.item_unit || "") + "", + routingDetailId: "", + isOverride: false, + }); + if (hasChildren) { + dfs(n.children, depth + 1, n.id); + } + } + }; + const rootParentId = rootItem ? `root:${rootItem.itemCode}` : null; + dfs(roots || [], rootItem ? 1 : 0, rootParentId); + return out; + }, + [], + ); + + // 품목의 BOM 자재 트리 로드 (다중 레벨, 이미 있으면 스킵) + const loadBomMaterials = async (mode: MaterialMode, itemKey: string, itemCode: string) => { + if (!itemCode) return; + const cur = getMaterialMap(mode); + if (cur[itemKey]) return; // 이미 로드됨 + setBomLoading(mode, (prev) => new Set(prev).add(itemKey)); + try { + const r = await getWIBomTree(itemCode); + if (r.success && r.hasBom && Array.isArray(r.treeRoots)) { + const rows = flattenBomTree(r.treeRoots, r.rootItem || { itemId: null, itemCode, itemName: "", baseQty: 1, unit: "" }); + // 루트만 있고 자식 자재 0인 경우는 BOM 미등록과 사실상 동일 + const hasMaterial = rows.some((row) => !row.isRoot); + if (!hasMaterial) { + setBomMissing(mode, (prev) => new Set(prev).add(itemKey)); + setMaterialMap(mode, (prev) => ({ ...prev, [itemKey]: [] })); + } else { + setMaterialMap(mode, (prev) => ({ ...prev, [itemKey]: rows })); + // 초기: 루트(0레벨) + 1레벨 자재 보유 노드 펼침. 더 깊은 하위는 사용자가 클릭. + const initiallyExpanded = rows + .filter((row) => row.hasChildren && (row.depth === 0 || row.depth === 1)) + .map((row) => row.bomDetailId); + if (mode === "confirm") { + setConfirmExpandedNodes((prev) => { + const n = new Set(prev); + for (const id of initiallyExpanded) n.add(id); + return n; + }); + } else { + setEditExpandedNodes((prev) => { + const n = new Set(prev); + for (const id of initiallyExpanded) n.add(id); + return n; + }); + } + } + } else { + setBomMissing(mode, (prev) => new Set(prev).add(itemKey)); + setMaterialMap(mode, (prev) => ({ ...prev, [itemKey]: [] })); + } + } catch { + setBomMissing(mode, (prev) => new Set(prev).add(itemKey)); + setMaterialMap(mode, (prev) => ({ ...prev, [itemKey]: [] })); + } finally { + setBomLoading(mode, (prev) => { + const n = new Set(prev); + n.delete(itemKey); + return n; + }); + } + }; + + // 트리 노드 펼침/접힘 토글 + const toggleTreeNode = (mode: MaterialMode, nodeId: string) => { + const setter = mode === "confirm" ? setConfirmExpandedNodes : setEditExpandedNodes; + setter((prev) => { + const n = new Set(prev); + if (n.has(nodeId)) n.delete(nodeId); + else n.add(nodeId); + return n; + }); + }; + + // 트리 노드의 모든 조상이 펼쳐져 있는지 검사 (행 가시성 판정). + // depth=0 노드는 항상 가시(접힘 토글 대상이 아님 → 섹션 전체 펼침 시 무조건 표시). + const isNodeVisible = (rows: MaterialRow[], row: MaterialRow, expandedSet: Set): boolean => { + if (row.depth === 0) return true; + // 부모 체인을 거슬러 올라가며 모두 expandedSet에 있어야 함 + let cur: MaterialRow | undefined = row; + while (cur && cur.parentDetailId) { + const parent = rows.find((r) => r.bomDetailId === cur!.parentDetailId); + if (!parent) return true; // 부모가 없으면(데이터 불일치) 일단 표시 + if (!expandedSet.has(parent.bomDetailId)) return false; + cur = parent; + } + return true; + }; + + // 자재 행의 대체품 후보(substituteOptions) lazy 로드 — 토글 ON 시 1회 + const loadSubstitutes = async (mode: MaterialMode, itemKey: string, rowIdx: number, bomDetailId: string) => { + const rows = getMaterialMap(mode)[itemKey]; + if (!rows || !rows[rowIdx]) return; + if (rows[rowIdx].substituteOptions !== undefined) return; // 이미 시도 + try { + const r = await getWIBomSubstitutes(bomDetailId); + const opts = r.success ? r.data : []; + setMaterialMap(mode, (prev) => { + const arr = [...(prev[itemKey] || [])]; + if (!arr[rowIdx]) return prev; + arr[rowIdx] = { ...arr[rowIdx], substituteOptions: opts }; + return { ...prev, [itemKey]: arr }; + }); + } catch { + setMaterialMap(mode, (prev) => { + const arr = [...(prev[itemKey] || [])]; + if (!arr[rowIdx]) return prev; + arr[rowIdx] = { ...arr[rowIdx], substituteOptions: [] }; + return { ...prev, [itemKey]: arr }; + }); + } + }; + + // 자재 행 1개 업데이트 + const updateMaterialRow = (mode: MaterialMode, itemKey: string, rowIdx: number, patch: Partial) => { + setMaterialMap(mode, (prev) => { + const arr = [...(prev[itemKey] || [])]; + if (!arr[rowIdx]) return prev; + arr[rowIdx] = { ...arr[rowIdx], ...patch }; + return { ...prev, [itemKey]: arr }; + }); + }; + + // BOM 자재 섹션 펼침/접기 토글 + const toggleMaterialExpand = (mode: MaterialMode, itemKey: string, itemCode: string) => { + const cur = mode === "confirm" ? confirmExpandedItems : editExpandedItems; + if (cur.has(itemKey)) { + setExpanded(mode, (prev) => { + const n = new Set(prev); + n.delete(itemKey); + return n; + }); + } else { + setExpanded(mode, (prev) => new Set(prev).add(itemKey)); + void loadBomMaterials(mode, itemKey, itemCode); + } + }; + + // 저장 페이로드용 materialOverrides 변환 (등록/수정 공통) + // - 자재 row가 "사용자가 건드린" 것으로 간주되려면 isOverride OR routingDetailId 가 채워져 있어야 함. + // 하지만 백엔드 정책상 wi_*를 만들면 BOM 전체를 펼쳐넣어야 하므로, + // 한 품목이라도 사용자 입력이 있으면 그 품목의 BOM 자재 전체를 페이로드에 포함. + // - 어느 품목도 안 건드린 경우 → materialOverrides 미전송 → 마스터 폴백 (regression-safe) + const buildMaterialOverridesPayload = ( + materialMap: Record, + items: SelectedItem[], + ): { payload: Array | undefined; error?: string } => { + const overrides: Array = []; + for (const item of items) { + const key = makeItemKey(item.itemCode, item.sourceTable, item.sourceId); + const allRows = materialMap[key]; + if (!allRows || allRows.length === 0) continue; + // 루트 행(작업지시 대상 품목 자체)은 저장 대상이 아님 → 항상 제외 + const rows = allRows.filter((r) => !r.isRoot); + if (rows.length === 0) continue; + // 사용자가 한 행이라도 건드렸는가? + const touched = rows.some((r) => r.isOverride || (r.routingDetailId && r.routingDetailId.trim() !== "")); + if (!touched) continue; + // 검증: 행마다 수량/공정 필수, 수량>0 + for (const r of rows) { + if (!r.routingDetailId || r.routingDetailId.trim() === "") { + return { + payload: undefined, + error: `[${item.itemName || item.itemCode}] BOM 자재 "${r.bomItemName || r.bomItemCode || ""}"의 투입 공정을 선택해주세요.`, + }; + } + if (!Number.isFinite(r.bomQty) || r.bomQty <= 0) { + return { + payload: undefined, + error: `[${item.itemName || item.itemCode}] BOM 자재 "${r.bomItemName || r.bomItemCode || ""}"의 수량은 0보다 커야 합니다.`, + }; + } + if (!r.bomItemId) { + return { + payload: undefined, + error: `[${item.itemName || item.itemCode}] BOM 자재 선택이 비어있는 행이 있습니다.`, + }; + } + } + overrides.push({ + itemNumber: item.itemCode, + routingVersionId: item.routing || undefined, + materials: rows.map((r) => ({ + bomItemId: r.bomItemId, + bomItemName: r.bomItemName || undefined, + bomQty: r.bomQty, + bomUnit: r.bomUnit || undefined, + routingDetailId: r.routingDetailId, + isOverride: r.isOverride, + originalBomItemId: r.originalBomItemId, + })), + }); + } + return { payload: overrides.length > 0 ? overrides : undefined }; + }; + // ─── 2단계 최종 적용 ─── const finalizeRegistration = async () => { - if (confirmItems.length === 0) { alert("품목이 없습니다."); return; } + if (confirmItems.length === 0) { + alert("품목이 없습니다."); + return; + } setSaving(true); try { // 헤더 대표값: 첫 번째 품목의 첫 번째 값으로 (하위 호환 유지 — 조회 화면이 헤더값으로 표시되는 레거시 대비) @@ -424,7 +977,7 @@ export default function WorkInstructionPage() { const headerWorkTeam = first?.workTeams?.[0] || ""; const headerWorker = first?.workers?.[0] || ""; // 배치수≥2인 품목은 splitMode에 따라 N건으로 펼친다. - const expandedItems: Array = []; + const expandedItems: Array<(typeof confirmItems)[number] & { _qty: number }> = []; for (const i of confirmItems) { const qty = Number(i.qty || 0); const baseQty = Number(i.baseQty || 0); @@ -438,14 +991,30 @@ export default function WorkInstructionPage() { expandedItems.push({ ...i, _qty: qty }); } } - const payload = { + // BOM 자재 매핑 페이로드 변환 (TASK:ERP-node-090) + const ovResult = buildMaterialOverridesPayload(confirmMaterialMap, confirmItems); + if (ovResult.error) { + alert(ovResult.error); + setSaving(false); + return; + } + + const payload: any = { status: confirmStatus, - startDate: headerStart, endDate: headerEnd, - equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, + startDate: headerStart, + endDate: headerEnd, + equipmentId: headerEquipment, + workTeam: headerWorkTeam, + worker: headerWorker, routing: confirmRouting || null, - items: expandedItems.map(i => ({ - itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i._qty), remark: i.remark, - sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, + items: expandedItems.map((i) => ({ + itemNumber: i.itemCode, + itemCode: i.itemCode, + qty: String(i._qty), + remark: i.remark, + sourceTable: i.sourceTable, + sourceId: String(i.sourceId), + partCode: i.itemCode, routing: i.routing || null, // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중값 쉼표 구분) startDate: i.startDate || "", @@ -455,25 +1024,50 @@ export default function WorkInstructionPage() { workers: (i.workers || []).join(","), })), }; + if (ovResult.payload && ovResult.payload.length > 0) payload.materialOverrides = ovResult.payload; const r = await saveWorkInstruction(payload); - if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); } - else alert(r.message || "저장 실패"); - } catch (e: any) { alert(e.message || "저장 실패"); } finally { setSaving(false); } + if (r.success) { + setIsConfirmModalOpen(false); + // 모달 종료 시 자재 매핑 state 정리 + setConfirmMaterialMap({}); + setConfirmExpandedItems(new Set()); + setConfirmBomMissing(new Set()); + setConfirmExpandedNodes(new Set()); + fetchOrders(); + alert("작업지시가 등록되었습니다."); + } else alert(r.message || "저장 실패"); + } catch (e: any) { + alert(e.message || "저장 실패"); + } finally { + setSaving(false); + } }; // ─── 수정 모달 ─── const openEditModal = (order: any) => { const wiNo = order.work_instruction_no; - const relatedDetails = orders.filter(o => o.work_instruction_no === wiNo); - setEditOrder(order); setEditStatus(order.status || "일반"); - setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || ""); - setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || ""); - setEditWorker(order.worker || ""); setEditRemark(order.wi_remark || ""); + const relatedDetails = orders.filter((o) => o.work_instruction_no === wiNo); + setEditOrder(order); + setEditStatus(order.status || "일반"); + setEditStartDate(order.start_date || ""); + setEditEndDate(order.end_date || ""); + setEditEquipmentId(order.equipment_id || ""); + setEditWorkTeam(order.work_team || ""); + setEditWorker(order.worker || ""); + setEditRemark(order.wi_remark || ""); const items: SelectedItem[] = relatedDetails.map((d: any) => ({ - itemCode: d.item_number || d.part_code || "", itemName: d.item_name || "", spec: d.item_spec || "", - qty: Number(d.detail_qty || 0), remark: d.detail_remark || "", - sourceType: (d.source_table === "sales_order_detail" ? "order" : d.source_table === "production_plan_mng" ? "production" : "item") as SourceType, - sourceTable: d.source_table || "item_info", sourceId: d.source_id || "", + itemCode: d.item_number || d.part_code || "", + itemName: d.item_name || "", + spec: d.item_spec || "", + qty: Number(d.detail_qty || 0), + remark: d.detail_remark || "", + sourceType: (d.source_table === "sales_order_detail" + ? "order" + : d.source_table === "production_plan_mng" + ? "production" + : "item") as SourceType, + sourceTable: d.source_table || "item_info", + sourceId: d.source_id || "", routing: d.detail_routing_version_id || order.routing_version_id || "", routingOptions: [], // 품목별 일정/설비/작업조/작업자 (detail 값 우선, 없으면 헤더값 폴백) @@ -484,47 +1078,176 @@ export default function WorkInstructionPage() { workers: (d.detail_workers || "").split(",").filter(Boolean), })); setEditItems(items); - setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker(""); + setAddQty(""); + setAddEquipment(""); + setAddWorkTeam(""); + setAddWorker(""); setEditRouting(order.routing_version_id || ""); setEditRoutingOptions([]); - // 품목별 라우팅 옵션 로드 - const uniqueItemCodes = [...new Set(items.map(i => i.itemCode).filter(Boolean))]; - for (const ic of uniqueItemCodes) { - getRoutingVersions(wiNo, ic).then(r => { - if (r.success && r.data) { - setEditItems(prev => prev.map(it => { - if (it.itemCode !== ic) return it; - const opts = r.data; - const hasRouting = it.routing && opts.some(rv => rv.id === it.routing); - return { - ...it, - routingOptions: opts, - routing: hasRouting ? it.routing : (opts.find(rv => rv.is_default)?.id || it.routing || ""), - }; - })); + // BOM 자재 매핑 state 초기화 후 wi_* 매핑 복원 (TASK:ERP-node-090) + setEditMaterialMap({}); + setEditExpandedItems(new Set()); + setEditBomMissing(new Set()); + setEditExpandedNodes(new Set()); + getWIMaterialOverrides(wiNo) + .then(async (ovRes) => { + if (!ovRes.success || !Array.isArray(ovRes.data) || ovRes.data.length === 0) return; + // 응답을 routingDetailId → materials 배열로 받음. 품목과 매핑하려면 BOM 자재 로드가 필요. + // 전략: 응답에 등장하는 모든 자재(bomItemId)를 품목별로 매핑하기 위해 + // 먼저 각 품목의 BOM을 로드(원본 자재 → bomDetailId 매핑) 한 다음, override를 입혀준다. + const itemKeys = items.map((it) => ({ + key: makeItemKey(it.itemCode, it.sourceTable, it.sourceId), + itemCode: it.itemCode, + })); + // BOM 로드 — 중복 itemCode는 한 번만 + const seenCodes = new Set(); + for (const { itemCode } of itemKeys) { + if (!itemCode || seenCodes.has(itemCode)) continue; + seenCodes.add(itemCode); } - }).catch(() => {}); + // 동시 로드 후 매핑 (트리 베이스 + override 패치) + await Promise.all( + itemKeys.map(async ({ key, itemCode }) => { + if (!itemCode) return; + try { + const r = await getWIBomTree(itemCode); + if (!r.success || !r.hasBom || !Array.isArray(r.treeRoots)) return; + const baseRows = flattenBomTree( + r.treeRoots, + r.rootItem || { itemId: null, itemCode, itemName: "", baseQty: 1, unit: "" }, + ); + if (baseRows.length === 0) return; + // override 적용: 응답의 materials를 BOM 트리 행과 매칭 (originalBomItemId 기준). + // 트리 구조(parentDetailId/depth/hasChildren)는 보존, 사용 자재/수량/공정/대체 여부만 패치. + const patched: MaterialRow[] = baseRows.map((b) => ({ ...b })); + const extraRows: MaterialRow[] = []; + for (const routing of ovRes.data) { + for (const mat of routing.materials) { + const matchOriginalId = mat.originalBomItemId || mat.bomItemId; + const targetIdx = patched.findIndex((b) => b.originalBomItemId === matchOriginalId); + if (targetIdx >= 0) { + patched[targetIdx] = { + ...patched[targetIdx], + bomItemId: mat.bomItemId, + bomItemName: mat.bomItemName || patched[targetIdx].bomItemName, + bomQty: Number(mat.bomQty || 0), + bomUnit: mat.bomUnit || patched[targetIdx].bomUnit, + routingDetailId: routing.routingDetailId, + isOverride: mat.isOverride === true, + }; + } else { + // BOM 트리에 없는데 저장된 자재 — depth=0 별행으로 추가 (안전성) + extraRows.push({ + bomDetailId: mat.detailId || `extra-${Math.random().toString(36).slice(2)}`, + parentDetailId: null, + depth: 0, + hasChildren: false, + bomItemId: mat.bomItemId, + bomItemName: mat.bomItemName || "", + bomItemCode: "", + originalBomItemId: mat.originalBomItemId || mat.bomItemId, + originalBomItemName: "", + originalBomItemCode: "", + bomQty: Number(mat.bomQty || 0), + bomUnit: mat.bomUnit || "", + routingDetailId: routing.routingDetailId, + isOverride: mat.isOverride === true, + }); + } + } + } + const finalRows = [...patched, ...extraRows]; + setEditMaterialMap((prev) => ({ ...prev, [key]: finalRows })); + setEditExpandedItems((prev) => new Set(prev).add(key)); + // 트리 노드 초기 펼침: 최상위 자식 보유 노드 + override가 적용된 노드의 모든 조상 + const initiallyExpanded = new Set(); + for (const r of finalRows) { + if (r.depth === 0 && r.hasChildren) initiallyExpanded.add(r.bomDetailId); + } + // override가 깊이 1+에 있다면 조상도 펼쳐야 보임 + for (const r of finalRows) { + if (r.isOverride || (r.routingDetailId && r.routingDetailId.trim() !== "")) { + let cur: MaterialRow | undefined = r; + while (cur && cur.parentDetailId) { + const parent = finalRows.find((x) => x.bomDetailId === cur!.parentDetailId); + if (!parent) break; + initiallyExpanded.add(parent.bomDetailId); + cur = parent; + } + } + } + setEditExpandedNodes((prev) => { + const n = new Set(prev); + for (const id of initiallyExpanded) n.add(id); + return n; + }); + } catch { + // 무시 — 화면 자동 복원 실패 시 사용자가 수동 펼침 + } + }), + ); + }) + .catch(() => {}); + + // 품목별 라우팅 옵션 로드 + const uniqueItemCodes = [...new Set(items.map((i) => i.itemCode).filter(Boolean))]; + for (const ic of uniqueItemCodes) { + getRoutingVersions(wiNo, ic) + .then((r) => { + if (r.success && r.data) { + setEditItems((prev) => + prev.map((it) => { + if (it.itemCode !== ic) return it; + const opts = r.data; + const hasRouting = it.routing && opts.some((rv) => rv.id === it.routing); + return { + ...it, + routingOptions: opts, + routing: hasRouting ? it.routing : opts.find((rv) => rv.is_default)?.id || it.routing || "", + }; + }), + ); + } + }) + .catch(() => {}); } setIsEditModalOpen(true); }; const addEditItem = () => { - if (!addQty || Number(addQty) <= 0) { alert("수량을 입력해주세요."); return; } + if (!addQty || Number(addQty) <= 0) { + alert("수량을 입력해주세요."); + return; + } const firstItem = editItems[0]; - setEditItems(prev => [...prev, { - itemCode: editOrder?.item_number || "", itemName: editOrder?.item_name || "", spec: editOrder?.item_spec || "", - qty: Number(addQty), remark: "", sourceType: "item", sourceTable: "item_info", sourceId: editOrder?.item_number || "", - startDate: firstItem?.startDate || editStartDate || "", - endDate: firstItem?.endDate || editEndDate || "", - equipmentIds: [], workTeams: [], workers: [], - }]); + setEditItems((prev) => [ + ...prev, + { + itemCode: editOrder?.item_number || "", + itemName: editOrder?.item_name || "", + spec: editOrder?.item_spec || "", + qty: Number(addQty), + remark: "", + sourceType: "item", + sourceTable: "item_info", + sourceId: editOrder?.item_number || "", + startDate: firstItem?.startDate || editStartDate || "", + endDate: firstItem?.endDate || editEndDate || "", + equipmentIds: [], + workTeams: [], + workers: [], + }, + ]); setAddQty(""); }; const saveEdit = async () => { - if (!editOrder || editItems.length === 0) { alert("품목이 없습니다."); return; } + if (!editOrder || editItems.length === 0) { + alert("품목이 없습니다."); + return; + } setEditSaving(true); try { // 헤더 대표값: 첫 번째 품목의 첫 번째 값 사용 (하위 호환 — 등록 모달과 동일 패턴) @@ -534,15 +1257,32 @@ export default function WorkInstructionPage() { const headerEquipment = first?.equipmentIds?.[0] || editEquipmentId || ""; const headerWorkTeam = first?.workTeams?.[0] || editWorkTeam || ""; const headerWorker = first?.workers?.[0] || editWorker || ""; - const payload = { - id: editOrder.wi_id, status: editStatus, - startDate: headerStart, endDate: headerEnd, - equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, + // BOM 자재 매핑 페이로드 (TASK:ERP-node-090) + const ovResult = buildMaterialOverridesPayload(editMaterialMap, editItems); + if (ovResult.error) { + alert(ovResult.error); + setEditSaving(false); + return; + } + + const payload: any = { + id: editOrder.wi_id, + status: editStatus, + startDate: headerStart, + endDate: headerEnd, + equipmentId: headerEquipment, + workTeam: headerWorkTeam, + worker: headerWorker, remark: editRemark, routing: editRouting || null, - items: editItems.map(i => ({ - itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, - sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, + items: editItems.map((i) => ({ + itemNumber: i.itemCode, + itemCode: i.itemCode, + qty: String(i.qty), + remark: i.remark, + sourceTable: i.sourceTable, + sourceId: String(i.sourceId), + partCode: i.itemCode, routing: i.routing || null, // 품목별 일정/설비/작업조/작업자 (다중값 쉼표 구분 — 등록 모달과 동일) startDate: i.startDate || "", @@ -552,10 +1292,22 @@ export default function WorkInstructionPage() { workers: (i.workers || []).join(","), })), }; + if (ovResult.payload && ovResult.payload.length > 0) payload.materialOverrides = ovResult.payload; const r = await saveWorkInstruction(payload); - if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); } - else alert(r.message || "저장 실패"); - } catch (e: any) { alert(e.message || "저장 실패"); } finally { setEditSaving(false); } + if (r.success) { + setIsEditModalOpen(false); + setEditMaterialMap({}); + setEditExpandedItems(new Set()); + setEditBomMissing(new Set()); + setEditExpandedNodes(new Set()); + fetchOrders(); + alert("수정되었습니다."); + } else alert(r.message || "저장 실패"); + } catch (e: any) { + alert(e.message || "저장 실패"); + } finally { + setEditSaving(false); + } }; const handleDelete = async (wiId: string) => { @@ -568,11 +1320,14 @@ export default function WorkInstructionPage() { } if (!confirm("이 작업지시를 삭제하시겠습니까?")) return; const r = await deleteWorkInstructions([wiId]); - if (r.success) { fetchOrders(); } else alert(r.message || "삭제 실패"); + if (r.success) { + fetchOrders(); + } else alert(r.message || "삭제 실패"); }; const getProgress = (o: any) => { - const t = Number(o.total_qty || 0), c = Number(o.completed_qty || 0); + const t = Number(o.total_qty || 0), + c = Number(o.completed_qty || 0); return t === 0 ? 0 : Math.min(100, Math.round((c / t) * 100)); }; const getProgressLabel = (o: any) => { @@ -581,7 +1336,9 @@ export default function WorkInstructionPage() { const map: Record = { completed: "완료", in_progress: "진행중", pending: "대기" }; return map[o.progress_status] || o.progress_status; } - if (p >= 100) return "완료"; if (p > 0) return "진행중"; return "대기"; + if (p >= 100) return "완료"; + if (p > 0) return "진행중"; + return "대기"; }; const totalRegPages = Math.max(1, Math.ceil(regTotalCount / regPageSize)); @@ -592,8 +1349,17 @@ export default function WorkInstructionPage() { return `${o.work_instruction_no}-${String(seq).padStart(2, "0")}`; }; - const openWorkStandardModal = (wiNo: string, routingVersionId: string, routingName: string, itemName: string, itemCode: string) => { - if (!routingVersionId) { alert("라우팅이 선택되지 않았습니다."); return; } + const openWorkStandardModal = ( + wiNo: string, + routingVersionId: string, + routingName: string, + itemName: string, + itemCode: string, + ) => { + if (!routingVersionId) { + alert("라우팅이 선택되지 않았습니다."); + return; + } setWsModalWiNo(wiNo); setWsModalRoutingId(routingVersionId); setWsModalRoutingName(routingName); @@ -604,19 +1370,328 @@ export default function WorkInstructionPage() { const getWorkerName = (userId: string) => { if (!userId) return "-"; - const emp = employeeOptions.find(e => e.user_id === userId); + const emp = employeeOptions.find((e) => e.user_id === userId); return emp ? emp.user_name : userId; }; - const WorkerCombobox = ({ value, onChange, open, onOpenChange, className, triggerClassName }: { - value: string; onChange: (v: string) => void; open: boolean; onOpenChange: (v: boolean) => void; - className?: string; triggerClassName?: string; + // BOM 자재 매핑 섹션 (등록확인/수정 모달 공통) + const MaterialRowsSection = ({ + mode, + item, + colSpan, + }: { + mode: MaterialMode; + item: SelectedItem; + colSpan: number; + }) => { + const itemKey = makeItemKey(item.itemCode, item.sourceTable, item.sourceId); + const loading = (mode === "confirm" ? confirmBomLoading : editBomLoading).has(itemKey); + const missing = (mode === "confirm" ? confirmBomMissing : editBomMissing).has(itemKey); + const rows = getMaterialMap(mode)[itemKey] || []; + const expandedNodes = mode === "confirm" ? confirmExpandedNodes : editExpandedNodes; + + // 공정 옵션은 선택된 라우팅의 processes로부터 + const processOpts = useMemo(() => { + const rv = (item.routingOptions || []).find((r) => r.id === item.routing); + if (!rv) return [] as Array<{ code: string; label: string }>; + return rv.processes.map((p) => ({ + code: p.routing_detail_id, + label: `${p.seq_no || ""}. ${p.process_name || p.process_code}`, + })); + }, [item.routingOptions, item.routing]); + + // 트리 가시 행만 필터링 (조상이 펼쳐져 있는 것만 표시). + // 모든 행에서 인덱스를 일관되게 쓰기 위해 원본 rowIdx도 같이 보관. + const visibleRows = useMemo( + () => + rows + .map((row, rowIdx) => ({ row, rowIdx })) + .filter(({ row }) => isNodeVisible(rows, row, expandedNodes)), + [rows, expandedNodes], + ); + + return ( + + +
+
+ BOM 자재 트리 + 공정 매핑 + + {missing + ? "BOM 미등록 — 대체 등록 불가" + : loading + ? "로드 중..." + : `자재 ${rows.filter((r) => !r.isRoot).length}건 / 표시 ${visibleRows.length}행 (루트 포함)`} + + {!item.routing && !missing && ( + ※ 라우팅을 먼저 선택해주세요 (공정 옵션 없음) + )} +
+ {loading ? ( +
+ +
+ ) : missing ? ( +
+ BOM이 등록되지 않은 품목입니다. (작업지시 등록은 정상 가능 — 마스터 자재 폴백) +
+ ) : rows.length === 0 ? ( +
표시할 자재가 없습니다.
+ ) : ( +
+ {/* BomTreeComponent 시각 패턴 동일 — 아이콘(40px) + 레벨 + 품번 + 품명(인덴트) + 대체/대체자재/소요량/단위/투입공정 */} + + + + + + 레벨 + + 품번 + 품명 + + 대체 + + + 대체 자재 (대체 ON 시) + + + 소요량 + + 단위 + 투입 공정 + + + + {visibleRows.map(({ row, rowIdx }) => { + const subOpts: Array<{ code: string; label: string }> = (row.substituteOptions || []).map( + (s) => ({ + code: s.substitute_item_id, + label: `${s.substitute_item_name || s.substitute_item_number || s.substitute_item_id}${s.priority ? ` (우선순위 ${s.priority})` : ""}`, + }), + ); + const isExpanded = expandedNodes.has(row.bomDetailId); + const isRoot = !!row.isRoot; + // BomTreeComponent 패턴: depth*16+8 들여쓰기 (라인 1126) + const indentPx = row.depth * 16 + 8; + // 깊이별 좌측 컬러 바 (BomTreeComponent 라인 1107~1111) + const depthBarColor = isRoot + ? "bg-primary/70" + : row.depth === 1 + ? "bg-emerald-400" + : row.depth === 2 + ? "bg-amber-400" + : row.depth >= 3 + ? "bg-purple-400" + : "bg-muted/60"; + // 깊이별 행 배경 (BomTreeComponent 라인 1095~1105) + const depthBg = isRoot + ? "bg-primary/10 font-medium hover:bg-primary/20" + : row.depth === 1 + ? "bg-background hover:bg-muted/60" + : row.depth === 2 + ? "bg-muted/40 hover:bg-muted/50" + : row.depth >= 3 + ? "bg-muted/40 hover:bg-muted/60" + : "bg-background hover:bg-muted/60"; + + return ( + + {/* 아이콘 컬럼 — 깊이별 컬러 바 + chevron/Layers */} + +
+
+ + {row.hasChildren ? ( + + ) : ( + + )} + +
+ + + {/* 레벨 컬럼 */} + + {row.depth} + + + {/* 품번 */} + + {row.originalBomItemCode || row.bomItemCode || "-"} + + + {/* 품명 (트리 인덴트 + 굵게(루트)) */} + + + {row.originalBomItemName || (isRoot ? "(품목명 미지정)" : "-")} + + + + {/* 대체 — 루트는 비활성("—") */} + + {isRoot ? ( + + ) : ( + { + const next = !!v; + if (next) { + void loadSubstitutes(mode, itemKey, rowIdx, row.bomDetailId); + updateMaterialRow(mode, itemKey, rowIdx, { isOverride: true }); + } else { + updateMaterialRow(mode, itemKey, rowIdx, { + isOverride: false, + bomItemId: row.originalBomItemId, + bomItemName: row.originalBomItemName || row.bomItemName, + }); + } + }} + /> + )} + + + {/* 대체 자재 */} + + {isRoot ? ( + + ) : row.isOverride ? ( + subOpts.length === 0 ? ( + + 등록된 대체품 후보 없음 — 마스터 BOM에 대체품을 먼저 등록해주세요 + + ) : ( + { + if (!v) return; + const sel = (row.substituteOptions || []).find((s) => s.substitute_item_id === v); + updateMaterialRow(mode, itemKey, rowIdx, { + bomItemId: v, + bomItemName: sel?.substitute_item_name || sel?.substitute_item_number || v, + bomUnit: sel?.substitute_unit || row.bomUnit, + }); + }} + options={subOpts} + placeholder="대체 자재 선택" + /> + ) + ) : ( + 원본 사용 + )} + + + {/* 소요량 — 루트는 base_qty(읽기전용 표시) */} + + {isRoot ? ( + + {Number(row.bomQty || 0).toLocaleString()} + + ) : ( + { + const v = Number(e.target.value); + updateMaterialRow(mode, itemKey, rowIdx, { bomQty: Number.isFinite(v) ? v : 0 }); + }} + /> + )} + + + {/* 단위 */} + {row.bomUnit || "-"} + + {/* 투입 공정 — 루트는 비활성 */} + + {isRoot ? ( + + ) : processOpts.length === 0 ? ( + 라우팅 선택 필요 + ) : ( + updateMaterialRow(mode, itemKey, rowIdx, { routingDetailId: v })} + options={processOpts} + placeholder="투입 공정 선택" + /> + )} + + + ); + })} + +
+
+ )} +

+ ※ 트리 인덴트 = BOM 계층 (자식 자재는 좌측 화살표로 펼침). 자재를 한 행이라도 대체 ON 또는 공정 매핑하면 이 + 작업지시 단위로 저장됩니다. 모두 비워두면 기존 마스터 BOM 그대로 동작합니다. +

+
+
+
+ ); + }; + + const WorkerCombobox = ({ + value, + onChange, + open, + onOpenChange, + className, + triggerClassName, + }: { + value: string; + onChange: (v: string) => void; + open: boolean; + onOpenChange: (v: boolean) => void; + className?: string; + triggerClassName?: string; }) => ( - @@ -624,17 +1699,32 @@ export default function WorkInstructionPage() { - 사원을 찾을 수 없습니다 + 사원을 찾을 수 없습니다 - { onChange(""); onOpenChange(false); }} className="text-xs"> + { + onChange(""); + onOpenChange(false); + }} + className="text-xs" + > 선택 안 함 - {employeeOptions.map(emp => ( - { onChange(emp.user_id); onOpenChange(false); }} className="text-xs"> + {employeeOptions.map((emp) => ( + { + onChange(emp.user_id); + onOpenChange(false); + }} + className="text-xs" + > - {emp.user_name}{emp.dept_name ? ` (${emp.dept_name})` : ""} + {emp.user_name} + {emp.dept_name ? ` (${emp.dept_name})` : ""} ))} @@ -645,32 +1735,32 @@ export default function WorkInstructionPage() { ); return ( -
+
{/* 검색 필터 바 */} o.work_instruction_no)).size} + dataCount={new Set(orders.map((o) => o.work_instruction_no)).size} /> {/* 메인 테이블 */} -
-
+
+
{/* 패널 헤더 */} -
+
- - 작업지시 목록 - - {new Set(orders.map(o => o.work_instruction_no)).size}건 ({orders.length}행) + + 작업지시 목록 + + {new Set(orders.map((o) => o.work_instruction_no)).size}건 ({orders.length}행) - {loading && } + {loading && }
- ); - } - return -; - }}, - { key: "work_team", label: "작업조", width: "w-[80px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" }, - { key: "worker", label: "작업자", width: "w-[100px]", render: (v, row) => Number(row.detail_seq) === 1 ? getWorkerName(v) : "" }, - { key: "start_date", label: "시작일", width: "w-[100px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" }, - { key: "end_date", label: "완료일", width: "w-[100px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" }, - { key: "actions", label: "작업", width: "w-[150px]", align: "center", sortable: false, filterable: false, render: (_v, row) => { - const isFirstOfGroup = Number(row.detail_seq) === 1; - if (!isFirstOfGroup) return null; - return ( -
- - -
- ); - }}, - ] as EDataTableColumn[]} + columns={ + [ + { + key: "work_instruction_no", + label: "작업지시번호", + width: "w-[150px]", + render: (_v, row) => {getDisplayNo(row)}, + }, + { + key: "status", + label: "상태", + width: "w-[70px]", + align: "center", + render: (v) => { + const sBadge = STATUS_BADGE[v] || STATUS_BADGE["일반"]; + return ( + + {sBadge.label} + + ); + }, + }, + { + key: "progress", + label: "진행현황", + width: "w-[100px]", + align: "center", + sortable: false, + filterable: false, + render: (_v, row) => { + const isFirstOfGroup = Number(row.detail_seq) === 1; + if (!isFirstOfGroup) return ; + const pct = getProgress(row); + const pLabel = getProgressLabel(row); + const pBadge = PROGRESS_BADGE[pLabel] || PROGRESS_BADGE["대기"]; + return ( +
+ + {pBadge.label} + +
+
= 100 ? "bg-success" : pct > 0 ? "bg-primary" : "bg-muted-foreground/30", + )} + style={{ width: `${pct}%` }} + /> +
+ {pct}% +
+ ); + }, + }, + { key: "item_name", label: "품목명", render: (_v, row) => row.item_name || row.item_number || "-" }, + { key: "item_spec", label: "규격", width: "w-[100px]" }, + { key: "detail_qty", label: "수량", width: "w-[80px]", align: "right", formatNumber: true }, + { + key: "equipment_name", + label: "설비", + width: "w-[120px]", + render: (v, row) => (Number(row.detail_seq) === 1 ? v || "-" : ""), + }, + { + key: "routing", + label: "라우팅", + width: "w-[120px]", + sortable: false, + filterable: false, + render: (_v, row) => { + const isFirstOfGroup = Number(row.detail_seq) === 1; + if (!isFirstOfGroup) return ""; + if (row.routing_version_id) { + return ( + + ); + } + return -; + }, + }, + { + key: "work_team", + label: "작업조", + width: "w-[80px]", + align: "center", + render: (v, row) => (Number(row.detail_seq) === 1 ? v || "-" : ""), + }, + { + key: "worker", + label: "작업자", + width: "w-[100px]", + render: (v, row) => (Number(row.detail_seq) === 1 ? getWorkerName(v) : ""), + }, + { + key: "start_date", + label: "시작일", + width: "w-[100px]", + align: "center", + render: (v, row) => (Number(row.detail_seq) === 1 ? v || "-" : ""), + }, + { + key: "end_date", + label: "완료일", + width: "w-[100px]", + align: "center", + render: (v, row) => (Number(row.detail_seq) === 1 ? v || "-" : ""), + }, + { + key: "actions", + label: "작업", + width: "w-[150px]", + align: "center", + sortable: false, + filterable: false, + render: (_v, row) => { + const isFirstOfGroup = Number(row.detail_seq) === 1; + if (!isFirstOfGroup) return null; + return ( +
+ + +
+ ); + }, + }, + ] as EDataTableColumn[] + } data={ts.groupData(orders)} rowKey={(row) => `${row.wi_id}-${row.detail_id}`} loading={loading} @@ -752,59 +1938,138 @@ export default function WorkInstructionPage() { {/* ── 1단계: 등록 모달 ── */} - - - 작업지시 등록 - 근거를 선택하고 품목을 체크한 후 "작업지시 적용" 버튼을 눌러주세요. + + + + 작업지시 등록 + + + 근거를 선택하고 품목을 체크한 후 "작업지시 적용" 버튼을 눌러주세요. + -
- 근거 - setRegSourceType(v as SourceType)}> + + + + + 생산계획 + 수주 + 품목정보 + - {regSourceType && (<> - setRegKeyword(e.target.value)} className="h-9 w-[220px]" - onKeyDown={e => { if (e.key === "Enter") { setRegPage(1); fetchRegSource(1); } }} /> - - )} + {regSourceType && ( + <> + setRegKeyword(e.target.value)} + className="h-9 w-[220px]" + onKeyDown={(e) => { + if (e.key === "Enter") { + setRegPage(1); + fetchRegSource(1); + } + }} + /> + + + )}
-
{!regSourceType ? ( -
-
- +
+
+
-

근거를 선택해주세요

-

근거를 선택하고 검색하면 품목이 표시돼요

+

근거를 선택해주세요

+

근거를 선택하고 검색하면 품목이 표시돼요

) : regSourceLoading ? ( -
+
+ +
) : regSourceData.length === 0 ? ( -
-
- +
+
+
-

검색 결과가 없어요

-

다른 키워드로 검색해주세요

+

검색 결과가 없어요

+

다른 키워드로 검색해주세요

) : ( - 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /> - {regSourceType === "item" && <>품목코드품목명규격} - {regSourceType === "order" && <>수주번호품번품목명규격수량납기일} - {regSourceType === "production" && <>계획번호품번품목명계획수량적용수량잔량시작일완료일설비} + + 0 && regCheckedIds.size === regSourceData.length} + onCheckedChange={toggleRegAll} + /> + + {regSourceType === "item" && ( + <> + + 품목코드 + + 품목명 + + 규격 + + + )} + {regSourceType === "order" && ( + <> + + 수주번호 + + 품번 + 품목명 + 규격 + 수량 + 납기일 + + )} + {regSourceType === "production" && ( + <> + + 계획번호 + + 품번 + 품목명 + 계획수량 + 적용수량 + 잔량 + 시작일 + 완료일 + 설비 + + )} @@ -812,11 +2077,61 @@ export default function WorkInstructionPage() { const id = getRegId(item); const checked = regCheckedIds.has(id); return ( - toggleRegItem(id)}> - e.stopPropagation()}> toggleRegItem(id)} /> - {regSourceType === "item" && <>{item.item_code}{item.item_name}{item.spec || "-"}} - {regSourceType === "order" && <>{item.order_no}{item.item_code}{item.item_name}{item.spec || "-"}{Number(item.qty || 0).toLocaleString()}{item.due_date || "-"}} - {regSourceType === "production" && <>{item.plan_no}{item.item_code}{item.item_name}{Number(item.plan_qty || 0).toLocaleString()}{Number(item.applied_qty || 0).toLocaleString()}{Number(item.remain_qty ?? item.plan_qty ?? 0).toLocaleString()}{item.start_date ? String(item.start_date).split("T")[0] : "-"}{item.end_date ? String(item.end_date).split("T")[0] : "-"}{item.equipment_name || "-"}} + toggleRegItem(id)} + > + e.stopPropagation()}> + toggleRegItem(id)} /> + + {regSourceType === "item" && ( + <> + {item.item_code} + {item.item_name} + {item.spec || "-"} + + )} + {regSourceType === "order" && ( + <> + {item.order_no} + {item.item_code} + {item.item_name} + {item.spec || "-"} + + {Number(item.qty || 0).toLocaleString()} + + {item.due_date || "-"} + + )} + {regSourceType === "production" && ( + <> + {item.plan_no} + {item.item_code} + {item.item_name} + + {Number(item.plan_qty || 0).toLocaleString()} + + + {Number(item.applied_qty || 0).toLocaleString()} + + + {Number(item.remain_qty ?? item.plan_qty ?? 0).toLocaleString()} + + + {item.start_date ? String(item.start_date).split("T")[0] : "-"} + + + {item.end_date ? String(item.end_date).split("T")[0] : "-"} + + {item.equipment_name || "-"} + + )} ); })} @@ -826,335 +2141,767 @@ export default function WorkInstructionPage() { {regTotalCount > 0 && ( -
- 총 {regTotalCount}건 (선택: {regCheckedIds.size}건) +
+ + 총 {regTotalCount}건 (선택: {regCheckedIds.size}건) +
- - {regPage} / {totalRegPages} - + + + {regPage} / {totalRegPages} + +
)} - - - + + + {/* ── 2단계: 확인 모달 ── */} - + 작업지시 적용 확인 기본 정보를 입력하고 '최종 적용' 버튼을 눌러주세요.
-
-
-

작업지시 기본 정보

-

시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.

-
-
-
- +
+
+

작업지시 기본 정보

+

+ 시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요. +

+
+
+ + +
+
+ + +
+
+ + +
-
-
-
-

품목 목록

-
-
- - - 순번 - 품목코드 - 품목명 - 규격 - 수량 - 기준수 - 배치수 - 배분 - 라우팅 - 시작일 - 완료예정일 - 설비 - 작업조 - 작업자 - 비고 - - - - - {confirmItems.map((item, idx) => { - const isManualBatch = item.batchUse === "N"; - const batchCount = effectiveBatchCount(item); - const splitDisabled = batchCount <= 1 || (!isManualBatch && !item.baseQty); - return ( - - {idx + 1} - {item.itemCode} - {item.itemName || item.itemCode} - {item.spec || "-"} - setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> - - {/* 미사용 품목은 기준수 무관 → "-" 표시 */} - {!isManualBatch && item.baseQty != null && item.baseQty > 0 ? Number(item.baseQty).toLocaleString() : "-"} - - {isManualBatch ? ( - /* 배치 미사용: 배치수 수동 입력 (기본 1 = 분할 없음) */ - - { - const n = Math.max(1, Math.floor(Number(e.target.value) || 1)); - setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, manualBatch: n } : it)); - }} - title="배치 미사용 품목 — 수동 배치수 (1이면 분할 없음)" - /> - - ) : ( - 1 ? "text-primary" : "text-muted-foreground")}> - {item.baseQty != null && item.baseQty > 0 ? batchCount : "-"} - - )} - - - - - - - - setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> - - - setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> - - - ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))} - value={item.equipmentIds || []} - onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))} - placeholder="설비 선택" - searchable - emptyMessage="설비가 없어요" - /> - - - setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))} - placeholder="작업조 선택" - /> - - - ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))} - value={item.workers || []} - onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))} - placeholder="작업자 선택" - searchable - emptyMessage="사원을 찾을 수 없어요" - /> - - setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> - +
+

품목 목록

+
+
+ + + + 순번 + + + 품목코드 + + + 품목명 + + + 규격 + + + BOM/대체 + + + 수량 + + + 기준수 + + + 배치수 + + + 배분 + + + 라우팅 + + + 시작일 + + + 완료예정일 + + + 설비 + + + 작업조 + + + 작업자 + + + 비고 + + - ); - })} - -
+ + + {confirmItems.map((item, idx) => { + const isManualBatch = item.batchUse === "N"; + const batchCount = effectiveBatchCount(item); + const splitDisabled = batchCount <= 1 || (!isManualBatch && !item.baseQty); + const itemKey = makeItemKey(item.itemCode, item.sourceTable, item.sourceId); + const matExpanded = confirmExpandedItems.has(itemKey); + return ( + + + + {idx + 1} + + + {item.itemCode} + + + {item.itemName || item.itemCode} + + + {item.spec || "-"} + + + {confirmBomMissing.has(itemKey) ? ( + BOM 없음 + ) : ( + + )} + + + + setConfirmItems((prev) => + prev.map((it, i) => (i === idx ? { ...it, qty: Number(e.target.value) } : it)), + ) + } + /> + + + {/* 미사용 품목은 기준수 무관 → "-" 표시 */} + {!isManualBatch && item.baseQty != null && item.baseQty > 0 + ? Number(item.baseQty).toLocaleString() + : "-"} + + {isManualBatch ? ( + /* 배치 미사용: 배치수 수동 입력 (기본 1 = 분할 없음) */ + + { + const n = Math.max(1, Math.floor(Number(e.target.value) || 1)); + setConfirmItems((prev) => + prev.map((it, i) => (i === idx ? { ...it, manualBatch: n } : it)), + ); + }} + title="배치 미사용 품목 — 수동 배치수 (1이면 분할 없음)" + /> + + ) : ( + 1 ? "text-primary" : "text-muted-foreground", + )} + > + {item.baseQty != null && item.baseQty > 0 ? batchCount : "-"} + + )} + + + + + + + + + setConfirmItems((prev) => + prev.map((it, i) => (i === idx ? { ...it, startDate: e.target.value } : it)), + ) + } + /> + + + + setConfirmItems((prev) => + prev.map((it, i) => (i === idx ? { ...it, endDate: e.target.value } : it)), + ) + } + /> + + + ({ + value: eq.id, + label: eq.equipment_name || eq.equipment_code, + sub: eq.equipment_code, + }))} + value={item.equipmentIds || []} + onChange={(next) => + setConfirmItems((prev) => + prev.map((it, i) => (i === idx ? { ...it, equipmentIds: next } : it)), + ) + } + placeholder="설비 선택" + searchable + emptyMessage="설비가 없어요" + /> + + + + setConfirmItems((prev) => + prev.map((it, i) => (i === idx ? { ...it, workTeams: next } : it)), + ) + } + placeholder="작업조 선택" + /> + + + ({ + value: emp.user_id, + label: emp.user_name, + sub: emp.dept_name || undefined, + }))} + value={item.workers || []} + onChange={(next) => + setConfirmItems((prev) => + prev.map((it, i) => (i === idx ? { ...it, workers: next } : it)), + ) + } + placeholder="작업자 선택" + searchable + emptyMessage="사원을 찾을 수 없어요" + /> + + + + setConfirmItems((prev) => + prev.map((it, i) => (i === idx ? { ...it, remark: e.target.value } : it)), + ) + } + /> + + + + + + {matExpanded && } + + ); + })} + + +
-
- - - + + +
{/* ── 수정 모달 ── */} - { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}> - + { + if (!v && wsModalOpen) return; + setIsEditModalOpen(v); + }} + > + {`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`} 품목을 추가/삭제하고 정보를 수정해주세요.
-
-
-

기본 정보

-

시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.

-
-
-
setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" />
-
-
- - {/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */} -
-
- 작업지시 항목 - {editItems.length}건 -
-
- - - - 순번 - 품목코드 - 품목명 - 규격 - 수량 - 라우팅 - 공정작업기준 - 시작일 - 완료예정일 - 설비 - 작업조 - 작업자 - 비고 - - - - - {editItems.length === 0 ? ( - 등록된 품목이 없어요 - ) : editItems.map((item, idx) => ( - - {idx + 1} - {item.itemCode} - {item.itemName || "-"} - {item.spec || "-"} - setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> - - - - - - - - setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> - - - setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> - - - ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))} - value={item.equipmentIds || []} - onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))} - placeholder="설비 선택" - searchable - emptyMessage="설비가 없어요" - /> - - - setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))} - placeholder="작업조 선택" - /> - - - ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))} - value={item.workers || []} - onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))} - placeholder="작업자 선택" - searchable - emptyMessage="사원을 찾을 수 없어요" - /> - - setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> - - - ))} - -
-
- {editItems.length > 0 && ( -
- 총 수량 - {editItems.reduce((s, i) => s + i.qty, 0).toLocaleString()} EA +
+
+

기본 정보

+

+ 시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요. +

+
+
+ + +
+
+ + setEditRemark(e.target.value)} + className="h-9" + placeholder="비고를 입력해주세요" + /> +
- )} +
+ + {/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */} +
+
+ 작업지시 항목 + + {editItems.length}건 + +
+
+ + + + + 순번 + + + 품목코드 + + + 품목명 + + + 규격 + + + BOM/대체 + + + 수량 + + + 라우팅 + + + 공정작업기준 + + + 시작일 + + + 완료예정일 + + + 설비 + + + 작업조 + + + 작업자 + + + 비고 + + + + + + {editItems.length === 0 ? ( + + + 등록된 품목이 없어요 + + + ) : ( + editItems.map((item, idx) => { + const editItemKey = makeItemKey(item.itemCode, item.sourceTable, item.sourceId); + const editMatExpanded = editExpandedItems.has(editItemKey); + return ( + + + + {idx + 1} + + + {item.itemCode} + + + {item.itemName || "-"} + + + {item.spec || "-"} + + + {editBomMissing.has(editItemKey) ? ( + BOM 없음 + ) : ( + + )} + + + + setEditItems((prev) => + prev.map((it, i) => (i === idx ? { ...it, qty: Number(e.target.value) } : it)), + ) + } + /> + + + + + + + + + + setEditItems((prev) => + prev.map((it, i) => (i === idx ? { ...it, startDate: e.target.value } : it)), + ) + } + /> + + + + setEditItems((prev) => + prev.map((it, i) => (i === idx ? { ...it, endDate: e.target.value } : it)), + ) + } + /> + + + ({ + value: eq.id, + label: eq.equipment_name || eq.equipment_code, + sub: eq.equipment_code, + }))} + value={item.equipmentIds || []} + onChange={(next) => + setEditItems((prev) => + prev.map((it, i) => (i === idx ? { ...it, equipmentIds: next } : it)), + ) + } + placeholder="설비 선택" + searchable + emptyMessage="설비가 없어요" + /> + + + + setEditItems((prev) => + prev.map((it, i) => (i === idx ? { ...it, workTeams: next } : it)), + ) + } + placeholder="작업조 선택" + /> + + + ({ + value: emp.user_id, + label: emp.user_name, + sub: emp.dept_name || undefined, + }))} + value={item.workers || []} + onChange={(next) => + setEditItems((prev) => + prev.map((it, i) => (i === idx ? { ...it, workers: next } : it)), + ) + } + placeholder="작업자 선택" + searchable + emptyMessage="사원을 찾을 수 없어요" + /> + + + + setEditItems((prev) => + prev.map((it, i) => (i === idx ? { ...it, remark: e.target.value } : it)), + ) + } + /> + + + + + + {editMatExpanded && } + + ); + }) + )} + +
+
+ {editItems.length > 0 && ( +
+ 총 수량 + + {editItems.reduce((s, i) => s + i.qty, 0).toLocaleString()} EA + +
+ )} +
-
- - + +
diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 9f325ed7..7ca69854 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -191,6 +191,10 @@ export const ExcelUploadModal: React.FC = ({ // 3단계: 확인 const [isUploading, setIsUploading] = useState(false); + // 카테고리 컬럼별 유효값 캐시 (검증 시 조회한 값을 라벨→코드 변환에 재사용) + // key: 시스템 컬럼명, value: 해당 컬럼의 flatten 된 카테고리 값 목록 + const categoryValuesCacheRef = useRef>({}); + // 🆕 마스터-디테일 모드: 마스터 필드 입력값 const [masterFieldValues, setMasterFieldValues] = useState>({}); const [entitySearchData, setEntitySearchData] = useState>({}); @@ -711,33 +715,48 @@ export const ExcelUploadModal: React.FC = ({ // 각 카테고리 컬럼의 유효값 조회 및 엑셀 데이터 검증 const mismatches: typeof categoryMismatches = {}; + // 카테고리 유효값 캐시 초기화 (이번 검증 기준으로 갱신) + categoryValuesCacheRef.current = {}; + for (const catCol of mappedCategoryColumns) { const valuesResponse = await getCategoryValues(targetTableName, catCol.systemCol); if (!valuesResponse.success || !valuesResponse.data) continue; const validValues = flattenCategoryValues(valuesResponse.data as any[]); + // 라벨→코드 변환에 재사용하기 위해 캐시에 저장 + categoryValuesCacheRef.current[catCol.systemCol] = validValues; + const validCodes = new Set(validValues.map((v) => v.valueCode)); const validLabels = new Set(validValues.map((v) => v.valueLabel)); const validLabelsLower = new Set(validValues.map((v) => v.valueLabel.toLowerCase())); - // 엑셀 데이터에서 유효하지 않은 값 수집 + // 엑셀 데이터에서 유효하지 않은 값(토큰) 수집 + // 셀 값에 쉼표가 있으면 토큰별로 분리 검증 (쉼표 없으면 토큰 1개 → 기존 동작과 동일) const invalidMap = new Map(); allData.forEach((row, rowIdx) => { const val = row[catCol.excelCol]; if (val === undefined || val === null || String(val).trim() === "") return; - const strVal = String(val).trim(); - // 코드 매칭 → 라벨 매칭 → 소문자 라벨 매칭 - if (validCodes.has(strVal)) return; - if (validLabels.has(strVal)) return; - if (validLabelsLower.has(strVal.toLowerCase())) return; + const tokens = String(val) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); - if (!invalidMap.has(strVal)) { - invalidMap.set(strVal, []); + for (const token of tokens) { + // 코드 매칭 → 라벨 매칭 → 소문자 라벨 매칭 + if (validCodes.has(token)) continue; + if (validLabels.has(token)) continue; + if (validLabelsLower.has(token.toLowerCase())) continue; + + if (!invalidMap.has(token)) { + invalidMap.set(token, []); + } + const rows = invalidMap.get(token)!; + // 같은 토큰이 한 셀에 중복 등장해도 행 인덱스는 1번만 기록 + if (!rows.includes(rowIdx)) rows.push(rowIdx); } - invalidMap.get(strVal)!.push(rowIdx); }); if (invalidMap.size > 0) { @@ -800,17 +819,35 @@ export const ExcelUploadModal: React.FC = ({ const excelCol = systemToExcelMap.get(systemCol); if (!excelCol) continue; + // 이 컬럼에서 mismatch였던 토큰 → 대체 라벨 매핑 구축 + const tokenReplacementMap = new Map(); for (const item of items) { if (!item.replacement) continue; // 선택된 대체값의 라벨 찾기 const selectedOption = item.validOptions.find((opt) => opt.code === item.replacement); const replacementLabel = selectedOption?.label || item.replacement; + tokenReplacementMap.set(item.invalidValue, replacementLabel); + } - for (const rowIdx of item.rowIndices) { - if (newData[rowIdx]) { - newData[rowIdx][excelCol] = replacementLabel; - } - } + // mismatch 토큰이 등장한 행들만 셀 값을 토큰 단위로 재구성 + const affectedRows = new Set(); + for (const item of items) { + for (const rowIdx of item.rowIndices) affectedRows.add(rowIdx); + } + + for (const rowIdx of affectedRows) { + if (!newData[rowIdx]) continue; + const cellVal = newData[rowIdx][excelCol]; + if (cellVal === undefined || cellVal === null || String(cellVal).trim() === "") continue; + + // 셀을 토큰화 → mismatch였던 토큰만 치환, 유효 토큰은 유지 → 쉼표 join + const replacedTokens = String(cellVal) + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((token) => tokenReplacementMap.get(token) ?? token); + + newData[rowIdx][excelCol] = replacedTokens.join(","); } } @@ -1058,6 +1095,100 @@ export const ExcelUploadModal: React.FC = ({ } }; + // 매핑된 데이터의 카테고리 컬럼 값을 토큰별로 라벨→코드 변환 + // - 토큰이 이미 코드(validCodes)면 그대로 유지 (이중 변환 방지) + // - 토큰이 라벨/소문자 라벨이면 대응 코드로 치환 + // - 멀티 카테고리(쉼표 구분)는 토큰별 변환 후 쉼표 join + // - 검증 다이얼로그를 거치지 않은 경우에도 적용되도록 handleUpload 경로에 둠 + const convertCategoryLabelsToCodes = async ( + rows: Record[] + ): Promise[]> => { + try { + if (rows.length === 0) return rows; + + const targetTableName = + isMasterDetail && masterDetailRelation + ? masterDetailRelation.detailTable + : tableName; + + // 대상 테이블의 카테고리 타입 컬럼 조회 + const colResponse = await getTableColumns(targetTableName); + if (!colResponse.success || !colResponse.data?.columns) return rows; + + const categoryColumns = colResponse.data.columns.filter( + (col: any) => col.inputType === "category" + ); + if (categoryColumns.length === 0) return rows; + + // mappedData의 키는 시스템 컬럼명(rawName). 카테고리 컬럼명 집합 구성 + const categoryColNames = new Set( + categoryColumns.map((cc: any) => cc.columnName || cc.column_name) + ); + + // 컬럼별 변환 맵 구축: 라벨/소문자라벨 → 코드, 코드 → 코드(통과용) + const columnConvertMaps: Record< + string, + { codeSet: Set; labelToCode: Map } + > = {}; + + for (const colName of categoryColNames) { + // 검증 시 캐싱해둔 값 우선 사용, 없으면 직접 조회 + let validValues = categoryValuesCacheRef.current[colName]; + if (!validValues) { + const valuesResponse: any = await getCategoryValues(targetTableName, colName); + if (!valuesResponse.success || !valuesResponse.data) continue; + validValues = flattenCategoryValues(valuesResponse.data as any[]); + categoryValuesCacheRef.current[colName] = validValues; + } + + const codeSet = new Set(validValues.map((v) => v.valueCode)); + const labelToCode = new Map(); + for (const v of validValues) { + // 라벨 및 소문자 라벨 모두 등록 (소문자 라벨 매칭 대응) + if (!labelToCode.has(v.valueLabel)) labelToCode.set(v.valueLabel, v.valueCode); + const lower = v.valueLabel.toLowerCase(); + if (!labelToCode.has(lower)) labelToCode.set(lower, v.valueCode); + } + columnConvertMaps[colName] = { codeSet, labelToCode }; + } + + // 각 행의 카테고리 컬럼 값을 토큰별 변환 + return rows.map((row) => { + const newRow = { ...row }; + for (const colName of categoryColNames) { + const convertMap = columnConvertMaps[colName]; + if (!convertMap) continue; + + const val = newRow[colName]; + if (val === undefined || val === null || String(val).trim() === "") continue; + + const convertedTokens = String(val) + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((token) => { + // 이미 코드면 그대로 (이중 변환 방지) + if (convertMap.codeSet.has(token)) return token; + // 라벨 → 코드 + const byLabel = convertMap.labelToCode.get(token); + if (byLabel) return byLabel; + // 소문자 라벨 → 코드 + const byLower = convertMap.labelToCode.get(token.toLowerCase()); + if (byLower) return byLower; + // 매칭 실패 토큰은 원본 유지 (검증을 통과한 데이터이므로 정상적으론 도달 안 함) + return token; + }); + + newRow[colName] = convertedTokens.join(","); + } + return newRow; + }); + } catch (error) { + console.error("카테고리 라벨→코드 변환 실패 (원본 유지):", error); + return rows; + } + }; + // 업로드 핸들러 const handleUpload = async () => { if (!file || !tableName) { @@ -1085,7 +1216,7 @@ export const ExcelUploadModal: React.FC = ({ }); // 빈 행 필터링: 모든 값이 비어있거나 undefined/null인 행 제외 - const filteredData = mappedData.filter((row) => { + let filteredData = mappedData.filter((row) => { const values = Object.values(row); return values.some((value) => { if (value === undefined || value === null) return false; @@ -1098,6 +1229,10 @@ export const ExcelUploadModal: React.FC = ({ `📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행` ); + // 카테고리 컬럼 라벨→코드 변환 (멀티 카테고리는 토큰별 변환 후 쉼표 join) + // 검증 다이얼로그 통과 여부와 무관하게 저장 직전에 적용 + filteredData = await convertCategoryLabelsToCodes(filteredData); + // 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번 자동 감지) if (isSimpleMasterDetailMode && screenId && masterDetailRelation) { // 마스터 테이블에서 채번 컬럼 자동 감지 diff --git a/frontend/lib/api/workInstruction.ts b/frontend/lib/api/workInstruction.ts index a8588284..b0551197 100644 --- a/frontend/lib/api/workInstruction.ts +++ b/frontend/lib/api/workInstruction.ts @@ -1,6 +1,12 @@ import { apiClient } from "@/lib/api/client"; -export interface PaginatedResponse { success: boolean; data: any[]; totalCount: number; page: number; pageSize: number; } +export interface PaginatedResponse { + success: boolean; + data: any[]; + totalCount: number; + page: number; + pageSize: number; +} export async function getWorkInstructionList(params?: Record) { const res = await apiClient.get("/work-instruction/list", { params }); @@ -187,3 +193,102 @@ export async function resetWIWorkStandard(wiNo: string) { const res = await apiClient.delete(`/work-instruction/${wiNo}/work-standard/reset`); return res.data as { success: boolean }; } + +// ─── BOM 대체품 / 작업지시 단위 자재투입 매핑 (TASK:ERP-node-090) ─── + +// BOM 자재 행 (작업지시 등록 모달에서 펼침) +export interface WIBomMaterial { + id: string; // bom_detail.id + child_item_id: string | null; // item_info.id (자재 품목 id) + child_item_code?: string | null; + child_item_name?: string | null; + quantity?: string | number | null; + detail_unit?: string | null; + item_unit?: string | null; + process_type?: string | null; +} + +// 품목 코드(item_code)로 그 품목의 BOM 자재 목록을 조회 (단일 레벨) +// process-info의 /process-info/bom-materials/:itemCode 라우트 재활용 +export async function getWIBomMaterials(itemCode: string) { + const res = await apiClient.get(`/process-info/bom-materials/${encodeURIComponent(itemCode)}`); + return res.data as { success: boolean; data: WIBomMaterial[] }; +} + +// BOM 트리 조회 — 다중 레벨 자재를 재귀 트리로 반환 (TASK:ERP-node-090 트리화) +// 활성 버전(current_version_id) 기준. 미초기화 BOM은 version_id IS NULL 폴백. +export interface WIBomTreeNode { + id: string; // bom_detail.id + bom_id: string; + parent_detail_id: string | null; + child_item_id: string | null; + child_item_code?: string | null; + child_item_name?: string | null; + quantity?: string | number | null; + detail_unit?: string | null; + item_unit?: string | null; + process_type?: string | null; + level?: number | string | null; + seq_no?: string | null; + children: WIBomTreeNode[]; +} +// 루트 노드(품목 자체) 정보 — BomTreeComponent와 동일하게 0레벨로 표시 +export interface WIBomTreeRootItem { + itemId: string | null; + itemCode: string; + itemName: string; + baseQty: string | number | null; + unit: string; +} +export async function getWIBomTree(itemCode: string) { + const res = await apiClient.get(`/work-instruction/bom-tree/${encodeURIComponent(itemCode)}`); + return res.data as { + success: boolean; + hasBom: boolean; + treeRoots: WIBomTreeNode[]; + rootItem: WIBomTreeRootItem | null; + }; +} + +// bom_detail.id 단위 대체품 후보 조회 (마스터 bom_detail_substitute) +export interface WIBomSubstitute { + id: string; + bom_detail_id: string; + substitute_item_id: string; + substitute_item_name?: string | null; + substitute_item_number?: string | null; + substitute_unit?: string | null; + priority?: number | string | null; + ratio?: number | string | null; + status?: string | null; +} +export async function getWIBomSubstitutes(detailId: string) { + const res = await apiClient.get(`/bom/details/${encodeURIComponent(detailId)}/substitutes`); + return res.data as { success: boolean; data: WIBomSubstitute[] }; +} + +// 작업지시별 자재투입 매핑 조회 (편집 복원용) +export interface WIMaterialOverrideMaterial { + detailId: string; + bomItemId: string; + bomItemName: string; + bomQty: string; + bomUnit: string; + content: string; + isOverride: boolean; + originalBomItemId: string | null; +} +export interface WIMaterialOverrideRouting { + routingDetailId: string; + processCode: string; + processName: string; + materials: WIMaterialOverrideMaterial[]; +} +export async function getWIMaterialOverrides(wiNo: string) { + const res = await apiClient.get(`/work-instruction/${encodeURIComponent(wiNo)}/material-overrides`); + return res.data as { success: boolean; data: WIMaterialOverrideRouting[] }; +} + +// 품목 검색 (대체품 SmartSelect 옵션 — 마스터에 등록 안 된 임의 자재로 교체할 때 사용) +// 작업지시 등록 모달의 source/item 라우트를 재활용 (이미 keyword 검색 지원) +// → getWIItemSource(params={ keyword, page, pageSize })