diff --git a/backend-node/src/controllers/processWorkStandardController.ts b/backend-node/src/controllers/processWorkStandardController.ts index 47b8b48f..1b0560bc 100644 --- a/backend-node/src/controllers/processWorkStandardController.ts +++ b/backend-node/src/controllers/processWorkStandardController.ts @@ -1104,6 +1104,669 @@ export async function registerItemsBatch(req: AuthenticatedRequest, res: Respons } } +// ============================================================ +// 품목 단위 작업기준 복사 (TASK:ERP-029) +// ============================================================ + +/** + * 소스 품목의 작업기준 트리 전체 로드 (복사 모달용) + * process_code별 첫 routing_detail의 work_items + details 한 번에 반환 + */ +export async function getWorkStandardTreeByItem(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { itemCode } = req.params; + if (!itemCode) { + return res.status(400).json({ success: false, message: "itemCode 필수" }); + } + + const pool = getPool(); + + const routingResult = await pool.query( + `SELECT + rd.id AS routing_detail_id, + rd.process_code, + rd.seq_no, + rd.is_required, + rd.is_fixed_order, + rd.work_type, + rd.standard_time, + rd.outsource_supplier, + rv.id AS routing_version_id, + COALESCE(rv.is_default, false) AS is_default, + p.process_name + FROM item_routing_detail rd + JOIN item_routing_version rv ON rv.id = rd.routing_version_id + AND rv.company_code = rd.company_code + LEFT JOIN process_mng p ON p.process_code = rd.process_code + AND p.company_code = rd.company_code + WHERE rv.item_code = $1 AND rd.company_code = $2 + ORDER BY rv.is_default DESC NULLS LAST, rv.created_date DESC, rd.seq_no::integer`, + [itemCode, companyCode] + ); + + // process_code별 첫 routing_detail만 사용 (기본 버전 우선) + const firstByProcess: Record = {}; + for (const row of routingResult.rows) { + if (!firstByProcess[row.process_code]) { + firstByProcess[row.process_code] = row; + } + } + + const processes: any[] = []; + for (const processCode of Object.keys(firstByProcess)) { + const meta = firstByProcess[processCode]; + const wiResult = await pool.query( + `SELECT * FROM process_work_item + WHERE routing_detail_id = $1 AND company_code = $2 + ORDER BY work_phase, sort_order, created_date`, + [meta.routing_detail_id, companyCode] + ); + const workItems: any[] = []; + for (const wi of wiResult.rows) { + const dr = await pool.query( + `SELECT * FROM process_work_item_detail + WHERE work_item_id = $1 AND company_code = $2 + ORDER BY sort_order, created_date`, + [wi.id, companyCode] + ); + workItems.push({ ...wi, details: dr.rows }); + } + processes.push({ + processCode, + processName: meta.process_name || processCode, + workItems, + // 라우팅 메타 — 자동 라우팅 복제 시 그대로 보존 + routingMeta: { + seq_no: meta.seq_no, + is_required: meta.is_required, + is_fixed_order: meta.is_fixed_order, + work_type: meta.work_type, + standard_time: meta.standard_time, + outsource_supplier: meta.outsource_supplier, + }, + }); + } + + return res.json({ success: true, data: { processes } }); + } catch (error: any) { + logger.error("작업기준 트리 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 품목 단위 작업기준 복사 (편집된 트리 지원) + * 소스 품목의 모든 라우팅 작업기준 → 타겟 품목들에 복제 + * + * 매칭 키: process_code 단일 키 (소스/타겟 모두 process_code별 첫 routing_detail 사용, + * 기본 버전 is_default=true 우선) + * 충돌 전략: skip(이미 있으면 건너뜀) | overwrite(기존 삭제 후 INSERT) + * 트랜잭션: 타겟 품목당 1트랜잭션 (부분 실패 허용) + * + * Body: + * sourceItemCode, targetItemCodes, conflictStrategy + * editedTree?: [{ processCode, processName, workItems: [{ work_phase, title, ..., details: [{...}] }] }] + * - 지정 시 소스 DB 대신 이 트리를 사용 (사용자 모달 편집 결과 반영) + * - 미지정 시 소스 DB 조회 결과 그대로 복제 + */ +export async function copyByItem(req: AuthenticatedRequest, res: Response) { + const pool = getPool(); + try { + const companyCode = req.user?.companyCode; + const writer = req.user?.userId; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { sourceItemCode, targetItemCodes, conflictStrategy, editedTree, screenCode } = req.body; + + if (!sourceItemCode || !Array.isArray(targetItemCodes) || targetItemCodes.length === 0) { + return res.status(400).json({ + success: false, + message: "sourceItemCode, targetItemCodes 필수", + }); + } + if (!["skip", "overwrite"].includes(conflictStrategy)) { + return res.status(400).json({ + success: false, + message: "conflictStrategy는 skip 또는 overwrite", + }); + } + + // 1) 소스 process_code 목록 + 작업기준 트리 준비 + // editedTree 있으면 그것을 사용, 없으면 DB에서 로드 + // sourceProcessMap에는 라우팅 자동 복제 시 사용할 메타(seq_no/is_required/is_fixed_order/work_type/standard_time/outsource_supplier)도 함께 보관 + type RoutingMeta = { + process_name: string; + seq_no: string; + is_required: string | null; + is_fixed_order: string | null; + work_type: string | null; + standard_time: string | null; + outsource_supplier: string | null; + }; + const sourceProcessMap: Record = {}; + const sourceTreeByProcessCode: Record< + string, + Array<{ work_phase: string; title: string; is_required?: string; sort_order?: number; description?: string; details: any[] }> + > = {}; + + if (Array.isArray(editedTree) && editedTree.length > 0) { + let editedSeq = 0; + for (const proc of editedTree) { + if (!proc?.processCode) continue; + editedSeq++; + const meta = proc.routingMeta || {}; + sourceProcessMap[proc.processCode] = { + process_name: proc.processName || proc.processCode, + seq_no: meta.seq_no ? String(meta.seq_no) : String(editedSeq), + is_required: meta.is_required ?? null, + is_fixed_order: meta.is_fixed_order ?? null, + work_type: meta.work_type ?? null, + standard_time: meta.standard_time ?? null, + outsource_supplier: meta.outsource_supplier ?? null, + }; + sourceTreeByProcessCode[proc.processCode] = Array.isArray(proc.workItems) + ? proc.workItems.map((wi: any) => ({ + work_phase: wi.work_phase, + title: wi.title, + is_required: wi.is_required || "N", + sort_order: wi.sort_order || 0, + description: wi.description || null, + details: Array.isArray(wi.details) ? wi.details : [], + })) + : []; + } + } else { + // 소스 품목 routing_detail + 작업기준 사전 로드 (기존 경로) + const sourceRoutingResult = await pool.query( + `SELECT + rd.id AS routing_detail_id, + rd.process_code, + rd.seq_no, + rd.is_required, + rd.is_fixed_order, + rd.work_type, + rd.standard_time, + rd.outsource_supplier, + rv.id AS routing_version_id, + rv.version_name, + COALESCE(rv.is_default, false) AS is_default, + p.process_name + FROM item_routing_detail rd + JOIN item_routing_version rv ON rv.id = rd.routing_version_id + AND rv.company_code = rd.company_code + LEFT JOIN process_mng p ON p.process_code = rd.process_code + AND p.company_code = rd.company_code + WHERE rv.item_code = $1 AND rd.company_code = $2 + ORDER BY rv.is_default DESC NULLS LAST, rv.created_date DESC, rd.seq_no::integer`, + [sourceItemCode, companyCode] + ); + + if (sourceRoutingResult.rowCount === 0) { + return res.status(400).json({ + success: false, + message: "소스 품목에 라우팅이 없습니다", + }); + } + + const firstRoutingDetailByProcess: Record = {}; + for (const row of sourceRoutingResult.rows) { + if (!firstRoutingDetailByProcess[row.process_code]) { + firstRoutingDetailByProcess[row.process_code] = row; + sourceProcessMap[row.process_code] = { + process_name: row.process_name || row.process_code, + seq_no: row.seq_no ? String(row.seq_no) : "1", + is_required: row.is_required ?? null, + is_fixed_order: row.is_fixed_order ?? null, + work_type: row.work_type ?? null, + standard_time: row.standard_time ?? null, + outsource_supplier: row.outsource_supplier ?? null, + }; + } + } + + for (const processCode of Object.keys(firstRoutingDetailByProcess)) { + const sourceDetailId = firstRoutingDetailByProcess[processCode].routing_detail_id; + const wiResult = await pool.query( + `SELECT * FROM process_work_item + WHERE routing_detail_id = $1 AND company_code = $2 + ORDER BY work_phase, sort_order, created_date`, + [sourceDetailId, companyCode] + ); + const workItems: any[] = []; + for (const wi of wiResult.rows) { + const dr = await pool.query( + `SELECT * FROM process_work_item_detail + WHERE work_item_id = $1 AND company_code = $2 + ORDER BY sort_order, created_date`, + [wi.id, companyCode] + ); + workItems.push({ + work_phase: wi.work_phase, + title: wi.title, + is_required: wi.is_required, + sort_order: wi.sort_order, + description: wi.description, + details: dr.rows, + }); + } + sourceTreeByProcessCode[processCode] = workItems; + } + } + + if (Object.keys(sourceProcessMap).length === 0) { + return res.status(400).json({ + success: false, + message: "복사할 공정/작업기준이 없습니다", + }); + } + + // 2) 타겟 품목별 처리 + const results: any[] = []; + const summary = { + totalItems: 0, + totalProcesses: 0, + copied: 0, + skipped: 0, + notMatched: 0, + failed: 0, + }; + + for (const targetItemCode of targetItemCodes) { + summary.totalItems++; + const itemResult: any = { + itemCode: targetItemCode, + itemName: targetItemCode, + processes: [], + }; + + // 타겟 품목명 조회 + const itemNameResult = await pool.query( + `SELECT item_name FROM item_info WHERE item_number = $1 AND company_code = $2 LIMIT 1`, + [targetItemCode, companyCode] + ); + if (itemNameResult.rowCount && itemNameResult.rows[0]?.item_name) { + itemResult.itemName = itemNameResult.rows[0].item_name; + } + + // 타겟 품목의 routing_detail 매핑 (기본 버전 우선) + const targetRoutingResult = await pool.query( + `SELECT + rd.id AS routing_detail_id, + rd.process_code, + rd.seq_no, + rv.id AS routing_version_id, + COALESCE(rv.is_default, false) AS is_default + FROM item_routing_detail rd + JOIN item_routing_version rv ON rv.id = rd.routing_version_id + AND rv.company_code = rd.company_code + WHERE rv.item_code = $1 AND rd.company_code = $2 + ORDER BY rv.is_default DESC NULLS LAST, rv.created_date DESC, rd.seq_no::integer`, + [targetItemCode, companyCode] + ); + const targetProcessMap: Record = {}; + for (const row of targetRoutingResult.rows) { + if (!targetProcessMap[row.process_code]) { + targetProcessMap[row.process_code] = row; + } + } + // 자동 등록 결과 추적 — 같은 트랜잭션 안에서 처리되어야 함 + const autoRoutedProcessCodes = new Set(); + + // 트랜잭션 (성공 시에만 itemResult/summary에 반영) + const client = await pool.connect(); + const tempProcesses: any[] = []; + const tempCounts = { copied: 0, skipped: 0, notMatched: 0, totalProcesses: 0 }; + try { + await client.query("BEGIN"); + + // 타겟 품목의 라우팅이 전혀 없으면 → 소스 routing_detail 전체를 순서/속성 보존해서 자동 복제 + // (매칭 안 된 일부 공정만 추가하는 케이스는 process loop 안에서 처리됨) + if (Object.keys(targetProcessMap).length === 0 && Object.keys(sourceProcessMap).length > 0) { + // 기본 routing_version 가져오기 or 생성 + const defaultVerResult = await client.query( + `SELECT id FROM item_routing_version + WHERE item_code = $1 AND company_code = $2 AND COALESCE(is_default, false) = true + ORDER BY created_date DESC + LIMIT 1`, + [targetItemCode, companyCode] + ); + let defaultVersionId: string; + if (defaultVerResult.rowCount && defaultVerResult.rows[0]?.id) { + defaultVersionId = defaultVerResult.rows[0].id; + } else { + const newVerResult = await client.query( + `INSERT INTO item_routing_version + (id, company_code, item_code, version_name, description, is_default, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, true, $5) + RETURNING id`, + [companyCode, targetItemCode, "기본", "복사 시 자동 생성", writer] + ); + defaultVersionId = newVerResult.rows[0].id; + } + + // 소스 routing_detail 전체 복제 — 메타 모두 보존 + for (const pc of Object.keys(sourceProcessMap)) { + const meta = sourceProcessMap[pc]; + const newDetailResult = await client.query( + `INSERT INTO item_routing_detail + (id, company_code, routing_version_id, seq_no, process_code, + is_required, is_fixed_order, work_type, standard_time, outsource_supplier, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id`, + [ + companyCode, + defaultVersionId, + meta.seq_no || "1", + pc, + meta.is_required ?? null, + meta.is_fixed_order ?? null, + meta.work_type ?? null, + meta.standard_time ?? null, + meta.outsource_supplier ?? null, + writer, + ] + ); + targetProcessMap[pc] = { + routing_detail_id: newDetailResult.rows[0].id, + process_code: pc, + routing_version_id: defaultVersionId, + seq_no: meta.seq_no, + is_default: true, + }; + autoRoutedProcessCodes.add(pc); + } + } + + for (const processCode of Object.keys(sourceProcessMap)) { + tempCounts.totalProcesses++; + const sourceProcess = sourceProcessMap[processCode]; + const targetProcess = targetProcessMap[processCode]; + const processInfo: any = { + processCode, + processName: sourceProcess.process_name || processCode, + }; + + const sourceWorkItemsForProc = sourceTreeByProcessCode[processCode] || []; + if (sourceWorkItemsForProc.length === 0) { + processInfo.status = "skipped_exists"; + processInfo.reason = "복사할 작업기준 없음"; + tempProcesses.push(processInfo); + tempCounts.skipped++; + continue; + } + + // 매칭 안 됨 → 라우팅 자동 생성/공정 자동 추가 + let effectiveTargetProcess = targetProcess; + if (!effectiveTargetProcess) { + // 기본 라우팅 버전 확인 또는 생성 (트랜잭션 안) + const defaultVerResult = await client.query( + `SELECT id FROM item_routing_version + WHERE item_code = $1 AND company_code = $2 AND COALESCE(is_default, false) = true + ORDER BY created_date DESC + LIMIT 1`, + [targetItemCode, companyCode] + ); + let defaultVersionId: string; + if (defaultVerResult.rowCount && defaultVerResult.rows[0]?.id) { + defaultVersionId = defaultVerResult.rows[0].id; + } else { + // 라우팅 버전이 전혀 없음 → 새로 생성 + const newVerResult = await client.query( + `INSERT INTO item_routing_version + (id, company_code, item_code, version_name, description, is_default, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, true, $5) + RETURNING id`, + [companyCode, targetItemCode, "기본", "복사 시 자동 생성", writer] + ); + defaultVersionId = newVerResult.rows[0].id; + } + + // seq_no는 해당 버전의 max+1 + const maxSeqResult = await client.query( + `SELECT COALESCE(MAX(seq_no::integer), 0) + 1 AS next_seq + FROM item_routing_detail + WHERE routing_version_id = $1 AND company_code = $2`, + [defaultVersionId, companyCode] + ); + const nextSeq = maxSeqResult.rows[0]?.next_seq || 1; + + // routing_detail INSERT — 매칭 안 된 누락 공정 1개를 max+1로 추가. 메타도 보존. + const metaForProc = sourceProcessMap[processCode]; + const newDetailResult = await client.query( + `INSERT INTO item_routing_detail + (id, company_code, routing_version_id, seq_no, process_code, + is_required, is_fixed_order, work_type, standard_time, outsource_supplier, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id`, + [ + companyCode, + defaultVersionId, + String(nextSeq), + processCode, + metaForProc?.is_required ?? null, + metaForProc?.is_fixed_order ?? null, + metaForProc?.work_type ?? null, + metaForProc?.standard_time ?? null, + metaForProc?.outsource_supplier ?? null, + writer, + ] + ); + + effectiveTargetProcess = { + routing_detail_id: newDetailResult.rows[0].id, + process_code: processCode, + routing_version_id: defaultVersionId, + seq_no: String(nextSeq), + is_default: true, + }; + targetProcessMap[processCode] = effectiveTargetProcess; + autoRoutedProcessCodes.add(processCode); + } + + // 기존 work_item 존재 여부 + const existingResult = await client.query( + `SELECT COUNT(*)::int AS cnt FROM process_work_item + WHERE routing_detail_id = $1 AND company_code = $2`, + [effectiveTargetProcess.routing_detail_id, companyCode] + ); + const hasExisting = (existingResult.rows[0]?.cnt || 0) > 0; + + if (hasExisting && conflictStrategy === "skip") { + processInfo.status = "skipped_exists"; + processInfo.reason = "기존 작업기준 존재"; + tempProcesses.push(processInfo); + tempCounts.skipped++; + continue; + } + + if (hasExisting && conflictStrategy === "overwrite") { + await client.query( + `DELETE FROM process_work_item_detail + WHERE work_item_id IN ( + SELECT id FROM process_work_item + WHERE routing_detail_id = $1 AND company_code = $2 + )`, + [effectiveTargetProcess.routing_detail_id, companyCode] + ); + await client.query( + `DELETE FROM process_work_item + WHERE routing_detail_id = $1 AND company_code = $2`, + [effectiveTargetProcess.routing_detail_id, companyCode] + ); + } + + let copiedItemCount = 0; + let copiedDetailCount = 0; + + for (const sourceWi of sourceWorkItemsForProc) { + const wiInsert = await client.query( + `INSERT INTO process_work_item + (company_code, routing_detail_id, work_phase, title, is_required, sort_order, description, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id`, + [ + companyCode, + effectiveTargetProcess.routing_detail_id, + sourceWi.work_phase, + sourceWi.title, + sourceWi.is_required, + sourceWi.sort_order, + sourceWi.description, + writer, + ] + ); + const newWorkItemId = wiInsert.rows[0].id; + copiedItemCount++; + + const sourceDetails = (sourceWi as any).details || []; + for (const sd of sourceDetails) { + // selected_bom_items: 배열로 들어오면 JSON 문자열로 정규화 + const selectedBomItemsValue = Array.isArray(sd.selected_bom_items) + ? JSON.stringify(sd.selected_bom_items) + : sd.selected_bom_items ?? null; + await client.query( + `INSERT INTO process_work_item_detail + (company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + duration_minutes, input_type, lookup_target, display_fields, selected_bom_items, + 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, + inspection_count_apply, inspection_count, + material_equipment_code, material_equipment_name, + material_auto_collect, material_plc_data) + VALUES ($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, $37, + $38, $39, $40, $41, $42, $43)`, + [ + companyCode, + newWorkItemId, + sd.detail_type, + sd.content, + sd.is_required, + sd.sort_order, + sd.remark, + writer, + sd.inspection_code, + sd.inspection_method, + sd.unit, + sd.lower_limit, + sd.upper_limit, + sd.duration_minutes, + sd.input_type, + sd.lookup_target, + sd.display_fields, + selectedBomItemsValue, + sd.process_inspection_apply, + sd.equip_inspection_apply, + sd.condition_unit, + sd.condition_base_value, + sd.condition_tolerance, + sd.condition_auto_collect, + sd.condition_plc_data, + sd.bom_item_id, + sd.bom_item_name, + sd.bom_qty, + sd.bom_unit, + sd.work_qty_auto_collect, + sd.work_qty_plc_data, + sd.defect_qty_auto_collect, + sd.defect_qty_plc_data, + sd.good_qty_auto_collect, + sd.good_qty_plc_data, + sd.loss_qty_auto_collect, + sd.loss_qty_plc_data, + sd.inspection_count_apply, + sd.inspection_count, + sd.material_equipment_code, + sd.material_equipment_name, + sd.material_auto_collect, + sd.material_plc_data, + ] + ); + copiedDetailCount++; + } + } + + processInfo.status = "copied"; + if (autoRoutedProcessCodes.has(processCode)) { + processInfo.reason = "라우팅 자동 생성"; + } + processInfo.copiedItemCount = copiedItemCount; + processInfo.copiedDetailCount = copiedDetailCount; + tempProcesses.push(processInfo); + tempCounts.copied++; + } + + // 자동 라우팅이 1건이라도 생성됐고 등록 모드(screenCode)면, 좌측 트리에 노출되도록 item_routing_registered 등록 + if (screenCode && autoRoutedProcessCodes.size > 0) { + await client.query( + `INSERT INTO item_routing_registered (screen_code, item_id, item_code, company_code, writer) + SELECT $1::text, id, $2::text, company_code, $3::text + FROM item_info + WHERE item_number = $2::text AND company_code = $4::text + ON CONFLICT (screen_code, item_id, company_code) DO NOTHING`, + [screenCode, targetItemCode, writer, companyCode] + ); + } + + await client.query("COMMIT"); + + // 트랜잭션 성공 — 실제 결과에 반영 + itemResult.processes.push(...tempProcesses); + summary.totalProcesses += tempCounts.totalProcesses; + summary.copied += tempCounts.copied; + summary.skipped += tempCounts.skipped; + summary.notMatched += tempCounts.notMatched; + } catch (err: any) { + try { + await client.query("ROLLBACK"); + } catch { /* ignore */ } + // 트랜잭션 실패 — 모든 공정을 failed로 표기 (적용된 것 없음) + for (const pc of Object.keys(sourceProcessMap)) { + itemResult.processes.push({ + processCode: pc, + processName: sourceProcessMap[pc].process_name || pc, + status: "failed", + reason: err.message || "트랜잭션 실패", + }); + summary.totalProcesses++; + summary.failed++; + } + } finally { + client.release(); + } + + results.push(itemResult); + } + + logger.info("작업기준 품목단위 복사", { + companyCode, + sourceItemCode, + targetCount: targetItemCodes.length, + ...summary, + }); + + return res.json({ success: true, results, summary }); + } 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/processWorkStandardRoutes.ts b/backend-node/src/routes/processWorkStandardRoutes.ts index c613d55f..2e8f89cc 100644 --- a/backend-node/src/routes/processWorkStandardRoutes.ts +++ b/backend-node/src/routes/processWorkStandardRoutes.ts @@ -33,6 +33,10 @@ router.delete("/work-item-details/:id", ctrl.deleteWorkItemDetail); // 전체 저장 (일괄) router.put("/save-all", ctrl.saveAll); +// 품목 단위 작업기준 복사 (TASK:ERP-029) +router.get("/items/:itemCode/work-standard-tree", ctrl.getWorkStandardTreeByItem); +router.post("/copy-by-item", ctrl.copyByItem); + // 등록 품목 관리 (화면별 품목 목록) router.get("/registered-items/:screenCode", ctrl.getRegisteredItems); router.post("/registered-items", ctrl.registerItem); diff --git a/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent.tsx b/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent.tsx index 98b94623..b96f706c 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent.tsx +++ b/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState, useMemo, useCallback } from "react"; -import { Save, Loader2, ClipboardCheck } from "lucide-react"; +import { Save, Loader2, ClipboardCheck, Copy } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; @@ -11,6 +11,7 @@ import { useProcessWorkStandard } from "./hooks/useProcessWorkStandard"; import { ItemProcessSelector } from "./components/ItemProcessSelector"; import { WorkPhaseSection } from "./components/WorkPhaseSection"; import { WorkItemAddModal } from "./components/WorkItemAddModal"; +import { CopyWorkStandardModal } from "./components/CopyWorkStandardModal"; interface ProcessWorkStandardComponentProps { config?: Partial; @@ -40,14 +41,10 @@ export function ProcessWorkStandardComponent({ ...defaultConfig, ...resolvedConfig, dataSource: { ...defaultConfig.dataSource, ...resolvedConfig?.dataSource }, - phases: resolvedConfig?.phases?.length - ? resolvedConfig.phases - : defaultConfig.phases, - detailTypes: resolvedConfig?.detailTypes?.length - ? resolvedConfig.detailTypes - : defaultConfig.detailTypes, + phases: resolvedConfig?.phases?.length ? resolvedConfig.phases : defaultConfig.phases, + detailTypes: resolvedConfig?.detailTypes?.length ? resolvedConfig.detailTypes : defaultConfig.detailTypes, }), - [resolvedConfig] + [resolvedConfig], ); const { @@ -71,12 +68,16 @@ export function ProcessWorkStandardComponent({ deleteDetail, reorderWorkItems, reorderDetails, + copyByItem, + fetchSourceTree, + fetchAllItemsForCopy, } = useProcessWorkStandard(config); // 모달 상태 const [modalOpen, setModalOpen] = useState(false); const [modalPhaseKey, setModalPhaseKey] = useState(""); const [editingItem, setEditingItem] = useState(null); + const [copyModalOpen, setCopyModalOpen] = useState(false); // phase별 작업 항목 그룹핑 const workItemsByPhase = useMemo(() => { @@ -87,10 +88,7 @@ export function ProcessWorkStandardComponent({ return map; }, [workItems, config.phases]); - const sortedPhases = useMemo( - () => [...config.phases].sort((a, b) => a.sortOrder - b.sortOrder), - [config.phases] - ); + const sortedPhases = useMemo(() => [...config.phases].sort((a, b) => a.sortOrder - b.sortOrder), [config.phases]); const handleAddWorkItem = useCallback((phaseKey: string) => { setModalPhaseKey(phaseKey); @@ -116,14 +114,14 @@ export function ProcessWorkStandardComponent({ await createWorkItem(data); } }, - [editingItem, createWorkItem, updateWorkItem] + [editingItem, createWorkItem, updateWorkItem], ); const handleSelectWorkItem = useCallback( (workItemId: string, phaseKey: string) => { fetchWorkItemDetails(workItemId, phaseKey); }, - [fetchWorkItemDetails] + [fetchWorkItemDetails], ); const handleInit = useCallback(() => { @@ -134,22 +132,18 @@ export function ProcessWorkStandardComponent({ if (isPreview) { return ( -
+
- -

- 공정 작업기준 -

-

- {sortedPhases.map((p) => p.label).join(" / ")} -

+ +

공정 작업기준

+

{sortedPhases.map((p) => p.label).join(" / ")}

); } return ( -
+
{/* 메인 콘텐츠 */}
{/* 좌측 패널 */} @@ -176,26 +170,29 @@ export function ProcessWorkStandardComponent({

{selection.itemName} - {selection.processName}

-
+
품목: {selection.itemCode} 공정: {selection.processName} 버전: {selection.routingVersionName}
{!config.readonly && ( - +
+ + +
)}
@@ -227,13 +224,10 @@ export function ProcessWorkStandardComponent({ ) : (
- -

- 좌측에서 품목과 공정을 선택하세요 -

-

- 품목을 펼쳐 라우팅별 공정을 선택하면 작업기준을 관리할 수 - 있습니다 + +

좌측에서 품목과 공정을 선택하세요

+

+ 품목을 펼쳐 라우팅별 공정을 선택하면 작업기준을 관리할 수 있습니다

)} @@ -249,13 +243,30 @@ export function ProcessWorkStandardComponent({ }} onSave={handleModalSave} phaseKey={modalPhaseKey} - phaseLabel={ - config.phases.find((p) => p.key === modalPhaseKey)?.label || "" - } + phaseLabel={config.phases.find((p) => p.key === modalPhaseKey)?.label || ""} detailTypes={config.detailTypes} editItem={editingItem} selectedItemCode={selection.itemCode || undefined} /> + + {/* 작업기준 품목 단위 복사 모달 (TASK:ERP-029) */} + {selection.itemCode && ( + { + setCopyModalOpen(false); + // 자동 등록된 라우팅이 좌측 트리에 즉시 반영되도록 재로드 + loadItems(); + }} + sourceItemCode={selection.itemCode} + sourceItemName={selection.itemName || selection.itemCode} + phases={config.phases} + detailTypes={config.detailTypes} + fetchSourceTree={fetchSourceTree} + fetchAllItemsForCopy={fetchAllItemsForCopy} + onCopy={copyByItem} + /> + )}
); } diff --git a/frontend/lib/registry/components/v2-process-work-standard/components/CopyWorkStandardModal.tsx b/frontend/lib/registry/components/v2-process-work-standard/components/CopyWorkStandardModal.tsx new file mode 100644 index 00000000..77f23361 --- /dev/null +++ b/frontend/lib/registry/components/v2-process-work-standard/components/CopyWorkStandardModal.tsx @@ -0,0 +1,946 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from "react"; +import { + Loader2, + Search, + AlertTriangle, + CheckCircle2, + XCircle, + MinusCircle, + Plus, + Trash2, + Pencil, + ChevronDown, + ChevronRight, + RefreshCw, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { ItemData, WorkItem, WorkItemDetail, WorkPhaseDefinition, DetailTypeDefinition } from "../types"; +import { WorkItemAddModal } from "./WorkItemAddModal"; +import { DetailFormModal } from "./DetailFormModal"; +import { getDetailContentSummary } from "./WorkItemDetailList"; + +type ConflictStrategy = "skip" | "overwrite"; + +// 모달 내 메모리 트리 노드 — 백엔드 응답 형태와 동일 + 임시 ID로 식별 +interface EditableWorkItem extends Partial { + id: string; // 임시 또는 원본 ID + work_phase: string; + title: string; + is_required: string; + sort_order: number; + description?: string; + details: EditableDetail[]; +} + +interface EditableDetail extends Partial { + id: string; + detail_type?: string; + content: string; + is_required: string; + sort_order: number; +} + +interface RoutingMeta { + seq_no?: string; + is_required?: string | null; + is_fixed_order?: string | null; + work_type?: string | null; + standard_time?: string | null; + outsource_supplier?: string | null; +} + +interface EditableProcess { + processCode: string; + processName: string; + routingMeta?: RoutingMeta; + workItems: EditableWorkItem[]; +} + +interface ProcessResult { + processCode: string; + processName: string; + status: "copied" | "skipped_exists" | "not_matched" | "failed"; + reason?: string; + copiedItemCount?: number; + copiedDetailCount?: number; +} + +interface ItemResult { + itemCode: string; + itemName: string; + processes: ProcessResult[]; +} + +interface CopyResponse { + success: boolean; + results: ItemResult[]; + summary: { + totalItems: number; + totalProcesses: number; + copied: number; + skipped: number; + notMatched: number; + failed: number; + }; +} + +interface CopyWorkStandardModalProps { + open: boolean; + onClose: () => void; + sourceItemCode: string; + sourceItemName: string; + phases: WorkPhaseDefinition[]; + detailTypes: DetailTypeDefinition[]; + fetchSourceTree: (itemCode: string) => Promise; + fetchAllItemsForCopy: (search?: string) => Promise; + onCopy: ( + sourceItemCode: string, + targetItemCodes: string[], + conflictStrategy: ConflictStrategy, + editedTree?: unknown, + ) => Promise; +} + +const tempId = () => + typeof crypto !== "undefined" && (crypto as any).randomUUID + ? (crypto as any).randomUUID() + : `tmp_${Math.random().toString(36).slice(2)}_${Date.now()}`; + +export function CopyWorkStandardModal({ + open, + onClose, + sourceItemCode, + sourceItemName, + phases, + detailTypes, + fetchSourceTree, + fetchAllItemsForCopy, + onCopy, +}: CopyWorkStandardModalProps) { + // 좌측 — 타겟 선택 / 옵션 + const [search, setSearch] = useState(""); + const [selected, setSelected] = useState>(new Set()); + const [strategy, setStrategy] = useState("overwrite"); + const [items, setItems] = useState([]); + const [itemsLoading, setItemsLoading] = useState(false); + + // 우측 — 메모리 트리 편집 + const [tree, setTree] = useState([]); + const [treeLoading, setTreeLoading] = useState(false); + const [activeProcessCode, setActiveProcessCode] = useState(null); + const [expandedWorkItemIds, setExpandedWorkItemIds] = useState>(new Set()); + + // 실행/결과 + const [running, setRunning] = useState(false); + const [result, setResult] = useState(null); + + // 작업항목 모달 + const [wiModalOpen, setWiModalOpen] = useState(false); + const [wiModalPhase, setWiModalPhase] = useState(""); + const [wiModalEditing, setWiModalEditing] = useState(null); + + // 상세 모달 + const [detailModalOpen, setDetailModalOpen] = useState(false); + const [detailModalMode, setDetailModalMode] = useState<"add" | "edit">("add"); + const [detailModalTargetWi, setDetailModalTargetWi] = useState(null); + const [detailModalEditing, setDetailModalEditing] = useState(null); + + const sortedPhases = useMemo(() => [...phases].sort((a, b) => a.sortOrder - b.sortOrder), [phases]); + + // 모달 열릴 때마다 상태 초기화 + 소스 트리 로드 + 전체 품목 로드 + useEffect(() => { + if (!open) return; + setSearch(""); + setSelected(new Set()); + setStrategy("overwrite"); + setRunning(false); + setResult(null); + setExpandedWorkItemIds(new Set()); + + // 전체 품목 로드 (라우팅 유무 무관) + setItemsLoading(true); + fetchAllItemsForCopy() + .then((list) => setItems(list)) + .catch((err) => { + console.error("타겟 품목 로드 실패", err); + setItems([]); + }) + .finally(() => setItemsLoading(false)); + + setTreeLoading(true); + fetchSourceTree(sourceItemCode) + .then((processes) => { + const editable: EditableProcess[] = (processes || []).map((p: any) => ({ + processCode: p.processCode, + processName: p.processName || p.processCode, + routingMeta: p.routingMeta, + workItems: (p.workItems || []).map((wi: any) => ({ + ...wi, + id: wi.id || tempId(), + details: (wi.details || []).map((d: any) => ({ + ...d, + id: d.id || tempId(), + })), + })), + })); + setTree(editable); + setActiveProcessCode(editable[0]?.processCode || null); + }) + .catch((err) => { + console.error("작업기준 트리 로드 실패", err); + setTree([]); + }) + .finally(() => setTreeLoading(false)); + }, [open, sourceItemCode, fetchSourceTree, fetchAllItemsForCopy]); + + // 타겟 필터 + const filteredItems = useMemo(() => { + const kw = search.trim().toLowerCase(); + return items.filter((it) => { + if (it.item_code === sourceItemCode) return false; + if (!kw) return true; + return it.item_name?.toLowerCase().includes(kw) || it.item_code?.toLowerCase().includes(kw); + }); + }, [items, sourceItemCode, search]); + + const allFilteredCodes = useMemo(() => filteredItems.map((it) => it.item_code), [filteredItems]); + const isAllSelected = allFilteredCodes.length > 0 && allFilteredCodes.every((c) => selected.has(c)); + + const toggleAll = () => { + const next = new Set(selected); + if (isAllSelected) { + for (const c of allFilteredCodes) next.delete(c); + } else { + for (const c of allFilteredCodes) next.add(c); + } + setSelected(next); + }; + + const toggleOne = (code: string) => { + const next = new Set(selected); + if (next.has(code)) next.delete(code); + else next.add(code); + setSelected(next); + }; + + // 현재 활성 공정 + const activeProcess = useMemo( + () => tree.find((p) => p.processCode === activeProcessCode) || null, + [tree, activeProcessCode], + ); + + // 트리 헬퍼들 — 메모리 state 갱신 + const updateActiveProcess = (mutator: (proc: EditableProcess) => EditableProcess) => { + setTree((prev) => prev.map((p) => (p.processCode === activeProcessCode ? mutator(p) : p))); + }; + + const handleAddWorkItem = (phaseKey: string) => { + setWiModalPhase(phaseKey); + setWiModalEditing(null); + setWiModalOpen(true); + }; + + const handleEditWorkItem = (wi: EditableWorkItem) => { + setWiModalPhase(wi.work_phase); + setWiModalEditing(wi); + setWiModalOpen(true); + }; + + const handleDeleteWorkItem = (wiId: string) => { + updateActiveProcess((proc) => ({ + ...proc, + workItems: proc.workItems.filter((w) => w.id !== wiId), + })); + setExpandedWorkItemIds((prev) => { + const next = new Set(prev); + next.delete(wiId); + return next; + }); + }; + + const handleWiModalSave = (data: { + work_phase: string; + title: string; + is_required: string; + description?: string; + details?: Array<{ detail_type?: string; content: string; is_required: string; sort_order: number }>; + }) => { + if (wiModalEditing) { + // 수정 — title/is_required/description만 변경, details는 유지 + updateActiveProcess((proc) => ({ + ...proc, + workItems: proc.workItems.map((w) => + w.id === wiModalEditing.id + ? { ...w, title: data.title, is_required: data.is_required, description: data.description } + : w, + ), + })); + } else { + // 신규 추가 — details는 동시 입력된 것도 받음 + const newWi: EditableWorkItem = { + id: tempId(), + work_phase: data.work_phase, + title: data.title, + is_required: data.is_required, + description: data.description, + sort_order: 0, // 추후 정렬은 work_phase 내 마지막에 + details: (data.details || []).map((d, idx) => ({ + id: tempId(), + detail_type: d.detail_type, + content: d.content, + is_required: d.is_required, + sort_order: idx + 1, + })), + }; + updateActiveProcess((proc) => { + const sameCount = proc.workItems.filter((w) => w.work_phase === data.work_phase).length; + newWi.sort_order = sameCount + 1; + return { ...proc, workItems: [...proc.workItems, newWi] }; + }); + } + }; + + const handleAddDetail = (wi: EditableWorkItem) => { + setDetailModalTargetWi(wi); + setDetailModalMode("add"); + setDetailModalEditing(null); + setDetailModalOpen(true); + }; + + const handleEditDetail = (wi: EditableWorkItem, d: EditableDetail) => { + setDetailModalTargetWi(wi); + setDetailModalMode("edit"); + setDetailModalEditing(d); + setDetailModalOpen(true); + }; + + const handleDeleteDetail = (wiId: string, detailId: string) => { + updateActiveProcess((proc) => ({ + ...proc, + workItems: proc.workItems.map((w) => + w.id === wiId ? { ...w, details: w.details.filter((d) => d.id !== detailId) } : w, + ), + })); + }; + + const handleDetailModalSubmit = (data: Partial) => { + if (!detailModalTargetWi) return; + const targetWiId = detailModalTargetWi.id; + if (detailModalMode === "edit" && detailModalEditing) { + // 수정 — 기존 detail 객체 + 변경값 덮어쓰기 + updateActiveProcess((proc) => ({ + ...proc, + workItems: proc.workItems.map((w) => + w.id === targetWiId + ? { + ...w, + details: w.details.map((d) => (d.id === detailModalEditing.id ? { ...d, ...data, id: d.id } : d)), + } + : w, + ), + })); + } else { + // 신규 — 임시 ID + sort_order 채우기 (...data 먼저 → 명시 필드가 덮어쓰지 못하게) + updateActiveProcess((proc) => ({ + ...proc, + workItems: proc.workItems.map((w) => { + if (w.id !== targetWiId) return w; + const next: EditableDetail = { + ...data, + id: tempId(), + content: (data.content as string) || "", + is_required: (data.is_required as string) || "N", + sort_order: w.details.length + 1, + }; + return { ...w, details: [...w.details, next] }; + }), + })); + } + }; + + const toggleWorkItemExpand = (wiId: string) => { + setExpandedWorkItemIds((prev) => { + const next = new Set(prev); + if (next.has(wiId)) next.delete(wiId); + else next.add(wiId); + return next; + }); + }; + + // 트리 통계 + const treeSummary = useMemo(() => { + let workItemCount = 0; + let detailCount = 0; + for (const p of tree) { + workItemCount += p.workItems.length; + for (const wi of p.workItems) detailCount += wi.details.length; + } + return { processCount: tree.length, workItemCount, detailCount }; + }, [tree]); + + // 복사 실행 + const handleCopy = async () => { + if (selected.size === 0) return; + setRunning(true); + try { + // 백엔드에 보낼 editedTree — 빈 작업항목 공정도 그대로 전송 (백엔드에서 skipped_exists로 처리됨) + // routingMeta도 그대로 전달 → 자동 라우팅 복제 시 소스 메타 보존 + const editedTree = tree.map((p) => ({ + processCode: p.processCode, + processName: p.processName, + routingMeta: p.routingMeta, + workItems: p.workItems.map((wi, idx) => ({ + work_phase: wi.work_phase, + title: wi.title, + is_required: wi.is_required, + sort_order: idx + 1, + description: wi.description, + details: wi.details.map((d, didx) => ({ ...d, sort_order: didx + 1 })), + })), + })); + const res = await onCopy(sourceItemCode, Array.from(selected), strategy, editedTree); + setResult(res); + } catch (err) { + console.error("작업기준 복사 실패", err); + setResult({ + success: false, + results: [], + summary: { + totalItems: 0, + totalProcesses: 0, + copied: 0, + skipped: 0, + notMatched: 0, + failed: 0, + }, + }); + } finally { + setRunning(false); + } + }; + + const handleReloadTree = () => { + setTreeLoading(true); + fetchSourceTree(sourceItemCode) + .then((processes) => { + const editable: EditableProcess[] = (processes || []).map((p: any) => ({ + processCode: p.processCode, + processName: p.processName || p.processCode, + routingMeta: p.routingMeta, + workItems: (p.workItems || []).map((wi: any) => ({ + ...wi, + id: wi.id || tempId(), + details: (wi.details || []).map((d: any) => ({ ...d, id: d.id || tempId() })), + })), + })); + setTree(editable); + setExpandedWorkItemIds(new Set()); + if (!editable.some((p) => p.processCode === activeProcessCode)) { + setActiveProcessCode(editable[0]?.processCode || null); + } + }) + .catch(() => setTree([])) + .finally(() => setTreeLoading(false)); + }; + + const statusBadge = (status: ProcessResult["status"]) => { + switch (status) { + case "copied": + return ( + + 복사 + + ); + case "skipped_exists": + return ( + + 스킵 + + ); + case "not_matched": + return ( + + 매칭실패 + + ); + case "failed": + return ( + + 실패 + + ); + } + }; + + const detailTypeLabel = (value?: string) => { + if (!value) return ""; + return detailTypes.find((d) => d.value === value)?.label || value; + }; + + return ( + <> + { + // 안쪽 모달(작업항목/상세) 열린 상태에서 onOpenChange(false) 콜백이 와도 무시 — 바깥 모달이 같이 닫히는 사고 방지 + if (!v && (wiModalOpen || detailModalOpen)) return; + if (!v) onClose(); + }} + > + { + if (wiModalOpen || detailModalOpen) e.preventDefault(); + }} + onEscapeKeyDown={(e) => { + if (wiModalOpen || detailModalOpen) e.preventDefault(); + }} + onInteractOutside={(e) => { + if (wiModalOpen || detailModalOpen) e.preventDefault(); + }} + > + + + 작업기준 복사 — 소스: {sourceItemName} ({sourceItemCode}) + + + 우측에서 공정별 작업항목/상세를 자유롭게 편집한 뒤, 좌측에서 선택한 타겟 품목들에 일괄 복제합니다. 매칭 + 키는 공정코드(process_code)이며, 타겟 품목에 해당 공정이 없으면 기본 라우팅 버전(없으면 ‘기본’으로 자동 + 생성)에 공정을 자동 등록한 뒤 복사합니다. + + + + {!result ? ( +
+ {/* 좌측: 타겟 + 옵션 */} +
+
+
+ + 선택: {selected.size}건 +
+
+ + setSearch(e.target.value)} + className="h-8 pl-8 text-xs" + /> +
+
+ + +
+
+ +
+ {itemsLoading ? ( +
+ 품목 로드 중... +
+ ) : filteredItems.length === 0 ? ( +
복사 가능한 품목이 없습니다
+ ) : ( + filteredItems.map((it) => ( + + )) + )} +
+
+ +
+ + setStrategy(v as ConflictStrategy)} + className="mt-2 space-y-1.5" + > +
+ +
+ +

+ 타겟 공정에 기존 작업기준이 있으면 모두 삭제 후 복사 +

+
+
+
+ +
+ +

+ 타겟 공정에 기존 작업기준이 있으면 그대로 두고 건너뜀 +

+
+
+
+
+ + + + + 검사기준/자재/BOM 등 FK 컬럼은 소스 값 그대로 복제됩니다. 타겟 품목 기준으로 유효성을 사후 + 점검하세요. + + +
+ + {/* 우측: 공정 탭 + 트리 편집 */} +
+ {/* 공정 탭 헤더 */} +
+
+ + {tree.length === 0 && !treeLoading && ( + 소스 품목에 공정 없음 + )} + {tree.map((p) => ( + + ))} +
+
+ + 공정 {treeSummary.processCount} · 작업 {treeSummary.workItemCount} · 상세{" "} + {treeSummary.detailCount} + + +
+
+ + {/* 활성 공정 트리 */} + +
+ {treeLoading ? ( +
+ 작업기준 로드 중... +
+ ) : !activeProcess ? ( +
편집할 공정을 선택하세요
+ ) : ( + sortedPhases.map((phase) => { + const phaseItems = activeProcess.workItems.filter((w) => w.work_phase === phase.key); + return ( +
+
+
+ {phase.label} + + {phaseItems.length} + +
+ +
+
+ {phaseItems.length === 0 ? ( +
항목 없음
+ ) : ( + phaseItems.map((wi) => { + const expanded = expandedWorkItemIds.has(wi.id); + return ( +
+
+ + {wi.title} + + 상세 {wi.details.length} + + {wi.is_required === "Y" && ( + + 필수 + + )} + + +
+ {expanded && ( +
+
+ + 상세 항목 + + +
+ {wi.details.length === 0 ? ( +
+ 상세 없음 +
+ ) : ( +
+ {wi.details.map((d) => ( +
+ {d.detail_type && ( + + {detailTypeLabel(d.detail_type)} + + )} + + {getDetailContentSummary(d)} + + + +
+ ))} +
+ )} +
+ )} +
+ ); + }) + )} +
+
+ ); + }) + )} +
+
+
+
+ ) : ( + // 결과 패널 +
+
+
+ 복사 완료 — 총 {result.summary.totalItems}개 품목 · {result.summary.totalProcesses}개 공정 처리 +
+
+ + 복사 {result.summary.copied} + + + 스킵 {result.summary.skipped} + + + 매칭실패 {result.summary.notMatched} + + + 실패 {result.summary.failed} + +
+
+ + +
+ {result.results.length === 0 ? ( +
처리 결과가 없습니다
+ ) : ( + + {result.results.map((r) => ( + + + + {r.itemName} ({r.itemCode}) ·{" "} + {r.processes.length}개 공정 + + + + + + + + + + + + + {r.processes.map((p, idx) => ( + + + + + + ))} + +
공정상태결과
+ {p.processName} ({p.processCode}) + {statusBadge(p.status)} + {p.status === "copied" + ? `항목 ${p.copiedItemCount || 0} / 상세 ${p.copiedDetailCount || 0}` + : p.reason || "-"} +
+
+
+ ))} +
+ )} +
+
+
+ )} + + + {!result ? ( + <> + + + + ) : ( + + )} + +
+
+ + {/* 작업항목 추가/수정 모달 — 기존 컴포넌트 재활용, 메모리 모드 */} + { + setWiModalOpen(false); + setWiModalEditing(null); + }} + onSave={handleWiModalSave} + phaseKey={wiModalPhase} + phaseLabel={phases.find((p) => p.key === wiModalPhase)?.label || ""} + detailTypes={detailTypes} + editItem={wiModalEditing as unknown as WorkItem | null} + selectedItemCode={sourceItemCode} + /> + + {/* 상세 추가/수정 모달 — 기존 컴포넌트 재활용, 메모리 모드 */} + { + setDetailModalOpen(false); + setDetailModalEditing(null); + setDetailModalTargetWi(null); + }} + onSubmit={handleDetailModalSubmit} + detailTypes={detailTypes} + editData={detailModalEditing as unknown as WorkItemDetail | null} + mode={detailModalMode} + selectedItemCode={sourceItemCode} + selectedProcessCode={activeProcessCode || undefined} + /> + + ); +} diff --git a/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx b/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx index a574de2b..b7d7205c 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx +++ b/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx @@ -431,10 +431,18 @@ export function DetailFormModal({ onSubmit({ ...submitData, detail_type: "inspection", + // 자동연동 분기는 process_inspection_apply="apply" 가정 — 명시 셋팅으로 수정 모달 라디오 복원 보장 + process_inspection_apply: "apply", content: `${insp.inspection_item_name || insp.inspection_standard || "-"} | ${insp.pass_criteria || ""}`.trim(), is_required: submitData.is_required || "Y", inspection_count_apply: countApply, inspection_count: countVal, + // 검사항목 메타 함께 전송 → 수정 모달 재진입 시 항목 식별/표시 가능 + inspection_code: insp.inspection_code ?? insp.inspection_standard_code ?? insp.inspection_standard ?? "", + inspection_method: insp.inspection_method ?? "", + unit: insp.unit ?? "", + lower_limit: insp.lower_limit ?? "", + upper_limit: insp.upper_limit ?? "", }); } onClose(); @@ -456,8 +464,14 @@ export function DetailFormModal({ onSubmit({ ...submitData, detail_type: "equip_inspection", + // 자동연동 분기는 equip_inspection_apply="apply" 가정 — 명시 셋팅으로 수정 모달 라디오 복원 보장 + equip_inspection_apply: "apply", content: `${item.inspection_item || "-"}${range ? ` | ${range}` : ""}`.trim(), is_required: submitData.is_required || "Y", + // 점검항목 메타 함께 전송 → 수정 모달 재진입 시 표시 복원 + unit: item.unit ?? "", + lower_limit: item.lower_limit ?? "", + upper_limit: item.upper_limit ?? "", }); } onClose(); diff --git a/frontend/lib/registry/components/v2-process-work-standard/components/WorkItemDetailList.tsx b/frontend/lib/registry/components/v2-process-work-standard/components/WorkItemDetailList.tsx index c3b07fbb..54d4edc3 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/components/WorkItemDetailList.tsx +++ b/frontend/lib/registry/components/v2-process-work-standard/components/WorkItemDetailList.tsx @@ -8,6 +8,62 @@ import { cn } from "@/lib/utils"; import { WorkItem, WorkItemDetail, DetailTypeDefinition } from "../types"; import { DetailFormModal, getPlcDataName } from "./DetailFormModal"; +// 상세 행 요약 문자열 — 검사항목 횟수/설비조건 기준 등 메타까지 함께 표시 +// 복사 모달 등 다른 컴포넌트에서도 재활용 +export function getDetailContentSummary(detail: Partial): string { + const type = detail.detail_type; + if (type === "inspection") { + if (detail.process_inspection_apply === "apply") { + const base = (detail.content || "품목별 검사정보 (자동 연동)").replace(/\s*\|\s*$/, "").trim(); + if (detail.inspection_count_apply === "Y" && detail.inspection_count) { + return `${base} (횟수 ${detail.inspection_count}회)`; + } + return base; + } + const parts: string[] = []; + if (detail.content) parts.push(detail.content); + if (detail.inspection_method) parts.push(`[${detail.inspection_method}]`); + if (detail.base_value) { + parts.push(`(기준: ${detail.base_value}${detail.tolerance ? ` ±${detail.tolerance}` : ""} ${detail.unit || ""})`); + } + if (detail.inspection_count_apply === "Y" && detail.inspection_count) { + parts.push(`(횟수 ${detail.inspection_count}회)`); + } + return parts.join(" "); + } + if (type === "procedure" && detail.duration_minutes) { + return `${detail.content} (${detail.duration_minutes}분)`; + } + if (type === "input" && detail.input_type) { + const typeMap: Record = { text: "텍스트", number: "숫자", date: "날짜", textarea: "장문" }; + return `${detail.content} [${typeMap[detail.input_type] || detail.input_type}]`; + } + if (type === "lookup") return "품목 등록 문서 (자동 연동)"; + if (type === "equip_inspection") { + return detail.content || (detail.equip_inspection_apply === "apply" ? "설비 점검항목 (설비정보 연동)" : "설비점검"); + } + if (type === "equip_condition") { + const parts: string[] = []; + if (detail.content) parts.push(detail.content); + if (detail.condition_base_value) { + parts.push(`(기준: ${detail.condition_base_value}${detail.condition_tolerance ? ` ±${detail.condition_tolerance}` : ""} ${detail.condition_unit || ""})`); + } + return parts.join(" "); + } + if (type === "production_result") return "작업수량 / 불량수량 / 양품수량"; + if (type === "material_input") { + const base = detail.content || "BOM 구성 자재 (자동 연동)"; + const meta: string[] = []; + const equipLabel = detail.material_equipment_name || detail.material_equipment_code; + if (equipLabel) meta.push(`설비: ${equipLabel}`); + if (detail.material_auto_collect === "Y" && detail.material_plc_data) { + meta.push(`수집: ${getPlcDataName(detail.material_plc_data)}`); + } + return meta.length > 0 ? `${base} (${meta.join(" / ")})` : base; + } + return detail.content || "-"; +} + interface WorkItemDetailListProps { workItem: WorkItem | null; details: WorkItemDetail[]; @@ -75,62 +131,7 @@ export function WorkItemDetailList({ } }; - const getContentSummary = (detail: WorkItemDetail): string => { - const type = detail.detail_type; - if (type === "inspection") { - if (detail.process_inspection_apply === "apply") { - const base = (detail.content || "품목별 검사정보 (자동 연동)").replace(/\s*\|\s*$/, "").trim(); - if (detail.inspection_count_apply === "Y" && detail.inspection_count) { - return `${base} (횟수 ${detail.inspection_count}회)`; - } - return base; - } - const parts = [detail.content]; - if (detail.inspection_method) parts.push(`[${detail.inspection_method}]`); - if (detail.base_value) { - parts.push(`(기준: ${detail.base_value}${detail.tolerance ? ` ±${detail.tolerance}` : ""} ${detail.unit || ""})`); - } - if (detail.inspection_count_apply === "Y" && detail.inspection_count) { - parts.push(`(횟수 ${detail.inspection_count}회)`); - } - return parts.join(" "); - } - if (type === "procedure" && detail.duration_minutes) { - return `${detail.content} (${detail.duration_minutes}분)`; - } - if (type === "input" && detail.input_type) { - const typeMap: Record = { - text: "텍스트", - number: "숫자", - date: "날짜", - textarea: "장문", - }; - return `${detail.content} [${typeMap[detail.input_type] || detail.input_type}]`; - } - if (type === "lookup") return "품목 등록 문서 (자동 연동)"; - if (type === "equip_inspection") { - return detail.content || (detail.equip_inspection_apply === "apply" ? "설비 점검항목 (설비정보 연동)" : "설비점검"); - } - if (type === "equip_condition") { - const parts = [detail.content]; - if (detail.condition_base_value) { - parts.push(`(기준: ${detail.condition_base_value}${detail.condition_tolerance ? ` ±${detail.condition_tolerance}` : ""} ${detail.condition_unit || ""})`); - } - return parts.join(" "); - } - if (type === "production_result") return "작업수량 / 불량수량 / 양품수량"; - if (type === "material_input") { - const base = detail.content || "BOM 구성 자재 (자동 연동)"; - const meta: string[] = []; - const equipLabel = detail.material_equipment_name || detail.material_equipment_code; - if (equipLabel) meta.push(`설비: ${equipLabel}`); - if (detail.material_auto_collect === "Y" && detail.material_plc_data) { - meta.push(`수집: ${getPlcDataName(detail.material_plc_data)}`); - } - return meta.length > 0 ? `${base} (${meta.join(" / ")})` : base; - } - return detail.content || "-"; - }; + const getContentSummary = (detail: WorkItemDetail): string => getDetailContentSummary(detail); return (
diff --git a/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts b/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts index 3b955114..b0624a8b 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts +++ b/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts @@ -59,7 +59,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { setLoading(false); } }, - [config.dataSource] + [config.dataSource], ); // 등록 품목 조회 (등록 모드) @@ -82,9 +82,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { routingFkColumn: ds.routingFkColumn, ...(search ? { search } : {}), }); - const res = await apiClient.get( - `${API_BASE}/registered-items/${encodeURIComponent(screenCode)}?${params}` - ); + const res = await apiClient.get(`${API_BASE}/registered-items/${encodeURIComponent(screenCode)}?${params}`); if (res.data?.success) { setItems(res.data.data || []); } @@ -94,7 +92,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { setLoading(false); } }, - [config.dataSource, config.screenCode] + [config.dataSource, config.screenCode], ); // 모드에 따라 적절한 함수 호출 @@ -106,7 +104,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { await fetchItems(search); } }, - [isRegisteredMode, fetchItems, fetchRegisteredItems] + [isRegisteredMode, fetchItems, fetchRegisteredItems], ); // 라우팅 + 공정 조회 @@ -122,9 +120,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { processNameColumn: ds.processNameColumn, processCodeColumn: ds.processCodeColumn, }); - const res = await apiClient.get( - `${API_BASE}/items/${encodeURIComponent(itemCode)}/routings?${params}` - ); + const res = await apiClient.get(`${API_BASE}/items/${encodeURIComponent(itemCode)}/routings?${params}`); if (res.data?.success) { setRoutings(res.data.data); } @@ -132,16 +128,14 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { console.error("라우팅 조회 실패", err); } }, - [config.dataSource] + [config.dataSource], ); // 작업 항목 조회 + 각 phase별 첫 항목 자동 선택 (상세 영역이 비어 보이는 오해 방지) const fetchWorkItems = useCallback(async (routingDetailId: string) => { try { setLoading(true); - const res = await apiClient.get( - `${API_BASE}/routing-detail/${routingDetailId}/work-items` - ); + const res = await apiClient.get(`${API_BASE}/routing-detail/${routingDetailId}/work-items`); if (res.data?.success) { const items: WorkItem[] = res.data.items || []; setWorkItems(items); @@ -160,8 +154,10 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { setSelectedDetailsByPhase((prev) => ({ ...prev, [phaseKey]: dr.data.data })); setSelectedWorkItemIdByPhase((prev) => ({ ...prev, [phaseKey]: item.id })); } - } catch { /* skip */ } - }) + } catch { + /* skip */ + } + }), ); } } catch (err) { @@ -174,12 +170,10 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { // 작업 항목 상세 조회 (phase별 독립 저장) const fetchWorkItemDetails = useCallback(async (workItemId: string, phaseKey: string) => { try { - const res = await apiClient.get( - `${API_BASE}/work-items/${workItemId}/details` - ); + const res = await apiClient.get(`${API_BASE}/work-items/${workItemId}/details`); if (res.data?.success) { - setSelectedDetailsByPhase(prev => ({ ...prev, [phaseKey]: res.data.data })); - setSelectedWorkItemIdByPhase(prev => ({ ...prev, [phaseKey]: workItemId })); + setSelectedDetailsByPhase((prev) => ({ ...prev, [phaseKey]: res.data.data })); + setSelectedWorkItemIdByPhase((prev) => ({ ...prev, [phaseKey]: workItemId })); } } catch (err) { console.error("상세 조회 실패", err); @@ -203,7 +197,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { setSelectedWorkItemIdByPhase({}); await fetchRoutings(itemCode); }, - [fetchRoutings] + [fetchRoutings], ); // 공정 선택 @@ -213,7 +207,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { processName: string, routingVersionId: string, routingVersionName: string, - processCode?: string + processCode?: string, ) => { setSelection((prev) => ({ ...prev, @@ -227,7 +221,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { setSelectedWorkItemIdByPhase({}); await fetchWorkItems(routingDetailId); }, - [fetchWorkItems] + [fetchWorkItems], ); // 작업 항목 추가 @@ -247,8 +241,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { if (!selection.routingDetailId) return null; try { - const nextOrder = - workItems.filter((wi) => wi.work_phase === data.work_phase).length + 1; + const nextOrder = workItems.filter((wi) => wi.work_phase === data.work_phase).length + 1; const res = await apiClient.post(`${API_BASE}/work-items`, { routing_detail_id: selection.routingDetailId, @@ -280,7 +273,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { } return null; }, - [selection.routingDetailId, workItems, fetchWorkItems] + [selection.routingDetailId, workItems, fetchWorkItems], ); // 작업 항목 수정 @@ -295,7 +288,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { console.error("작업 항목 수정 실패", err); } }, - [selection.routingDetailId, fetchWorkItems] + [selection.routingDetailId, fetchWorkItems], ); // 작업 항목 삭제 @@ -306,7 +299,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { if (res.data?.success && selection.routingDetailId) { await fetchWorkItems(selection.routingDetailId); // 삭제된 항목이 선택되어 있던 phase의 선택 상태 초기화 - setSelectedWorkItemIdByPhase(prev => { + setSelectedWorkItemIdByPhase((prev) => { const next = { ...prev }; for (const phaseKey of Object.keys(next)) { if (next[phaseKey] === id) { @@ -315,7 +308,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { } return next; }); - setSelectedDetailsByPhase(prev => { + setSelectedDetailsByPhase((prev) => { const next = { ...prev }; for (const phaseKey of Object.keys(next)) { if (selectedWorkItemIdByPhase[phaseKey] === id) { @@ -329,7 +322,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { console.error("작업 항목 삭제 실패", err); } }, - [selection.routingDetailId, selectedWorkItemIdByPhase, fetchWorkItems] + [selection.routingDetailId, selectedWorkItemIdByPhase, fetchWorkItems], ); // 상세 추가 @@ -350,17 +343,14 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { console.error("상세 생성 실패", err); } }, - [fetchWorkItemDetails, fetchWorkItems, selection.routingDetailId] + [fetchWorkItemDetails, fetchWorkItems, selection.routingDetailId], ); // 상세 수정 const updateDetail = useCallback( async (id: string, data: Partial, phaseKey: string) => { try { - const res = await apiClient.put( - `${API_BASE}/work-item-details/${id}`, - data - ); + const res = await apiClient.put(`${API_BASE}/work-item-details/${id}`, data); if (res.data?.success) { const workItemId = selectedWorkItemIdByPhase[phaseKey]; if (workItemId) { @@ -371,16 +361,14 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { console.error("상세 수정 실패", err); } }, - [selectedWorkItemIdByPhase, fetchWorkItemDetails] + [selectedWorkItemIdByPhase, fetchWorkItemDetails], ); // 상세 삭제 const deleteDetail = useCallback( async (id: string, phaseKey: string) => { try { - const res = await apiClient.delete( - `${API_BASE}/work-item-details/${id}` - ); + const res = await apiClient.delete(`${API_BASE}/work-item-details/${id}`); if (res.data?.success) { const workItemId = selectedWorkItemIdByPhase[phaseKey]; if (workItemId) { @@ -394,12 +382,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { console.error("상세 삭제 실패", err); } }, - [ - selectedWorkItemIdByPhase, - selection.routingDetailId, - fetchWorkItemDetails, - fetchWorkItems, - ] + [selectedWorkItemIdByPhase, selection.routingDetailId, fetchWorkItemDetails, fetchWorkItems], ); // 작업 항목 순서 일괄 재배치 @@ -408,16 +391,14 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { if (!selection.routingDetailId || orderedIds.length === 0) return; try { await Promise.all( - orderedIds.map((id, idx) => - apiClient.put(`${API_BASE}/work-items/${id}`, { sort_order: idx + 1 }) - ) + orderedIds.map((id, idx) => apiClient.put(`${API_BASE}/work-items/${id}`, { sort_order: idx + 1 })), ); await fetchWorkItems(selection.routingDetailId); } catch (err) { console.error("작업 항목 순서 변경 실패", err); } }, - [selection.routingDetailId, fetchWorkItems] + [selection.routingDetailId, fetchWorkItems], ); // 상세 항목 순서 일괄 재배치 (전체 필드 보존 위해 객체 배열 수신) @@ -427,15 +408,66 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { try { await Promise.all( orderedDetails.map((d, idx) => - apiClient.put(`${API_BASE}/work-item-details/${d.id}`, { ...d, sort_order: idx + 1 }) - ) + apiClient.put(`${API_BASE}/work-item-details/${d.id}`, { ...d, sort_order: idx + 1 }), + ), ); await fetchWorkItemDetails(workItemId, phaseKey); } catch (err) { console.error("상세 순서 변경 실패", err); } }, - [fetchWorkItemDetails] + [fetchWorkItemDetails], + ); + + // 소스 품목 작업기준 트리 전체 로드 (복사 모달용) + const fetchSourceTree = useCallback(async (itemCode: string) => { + const res = await apiClient.get(`${API_BASE}/items/${encodeURIComponent(itemCode)}/work-standard-tree`); + if (res.data?.success) { + return res.data.data?.processes || []; + } + return []; + }, []); + + // 복사 모달 전용 — 전체 품목 검색 (등록 모드 무시, 라우팅 없는 품목도 포함) + const fetchAllItemsForCopy = useCallback( + async (search?: string): Promise => { + const ds = config.dataSource; + const params = new URLSearchParams({ + tableName: ds.itemTable, + nameColumn: ds.itemNameColumn, + codeColumn: ds.itemCodeColumn, + routingTable: ds.routingVersionTable, + routingFkColumn: ds.routingFkColumn, + ...(search ? { search } : {}), + }); + const res = await apiClient.get(`${API_BASE}/items?${params}`); + if (res.data?.success) { + return res.data.data || []; + } + return []; + }, + [config.dataSource], + ); + + // 품목 단위 복사 (TASK:ERP-029) — editedTree 옵션으로 사용자 편집 결과 전달 가능 + // screenCode: 등록 모드일 때 자동 라우팅 생성된 품목을 item_routing_registered에도 등록 + const copyByItem = useCallback( + async ( + sourceItemCode: string, + targetItemCodes: string[], + conflictStrategy: "skip" | "overwrite", + editedTree?: unknown, + ) => { + const res = await apiClient.post(`${API_BASE}/copy-by-item`, { + sourceItemCode, + targetItemCodes, + conflictStrategy, + screenCode: config.screenCode, + ...(editedTree ? { editedTree } : {}), + }); + return res.data; + }, + [config.screenCode], ); return { @@ -463,5 +495,8 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { deleteDetail, reorderWorkItems, reorderDetails, + copyByItem, + fetchSourceTree, + fetchAllItemsForCopy, }; }