From c618283306ff9c82de4fbf855c363be5af43e1da Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 23 Apr 2026 17:36:04 +0900 Subject: [PATCH] feat: Refactor work process and item inspection logic - Updated SQL queries in `popProductionController` to separate work order process and result handling, ensuring batch_id is now managed in the work_order_process_result table. - Enhanced `workInstructionController` to include id generation for work items and details, preventing NULL values during insertion. - Implemented case-insensitive search functionality across various services, improving data retrieval accuracy. - Added sorting functionality in the item inspection page, allowing users to sort by different columns with visual indicators for sort direction. This refactor aims to improve data integrity and user experience across the production and inspection workflows. --- .../controllers/popProductionController.ts | 34 +- .../controllers/workInstructionController.ts | 19 +- backend-node/src/services/adminService.ts | 8 - .../src/services/tableManagementService.ts | 22 +- .../src/services/workHistoryService.ts | 12 +- backend-node/src/utils/dataFilterUtil.ts | 6 +- .../design/change-management/page.tsx | 6 +- .../WorkStandardEditModal.tsx | 1563 +++++++++++++---- .../quality/item-inspection/page.tsx | 49 +- .../design/change-management/page.tsx | 6 +- .../WorkStandardEditModal.tsx | 1563 +++++++++++++---- .../quality/item-inspection/page.tsx | 49 +- .../design/change-management/page.tsx | 6 +- .../WorkStandardEditModal.tsx | 1563 +++++++++++++---- .../quality/item-inspection/page.tsx | 49 +- .../design/change-management/page.tsx | 6 +- .../WorkStandardEditModal.tsx | 1563 +++++++++++++---- .../quality/item-inspection/page.tsx | 49 +- .../design/change-management/page.tsx | 6 +- .../WorkStandardEditModal.tsx | 1563 +++++++++++++---- .../(main)/COMPANY_7/purchase/order/page.tsx | 36 +- .../quality/item-inspection/page.tsx | 50 +- .../design/change-management/page.tsx | 6 +- .../WorkStandardEditModal.tsx | 1563 +++++++++++++---- .../quality/item-inspection/page.tsx | 49 +- .../design/change-management/page.tsx | 6 +- .../WorkStandardEditModal.tsx | 1563 +++++++++++++---- .../quality/item-inspection/page.tsx | 49 +- .../(main)/admin/userMng/departments/page.tsx | 833 +++++++++ .../admin/userMng/rolesList/[id]/page.tsx | 2 +- .../(main)/admin/userMng/rolesList/page.tsx | 4 +- .../components/common/ExcelUploadModal.tsx | 45 +- .../common/MultiTableExcelUploadModal.tsx | 76 +- frontend/lib/api/workInstruction.ts | 40 +- .../v2-bom-tree/BomExcelUploadModal.tsx | 59 +- 35 files changed, 9655 insertions(+), 2868 deletions(-) create mode 100644 frontend/app/(main)/admin/userMng/departments/page.tsx diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index c75a7476..4d747830 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -173,18 +173,21 @@ async function generateWorkProcessesForInstruction( total_checklists: number; } | null> { // 중복 호출 방지: 이미 생성된 공정이 있는지 확인 (batch_id 기준 분리) + // ※ TASK:ERP-011 스키마 분리 반영 — batch_id는 실적 테이블(work_order_process_result)로 이동 if (batchId) { // 다중 품목: 같은 wo_id + 같은 batch_id에 대해 이미 공정이 있으면 skip + // 기준정보(work_order_process) ⟷ 실적(work_order_process_result) JOIN으로 확인 const existCheck = await client.query( - `SELECT COUNT(*) as cnt FROM work_order_process - WHERE wo_id = $1 AND company_code = $2 AND batch_id = $3`, + `SELECT COUNT(*) as cnt FROM work_order_process p + JOIN work_order_process_result r ON r.wop_id = p.id + WHERE p.wo_id = $1 AND p.company_code = $2 AND r.batch_id = $3`, [workInstructionId, companyCode, batchId], ); if (parseInt(existCheck.rows[0].cnt, 10) > 0) { return null; // 이미 존재 } } else { - // 기존 동작: batch_id 없으면 wo_id 전체로 체크 + // 기존 동작: batch_id 없으면 wo_id 전체로 체크 (기준정보 테이블만 조회) const existCheck = await client.query( `SELECT COUNT(*) as cnt FROM work_order_process WHERE wo_id = $1 AND company_code = $2`, @@ -221,13 +224,14 @@ async function generateWorkProcessesForInstruction( let totalChecklists = 0; for (const rd of routingDetails.rows) { - // 2. work_order_process INSERT (batch_id 포함) + // 2-A. work_order_process INSERT — 기준정보(TASK:ERP-011 분리 후) + // status/batch_id는 실적 테이블로 이동했으므로 여기에서는 제외 const wopResult = await client.query( `INSERT INTO work_order_process ( id, company_code, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, standard_time, plan_qty, - status, routing_detail_id, batch_id, writer - ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + routing_detail_id, writer + ) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id`, [ companyCode, @@ -239,16 +243,26 @@ async function generateWorkProcessesForInstruction( rd.is_fixed_order, rd.standard_time, planQty || null, - parseInt(rd.seq_no, 10) === 1 || rd.is_fixed_order === "Y" - ? "acceptable" - : "waiting", rd.id, - batchId || null, userId, ], ); const wopId = wopResult.rows[0].id; + // 2-B. work_order_process_result INSERT — 최초 실적 레코드 (seq=1) + // 동일 client로 실행 → 트랜잭션 보호 유지 + // id는 wopId와 동일하게 부여 (초기 이관 정책 및 copyChecklistToSplit 호환 목적) + const initialStatus = + parseInt(rd.seq_no, 10) === 1 || rd.is_fixed_order === "Y" + ? "acceptable" + : "waiting"; + await client.query( + `INSERT INTO work_order_process_result ( + id, wop_id, seq, company_code, status, batch_id, writer + ) VALUES ($1, $2, 1, $3, $4, $5, $6)`, + [wopId, wopId, companyCode, initialStatus, batchId || null, userId], + ); + // 3. process_work_result INSERT (공통 함수로 체크리스트 복사) const checklistCount = await copyChecklistToSplit( client, diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index 955700f4..5611689d 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -757,8 +757,8 @@ export async function copyWorkStandard(req: AuthenticatedRequest, res: Response) for (const origItem of origItems.rows) { const newItemResult = await client.query( - `INSERT INTO wi_process_work_item (company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, + `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, rd.id, origItem.work_phase, origItem.title, origItem.is_required, origItem.sort_order, origItem.description, origItem.id, userId] ); const newItemId = newItemResult.rows[0].id; @@ -771,8 +771,8 @@ export async function copyWorkStandard(req: AuthenticatedRequest, res: Response) for (const origDetail of origDetails.rows) { await client.query( - `INSERT INTO wi_process_work_item_detail (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, writer) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`, + `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, 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)`, [companyCode, newItemId, origDetail.detail_type, origDetail.content, origDetail.is_required, origDetail.sort_order, origDetail.remark, origDetail.inspection_code, origDetail.inspection_method, origDetail.unit, origDetail.lower_limit, origDetail.upper_limit, origDetail.duration_minutes, origDetail.input_type, origDetail.lookup_target, origDetail.display_fields, origDetail.process_inspection_apply || null, origDetail.equip_inspection_apply || null, userId] ); } @@ -824,10 +824,13 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response) ); // 새 데이터 삽입 + // NOTE: wi_process_work_item / wi_process_work_item_detail.id 컬럼에 DEFAULT(gen_random_uuid()) 누락 + // → id를 명시하지 않으면 NULL 저장되어 재조회 시 wi_work_item_id 매칭 실패(0건 반환)로 이어짐. + // 원본 테이블(process_work_item) DEFAULT와 동기되지 않은 스키마 이슈. 여기서 명시 바인딩으로 회피. for (const wi of workItems) { const wiResult = await client.query( - `INSERT INTO wi_process_work_item (company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, + `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, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description || null, wi.source_work_item_id || null, userId] ); const newId = wiResult.rows[0].id; @@ -835,8 +838,8 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response) if (wi.details && Array.isArray(wi.details)) { for (const d of wi.details) { await client.query( - `INSERT INTO wi_process_work_item_detail (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, writer) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`, + `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, 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)`, [companyCode, newId, d.detail_type, d.content, d.is_required, d.sort_order, d.remark || null, d.inspection_code || null, d.inspection_method || null, d.unit || null, d.lower_limit || null, d.upper_limit || null, d.duration_minutes || null, d.input_type || null, d.lookup_target || null, d.display_fields || null, d.process_inspection_apply || null, d.equip_inspection_apply || null, userId] ); } diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index a27fcc77..9f363780 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -415,13 +415,6 @@ export class AdminService { let queryParams: any[] = [userLang]; let paramIndex = 2; - // [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시 - // TODO: 권한 체크 다시 활성화 필요 - logger.debug(`getUserMenuList 권한 그룹 체크 스킵 - ${userId}(${userType})`); - authFilter = ""; - unionFilter = ""; - - /* [원본 코드 - getUserMenuList 권한 그룹 체크] if (userType === "SUPER_ADMIN") { // SUPER_ADMIN: 권한 그룹 체크 없이 해당 회사의 모든 메뉴 표시 logger.info(`✅ 좌측 사이드바 (SUPER_ADMIN): 회사 ${userCompanyCode}의 모든 메뉴 표시`); @@ -481,7 +474,6 @@ export class AdminService { return []; } } - */ // 2. 회사별 필터링 조건 생성 let companyFilter = ""; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 3ad0e3f7..7b7a1270 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1421,9 +1421,9 @@ export class TableManagementService { const paramBase = paramIndex + idx * 4; conditions.push(`( ${columnName}::text = $${paramBase} OR - ${columnName}::text LIKE $${paramBase + 1} OR - ${columnName}::text LIKE $${paramBase + 2} OR - ${columnName}::text LIKE $${paramBase + 3} + ${columnName}::text ILIKE $${paramBase + 1} OR + ${columnName}::text ILIKE $${paramBase + 2} OR + ${columnName}::text ILIKE $${paramBase + 3} )`); values.push( safeValue, @@ -3466,17 +3466,17 @@ export class TableManagementService { } case "contains": filterConditions.push( - `${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}%'` + `${safeColumn} ILIKE '%${String(value).replace(/'/g, "''")}%'` ); break; case "starts_with": filterConditions.push( - `${safeColumn} LIKE '${String(value).replace(/'/g, "''")}%'` + `${safeColumn} ILIKE '${String(value).replace(/'/g, "''")}%'` ); break; case "ends_with": filterConditions.push( - `${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}'` + `${safeColumn} ILIKE '%${String(value).replace(/'/g, "''")}'` ); break; case "is_null": @@ -3487,7 +3487,7 @@ export class TableManagementService { break; case "not_contains": filterConditions.push( - `${safeColumn}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'` + `${safeColumn}::text NOT ILIKE '%${String(value).replace(/'/g, "''")}%'` ); break; case "greater_than": @@ -3553,16 +3553,16 @@ export class TableManagementService { conditions.push(`${safeCol}::text != '${String(value).replace(/'/g, "''")}'`); break; case "contains": - conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}%'`); + conditions.push(`${safeCol}::text ILIKE '%${String(value).replace(/'/g, "''")}%'`); break; case "not_contains": - conditions.push(`${safeCol}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'`); + conditions.push(`${safeCol}::text NOT ILIKE '%${String(value).replace(/'/g, "''")}%'`); break; case "starts_with": - conditions.push(`${safeCol}::text LIKE '${String(value).replace(/'/g, "''")}%'`); + conditions.push(`${safeCol}::text ILIKE '${String(value).replace(/'/g, "''")}%'`); break; case "ends_with": - conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}'`); + conditions.push(`${safeCol}::text ILIKE '%${String(value).replace(/'/g, "''")}'`); break; case "greater_than": conditions.push(`(${safeCol})::numeric > ${parseFloat(String(value))}`); diff --git a/backend-node/src/services/workHistoryService.ts b/backend-node/src/services/workHistoryService.ts index 5ccceba9..754876c3 100644 --- a/backend-node/src/services/workHistoryService.ts +++ b/backend-node/src/services/workHistoryService.ts @@ -39,13 +39,13 @@ export async function getWorkHistories(filters?: WorkHistoryFilters): Promise a.toLowerCase()); + if (!allowed.includes(val.toLowerCase())) return false; } else if (f.operator === "between") { const [from, to] = f.value.split("|"); if (from && val < from) return false; diff --git a/frontend/app/(main)/COMPANY_10/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/COMPANY_10/production/work-instruction/WorkStandardEditModal.tsx index da7e8fd5..96b96535 100644 --- a/frontend/app/(main)/COMPANY_10/production/work-instruction/WorkStandardEditModal.tsx +++ b/frontend/app/(main)/COMPANY_10/production/work-instruction/WorkStandardEditModal.tsx @@ -15,9 +15,11 @@ import { } from "lucide-react"; import { toast } from "sonner"; import { - getWIWorkStandard, copyWorkStandard, saveWIWorkStandard, resetWIWorkStandard, + getWIWorkStandard, saveWIWorkStandard, resetWIWorkStandard, WIWorkItem, WIWorkItemDetail, WIWorkStandardProcess, } from "@/lib/api/workInstruction"; +import { getBomMaterials, BomMaterial } from "@/lib/api/processInfo"; +import { apiClient } from "@/lib/api/client"; interface WorkStandardEditModalProps { open: boolean; @@ -60,12 +62,46 @@ const UNIT_OPTIONS = [ "L/min", "Hz", "dB", "ea", "g", "mg", "ml", "L", ]; +// PLC 데이터 옵션 (원본 DetailFormModal과 동일) +const PLC_DATA_OPTIONS = [ + { value: "PLC_TEMP_01", label: "온도 (PLC_TEMP_01)" }, + { value: "PLC_PRES_01", label: "압력 (PLC_PRES_01)" }, + { value: "PLC_RPM_01", label: "회전수 (PLC_RPM_01)" }, + { value: "PLC_TORQ_01", label: "토크 (PLC_TORQ_01)" }, + { value: "PLC_SPD_01", label: "속도 (PLC_SPD_01)" }, + { value: "PLC_CUR_01", label: "전류 (PLC_CUR_01)" }, + { value: "PLC_VOLT_01", label: "전압 (PLC_VOLT_01)" }, + { value: "PLC_VIB_01", label: "진동 (PLC_VIB_01)" }, + { value: "PLC_HUM_01", label: "습도 (PLC_HUM_01)" }, + { value: "PLC_FLOW_01", label: "유량 (PLC_FLOW_01)" }, +]; + +const PLC_PRODUCTION_OPTIONS: Record = { + work_qty: [ + { value: "PLC_CNT_01", label: "생산카운터 (PLC_CNT_01)" }, + { value: "PLC_CNT_02", label: "완료카운터 (PLC_CNT_02)" }, + { value: "PLC_QTY_01", label: "작업수량 (PLC_QTY_01)" }, + ], + defect_qty: [ + { value: "PLC_NG_01", label: "불량카운터 (PLC_NG_01)" }, + { value: "PLC_NG_02", label: "NG감지기 (PLC_NG_02)" }, + { value: "PLC_REJ_01", label: "리젝트수 (PLC_REJ_01)" }, + ], + good_qty: [ + { value: "PLC_OK_01", label: "양품카운터 (PLC_OK_01)" }, + { value: "PLC_OK_02", label: "합격카운터 (PLC_OK_02)" }, + { value: "PLC_GOOD_01", label: "양품수량 (PLC_GOOD_01)" }, + ], +}; + const getDetailTypeLabel = (type: string) => DETAIL_TYPES.find(d => d.value === type)?.label || type; +// 원본 WorkItemDetailList의 getContentSummary를 그대로 이식 const getContentSummary = (detail: WIWorkItemDetail): string => { const type = detail.detail_type; if (type === "inspection") { + if (detail.process_inspection_apply === "apply") return detail.content || "품목별 검사정보 (자동 연동)"; const parts = [detail.content]; if (detail.inspection_method) parts.push(`[${detail.inspection_method}]`); if (detail.base_value) { @@ -81,7 +117,9 @@ const getContentSummary = (detail: WIWorkItemDetail): string => { return `${detail.content} [${typeMap[detail.input_type] || detail.input_type}]`; } if (type === "lookup") return "품목 등록 문서 (자동 연동)"; - if (type === "equip_inspection") return detail.content || "설비점검"; + 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) { @@ -90,10 +128,1002 @@ const getContentSummary = (detail: WIWorkItemDetail): string => { return parts.join(" "); } if (type === "production_result") return "작업수량 / 불량수량 / 양품수량"; - if (type === "material_input") return "BOM 구성 자재 (자동 연동)"; + if (type === "material_input") return detail.content || "BOM 구성 자재 (자동 연동)"; return detail.content || "-"; }; +// ============================================================ +// 상세 항목 추가/수정 모달 — 원본 DetailFormModal을 내부에 이식 +// 원본: frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx +// ============================================================ +interface DetailFormModalInnerProps { + open: boolean; + onClose: () => void; + onSubmit: (data: Partial) => void; + editData?: WIWorkItemDetail | null; + mode: "add" | "edit"; + selectedItemCode?: string; + selectedProcessCode?: string; +} + +function DetailFormModalInner({ + open, + onClose, + onSubmit, + editData, + mode, + selectedItemCode, + selectedProcessCode, +}: DetailFormModalInnerProps) { + const [formData, setFormData] = useState>({}); + const [bomMaterials, setBomMaterials] = useState([]); + const [bomLoading, setBomLoading] = useState(false); + const [bomChecked, setBomChecked] = useState>(new Set()); + + // 품목검사정보 연동 + const [itemInspections, setItemInspections] = useState([]); + const [itemInspLoading, setItemInspLoading] = useState(false); + + // 품목 카테고리 (type 코드→라벨) + const [itemTypeCatMap, setItemTypeCatMap] = useState>({}); + + // 설비 점검항목 연동 + const [equipInspItems, setEquipInspItems] = useState([]); + const [equipInspLoading, setEquipInspLoading] = useState(false); + + const loadBomMaterials = useCallback(async () => { + if (!selectedItemCode) { + setBomMaterials([]); + return; + } + setBomLoading(true); + try { + const res = await getBomMaterials(selectedItemCode); + if (res.success && res.data) { + setBomMaterials(res.data); + } else { + setBomMaterials([]); + } + } catch { + setBomMaterials([]); + } finally { + setBomLoading(false); + } + }, [selectedItemCode]); + + // BOM 자재 로드 완료 후 체크 상태 초기화 + useEffect(() => { + if (!open || bomMaterials.length === 0) return; + if (mode === "edit" && editData?.selected_bom_items) { + const savedBom = editData.selected_bom_items; + const parsedBom = typeof savedBom === "string" ? JSON.parse(savedBom) : savedBom; + if (Array.isArray(parsedBom)) { + const matIds = new Set(bomMaterials.map((m) => m.id)); + const restored = new Set(); + const seenChildForLegacy = new Set(); + for (const saved of parsedBom) { + if (matIds.has(saved)) { + restored.add(saved); + } else { + const legacyKey = String(saved); + if (seenChildForLegacy.has(legacyKey)) continue; + const firstMat = bomMaterials.find((m) => m.child_item_id === legacyKey); + if (firstMat) { + restored.add(firstMat.id); + seenChildForLegacy.add(legacyKey); + } + } + } + setBomChecked(restored); + return; + } + } + setBomChecked(new Set()); + }, [open, bomMaterials, mode, editData]); + + useEffect(() => { + if (open) { + if (mode === "edit" && editData) { + setFormData({ ...editData }); + } else { + setFormData({ + detail_type: DETAIL_TYPES[0]?.value || "", + content: "", + is_required: "Y", + }); + } + } + }, [open, mode, editData]); + + useEffect(() => { + if (open && formData.detail_type === "material_input") { + loadBomMaterials(); + if (Object.keys(itemTypeCatMap).length === 0) { + apiClient.get("/table-categories/item_info/type/values").then(res => { + if (res.data?.success && res.data.data?.length) { + const map: Record = {}; + const flatten = (arr: any[]) => { for (const v of arr) { map[v.valueCode] = v.valueLabel; if (v.children?.length) flatten(v.children); } }; + flatten(res.data.data); + setItemTypeCatMap(map); + } + }).catch(() => {}); + } + } + }, [open, formData.detail_type, loadBomMaterials, itemTypeCatMap]); + + // 검사항목 적용 시 품목검사정보 로드 + useEffect(() => { + if (!open || formData.detail_type !== "inspection" || formData.process_inspection_apply !== "apply" || !selectedItemCode) { + setItemInspections([]); + return; + } + (async () => { + setItemInspLoading(true); + try { + const res = await apiClient.post("/table-management/tables/item_inspection_info/data", { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: selectedItemCode }] }, + autoFilter: true, + }); + setItemInspections(res.data?.data?.data || res.data?.data?.rows || []); + } catch { setItemInspections([]); } finally { setItemInspLoading(false); } + })(); + }, [open, formData.detail_type, formData.process_inspection_apply, selectedItemCode]); + + // 설비점검 적용 시 해당 공정의 설비 점검항목 로드 + useEffect(() => { + if (!open || formData.detail_type !== "equip_inspection" || formData.equip_inspection_apply !== "apply" || !selectedProcessCode) { + setEquipInspItems([]); + return; + } + (async () => { + setEquipInspLoading(true); + try { + // 1. 해당 공정의 설비 목록 조회 + const equipRes = await apiClient.post("/table-management/tables/process_equipment/data", { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [{ columnName: "process_code", operator: "equals", value: selectedProcessCode }] }, + autoFilter: true, + }); + const equipCodes = (equipRes.data?.data?.data || equipRes.data?.data?.rows || []).map((e: any) => e.equipment_code).filter(Boolean); + if (equipCodes.length === 0) { setEquipInspItems([]); return; } + // 2. 각 설비의 점검항목 조회 + const inspRes = await apiClient.post("/table-management/tables/equipment_inspection_item/data", { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "in", value: equipCodes }] }, + autoFilter: true, + }); + setEquipInspItems(inspRes.data?.data?.data || inspRes.data?.data?.rows || []); + } catch { setEquipInspItems([]); } finally { setEquipInspLoading(false); } + })(); + }, [open, formData.detail_type, formData.equip_inspection_apply, selectedProcessCode]); + + const updateField = (field: string, value: any) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + const handleSubmit = () => { + if (!formData.detail_type) return; + + const type = formData.detail_type; + + if (type === "checklist" && !formData.content?.trim()) return; + if (type === "inspection") { + if (!formData.process_inspection_apply) return; + if (formData.process_inspection_apply === "none" && !formData.content?.trim()) return; + } + if (type === "procedure" && !formData.content?.trim()) return; + if (type === "input" && !formData.content?.trim()) return; + if (type === "equip_inspection" && !formData.equip_inspection_apply) return; + if (type === "equip_condition" && !formData.content?.trim()) return; + + const submitData = { ...formData }; + + // 검사항목 적용 → 품목검사정보 각각 개별 등록 (다중행 자동생성) + if (type === "inspection" && submitData.process_inspection_apply === "apply") { + if (itemInspections.length > 0) { + for (const insp of itemInspections) { + onSubmit({ + ...submitData, + detail_type: "inspection", + content: `${insp.inspection_item_name || insp.inspection_standard || "-"} | ${insp.pass_criteria || ""}`.trim(), + is_required: submitData.is_required || "Y", + }); + } + onClose(); + return; + } + submitData.content = submitData.content || "품목별 검사정보 (자동 연동)"; + } + if (type === "lookup") { + submitData.content = submitData.content || "품목 등록 문서 (자동 연동)"; + } + // 설비점검 적용 → 점검항목 각각 개별 등록 (다중행 자동생성) + if (type === "equip_inspection" && submitData.equip_inspection_apply === "apply") { + if (equipInspItems.length > 0) { + for (const item of equipInspItems) { + const range = (item.lower_limit || item.upper_limit) ? `${item.lower_limit || ""} ~ ${item.upper_limit || ""}${item.unit ? ` ${item.unit}` : ""}` : ""; + onSubmit({ + ...submitData, + detail_type: "equip_inspection", + content: `${item.inspection_item || "-"}${range ? ` | ${range}` : ""}`.trim(), + is_required: submitData.is_required || "Y", + }); + } + onClose(); + return; + } + submitData.content = submitData.content || "설비 점검항목 (설비정보 연동)"; + } + if (type === "production_result") { + submitData.content = submitData.content || "작업수량 / 불량수량 / 양품수량"; + } + if (type === "material_input") { + // 선택된 BOM 자재를 각각 개별 상세 항목으로 등록 (다중행 자동생성) + const checkedMats = bomMaterials.filter(m => bomChecked.has(m.id)); + if (checkedMats.length > 0) { + const resolveType = (code: string) => itemTypeCatMap[code] || code || ""; + for (const mat of checkedMats) { + const typeLabel = resolveType(mat.child_item_type || ""); + const unitLabel = mat.detail_unit || mat.item_unit || ""; + onSubmit({ + ...submitData, + detail_type: "material_input", + content: `${mat.child_item_name || mat.child_item_id}${typeLabel ? ` ${typeLabel}` : ""} | ${mat.quantity || 1} ${unitLabel}`.trim(), + is_required: submitData.is_required || "Y", + bom_item_id: mat.child_item_id, + bom_item_name: mat.child_item_name || "", + bom_qty: String(mat.quantity || 1), + bom_unit: unitLabel, + }); + } + onClose(); + return; + } + submitData.content = submitData.content || "BOM 구성 자재 (자동 연동)"; + submitData.selected_bom_items = Array.from(bomChecked); + } + + onSubmit(submitData); + onClose(); + }; + + const currentType = formData.detail_type || ""; + + return ( + !v && onClose()}> + e.stopPropagation()}> + + + 상세 항목 {mode === "add" ? "추가" : "수정"} + + + 상세 항목의 유형을 선택하고 내용을 입력하세요 + + + +
+ {/* 유형 선택 */} +
+ + +
+ + {/* ============ 체크리스트 ============ */} + {currentType === "checklist" && ( +
+ + updateField("content", e.target.value)} + placeholder="예: 전원 상태 확인" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ )} + + {/* ============ 검사항목 ============ */} + {currentType === "inspection" && ( + <> + {/* 공정검사 적용 여부 */} +
+ +
+ + +
+
+ + {/* 적용 시: 품목검사정보에서 등록된 검사항목 표시 */} + {formData.process_inspection_apply === "apply" && ( +
+

+ 품목별 검사정보 (자동 연동) +

+ {itemInspLoading ? ( +
조회 중...
+ ) : itemInspections.length === 0 ? ( +

등록된 검사항목이 없습니다. 품목검사정보에서 먼저 등록해주세요.

+ ) : ( + + + + + + + + + + {itemInspections.map((insp, idx) => ( + + + + + + ))} + +
검사항목검사유형합격기준
{insp.inspection_item_name || insp.inspection_standard || "-"}{insp.inspection_type || "-"}{insp.pass_criteria || "-"}
+ )} +
+ )} + + {/* 미적용 시: 수동 입력 */} + {formData.process_inspection_apply === "none" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 외경 치수" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+
+ + updateField("inspection_method", e.target.value)} + placeholder="예: 마이크로미터" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + +
+
+ + {/* 기준값 ± 오차범위 + 자동수집 + PLC */} +
+
+
+ updateField("base_value", e.target.value)} + placeholder="기준값" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ ± +
+ updateField("tolerance", e.target.value)} + placeholder="오차범위" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ +
+ + +
+
+ + )} + + )} + + {/* ============ 작업절차 ============ */} + {currentType === "procedure" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 자재 투입" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + + updateField("duration_minutes", e.target.value ? Number(e.target.value) : undefined) + } + placeholder="예: 5" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + )} + + {/* ============ 직접입력 ============ */} + {currentType === "input" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 작업자 의견" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + +
+ + )} + + {/* ============ 문서참조 ============ */} + {currentType === "lookup" && ( + <> +
+ 📄 + + 해당 품목에 등록된 문서를 자동으로 불러옵니다. + +
+
+ +
+

+ 품목이 선택되지 않았습니다. +

+
+
+ + )} + + {/* ============ 설비점검 ============ */} + {currentType === "equip_inspection" && ( + <> +
+ 🏭 + + 공정에 지정된 설비를 자동 참조합니다. + +
+ +
+ +
+ + +
+
+ + {/* 적용 시: 설비 점검항목 표시 */} + {formData.equip_inspection_apply === "apply" && ( +
+

+ 설비 점검항목 (설비정보 연동) +

+ {equipInspLoading ? ( +
조회 중...
+ ) : equipInspItems.length === 0 ? ( +

해당 공정에 지정된 설비의 점검항목이 없습니다.

+ ) : ( + + + + + + + + + + + {equipInspItems.map((item, idx) => ( + + + + + + + ))} + +
설비코드점검항목점검방법기준범위
{item.equipment_code || "-"}{item.inspection_item || "-"}{item.inspection_method || "-"}{item.lower_limit || item.upper_limit ? `${item.lower_limit || ""} ~ ${item.upper_limit || ""}${item.unit ? ` ${item.unit}` : ""}` : "-"}
+ )} +
+ )} + + )} + + {/* ============ 설비조건 ============ */} + {currentType === "equip_condition" && ( + <> +
+ 🏭 + + 공정에 지정된 설비를 자동 참조합니다. + +
+ +
+ +
+ {/* 조건명 + 단위 */} +
+
+ updateField("content", e.target.value)} + placeholder="조건명 (예: 온도, 압력, RPM)" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ +
+
+ + {/* 기준값 ± 오차범위 */} +
+
+ updateField("condition_base_value", e.target.value)} + placeholder="기준값" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ ± +
+ updateField("condition_tolerance", e.target.value)} + placeholder="오차범위" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + {/* 자동수집 */} +
+ + +
+
+
+ + )} + + {/* ============ 실적등록 ============ */} + {currentType === "production_result" && ( +
+ +
+ {/* 작업수량 */} +
+
+ 📦 작업수량 + 생산된 전체 수량 +
+
+ + +
+
+ + {/* 불량수량 */} +
+
+ 🚫 불량수량 + 불량으로 판정된 수량 +
+
+ + +
+
+ + {/* 양품수량 */} +
+
+ ✅ 양품수량 + 작업수량 - 불량수량 +
+
+ + +
+
+
+
+ )} + + {/* ============ 자재투입 ============ */} + {currentType === "material_input" && ( +
+ +
+
+

+ 📦 BOM 구성 자재 (자동 연동) +

+ + {bomMaterials.length > 0 ? `${bomMaterials.length}건` : ""} + +
+ {bomMaterials.length > 0 && ( +
+ 0 + ? "indeterminate" + : false + } + onCheckedChange={(checked) => { + if (checked) { + setBomChecked(new Set(bomMaterials.map((m) => m.id))); + } else { + setBomChecked(new Set()); + } + }} + /> + + 전체 선택 ({bomChecked.size} / {bomMaterials.length}) + +
+ )} +
+ {bomLoading ? ( +
+ + BOM 데이터를 불러오는 중... +
+ ) : !selectedItemCode ? ( +
+ 품목을 먼저 선택하세요. +
+ ) : bomMaterials.length === 0 ? ( +
+ 해당 품목의 BOM 구성 자재가 없습니다. +
+ ) : ( + bomMaterials.map((mat) => { + const resolvedType = itemTypeCatMap[mat.child_item_type || ""] || mat.child_item_type || ""; + const typeColor = + resolvedType.includes("원자재") ? "#16a34a" + : resolvedType.includes("반제품") ? "#2563eb" + : resolvedType.includes("제품") ? "#9333ea" + : "#6b7280"; + return ( +
+ { + setBomChecked((prev) => { + const next = new Set(prev); + if (checked) next.add(mat.id); + else next.delete(mat.id); + return next; + }); + }} + /> +
+
+ {mat.child_item_type && ( + + {resolvedType} + + )} + + {mat.child_item_code || "-"} + + + {mat.child_item_name || "(이름 없음)"} + +
+
+ 소요량: {mat.quantity || "0"} {mat.detail_unit || mat.item_unit || ""} + {mat.process_type ? ` | 공정: ${mat.process_type}` : ""} +
+
+
+ ); + }) + )} +
+
+
+ )} + + {/* 필수 여부 (모든 유형 공통) */} + {currentType && ( +
+ + +
+ )} +
+ + + + + +
+
+ ); +} + +// ============================================================ +// 메인 모달 +// ============================================================ export function WorkStandardEditModal({ open, onClose, @@ -122,9 +1152,12 @@ export function WorkStandardEditModal({ const [detailModalOpen, setDetailModalOpen] = useState(false); const [detailModalMode, setDetailModalMode] = useState<"add" | "edit">("add"); const [detailModalPhase, setDetailModalPhase] = useState("PRE"); - const [detailFormData, setDetailFormData] = useState>({}); const [editingDetail, setEditingDetail] = useState(null); + // 드래그 정렬용 state (phase + workItem 조합으로 스코프 관리) + const [dragIdx, setDragIdx] = useState(null); + const [overIdx, setOverIdx] = useState(null); + // 데이터 로드 const loadData = useCallback(async () => { if (!workInstructionNo || !routingVersionId) return; @@ -163,21 +1196,16 @@ export function WorkStandardEditModal({ return map; }, [currentProcess]); - // 커스텀 복사 확인 후 수정 + // 커스텀 전환 — 로컬 마킹만 수행 (TASK:ERP-012 수정분 보존) + // 기존에는 copyWorkStandard API 호출 + loadData()로 state 전면 재로딩을 해서 + // closure로 잡은 workItem id / detail id가 모두 stale해져서 + // ① 삭제가 무효화되어 UI상 상세가 0개로 보이고 + // ② 추가 state 변경이 반영되지 않아 저장이 먹통이 됨. + // saveWIWorkStandard는 DELETE→INSERT 방식으로 전체 덮어쓰므로 사전 복사 불필요. const ensureCustom = useCallback(async () => { - if (isCustom) return true; - try { - const res = await copyWorkStandard(workInstructionNo, routingVersionId); - if (res.success) { - await loadData(); - setIsCustom(true); - return true; - } - } catch (err) { - toast.error("원본 복사에 실패했습니다"); - } - return false; - }, [isCustom, workInstructionNo, routingVersionId, loadData]); + if (!isCustom) setIsCustom(true); + return true; + }, [isCustom]); // 작업항목 추가 모달 열기 const openAddWorkItem = useCallback((phaseKey: string) => { @@ -204,7 +1232,6 @@ export function WorkStandardEditModal({ if (!ok || !currentProcess) return; if (editingWorkItem) { - // 수정 setProcesses(prev => { const next = [...prev]; const workItems = [...next[selectedProcessIdx].workItems]; @@ -216,7 +1243,6 @@ export function WorkStandardEditModal({ return next; }); } else { - // 추가 const phaseKey = addItemPhase === "IN" ? "IN" : addItemPhase; const phaseItems = currentProcess.workItems.filter( wi => wi.work_phase === phaseKey || (phaseKey === "IN" && wi.work_phase === "MAIN") @@ -271,11 +1297,6 @@ export function WorkStandardEditModal({ setDetailModalPhase(phaseKey); setDetailModalMode("add"); setEditingDetail(null); - setDetailFormData({ - detail_type: DETAIL_TYPES[0].value, - content: "", - is_required: "Y", - }); setDetailModalOpen(true); }, []); @@ -284,37 +1305,13 @@ export function WorkStandardEditModal({ setDetailModalPhase(phaseKey); setDetailModalMode("edit"); setEditingDetail(detail); - setDetailFormData({ ...detail }); setDetailModalOpen(true); }, []); - // 상세 추가/수정 처리 - const handleSaveDetail = useCallback(async () => { - const type = detailFormData.detail_type || ""; - if (!type) return; - - // 유효성 검사 (내용이 필요한 유형) - const needsContent = ["checklist", "procedure", "input", "equip_condition"]; - if (needsContent.includes(type) && !detailFormData.content?.trim()) { - toast.error("내용을 입력하세요"); - return; - } - if (type === "inspection" && !detailFormData.content?.trim()) { - toast.error("검사 항목명을 입력하세요"); - return; - } - + // 상세 추가 처리 — 다중행 자동생성 대응 (DetailFormModalInner의 onSubmit이 for-loop로 여러 번 호출) + const handleAddDetailRow = useCallback((data: Partial) => { const workItemId = selectedWorkItemIdByPhase[detailModalPhase]; if (!workItemId) return; - const ok = await ensureCustom(); - if (!ok) return; - - // content 자동 설정 (UI에서 직접 입력이 없는 유형들) - const submitData = { ...detailFormData }; - if (type === "lookup") submitData.content = submitData.content || "품목 등록 문서 (자동 연동)"; - if (type === "equip_inspection") submitData.content = submitData.content || "설비점검"; - if (type === "production_result") submitData.content = submitData.content || "작업수량 / 불량수량 / 양품수량"; - if (type === "material_input") submitData.content = submitData.content || "BOM 구성 자재 (자동 연동)"; setProcesses(prev => { const next = [...prev]; @@ -322,33 +1319,74 @@ export function WorkStandardEditModal({ const wiIdx = workItems.findIndex(wi => wi.id === workItemId); if (wiIdx < 0) return prev; - if (detailModalMode === "edit" && editingDetail) { - // 수정 - const details = (workItems[wiIdx].details || []).map(d => - d.id === editingDetail.id ? { ...d, ...submitData } : d - ); + const currentDetails = workItems[wiIdx].details || []; + const newDetail: WIWorkItemDetail = { + ...data, + id: `temp-detail-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + work_item_id: workItemId, + sort_order: currentDetails.length + 1, + }; + workItems[wiIdx] = { + ...workItems[wiIdx], + details: [...currentDetails, newDetail], + detail_count: currentDetails.length + 1, + }; + next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; + return next; + }); + setDirty(true); + }, [detailModalPhase, selectedWorkItemIdByPhase, selectedProcessIdx]); + + // 상세 수정 처리 + const handleUpdateDetailRow = useCallback((data: Partial) => { + const workItemId = selectedWorkItemIdByPhase[detailModalPhase]; + if (!workItemId || !editingDetail) return; + + setProcesses(prev => { + const next = [...prev]; + const workItems = [...next[selectedProcessIdx].workItems]; + const wiIdx = workItems.findIndex(wi => wi.id === workItemId); + if (wiIdx < 0) return prev; + + // 같은 유형의 apply 그룹 전체 일괄 업데이트 (필수여부 등) — 원본 WorkItemDetailList 로직과 동일 + const isApplyGroup = + (editingDetail.detail_type === "inspection" && editingDetail.process_inspection_apply === "apply") || + (editingDetail.detail_type === "equip_inspection" && editingDetail.equip_inspection_apply === "apply") || + editingDetail.detail_type === "material_input"; + + if (isApplyGroup && data.is_required !== undefined) { + const details = (workItems[wiIdx].details || []).map(d => { + const isSibling = + d.detail_type === editingDetail.detail_type && ( + d.detail_type === "inspection" ? d.process_inspection_apply === "apply" : + d.detail_type === "equip_inspection" ? d.equip_inspection_apply === "apply" : + d.detail_type === "material_input" + ); + return isSibling ? { ...d, is_required: data.is_required! } : d; + }); workItems[wiIdx] = { ...workItems[wiIdx], details }; } else { - // 추가 - const newDetail: WIWorkItemDetail = { - ...submitData, - id: `temp-detail-${Date.now()}`, - work_item_id: workItemId, - sort_order: (workItems[wiIdx].details?.length || 0) + 1, - }; - workItems[wiIdx] = { - ...workItems[wiIdx], - details: [...(workItems[wiIdx].details || []), newDetail], - detail_count: (workItems[wiIdx].detail_count || 0) + 1, - }; + const details = (workItems[wiIdx].details || []).map(d => + d.id === editingDetail.id ? { ...d, ...data } : d + ); + workItems[wiIdx] = { ...workItems[wiIdx], details }; } next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; return next; }); - - setDetailModalOpen(false); setDirty(true); - }, [detailFormData, detailModalPhase, detailModalMode, editingDetail, selectedWorkItemIdByPhase, ensureCustom, selectedProcessIdx]); + }, [detailModalPhase, selectedWorkItemIdByPhase, selectedProcessIdx, editingDetail]); + + // DetailFormModalInner onSubmit 분기 진입점 + const handleDetailSubmit = useCallback(async (data: Partial) => { + const ok = await ensureCustom(); + if (!ok) return; + if (detailModalMode === "add") { + handleAddDetailRow(data); + } else { + handleUpdateDetailRow(data); + } + }, [detailModalMode, handleAddDetailRow, handleUpdateDetailRow, ensureCustom]); // 상세 삭제 const handleDeleteDetail = useCallback(async (detailId: string, phaseKey: string) => { @@ -374,6 +1412,25 @@ export function WorkStandardEditModal({ setDirty(true); }, [selectedWorkItemIdByPhase, ensureCustom, selectedProcessIdx]); + // 드래그 정렬 — 상세 목록 순서 변경 + const handleReorderDetails = useCallback((phaseKey: string, reordered: WIWorkItemDetail[]) => { + const workItemId = selectedWorkItemIdByPhase[phaseKey]; + if (!workItemId) return; + setProcesses(prev => { + const next = [...prev]; + const workItems = [...next[selectedProcessIdx].workItems]; + const wiIdx = workItems.findIndex(wi => wi.id === workItemId); + if (wiIdx >= 0) { + // sort_order 재할당 + const withOrder = reordered.map((d, i) => ({ ...d, sort_order: i + 1 })); + workItems[wiIdx] = { ...workItems[wiIdx], details: withOrder }; + } + next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; + return next; + }); + setDirty(true); + }, [selectedWorkItemIdByPhase, selectedProcessIdx]); + // 저장 const handleSave = useCallback(async () => { if (!currentProcess) return; @@ -394,7 +1451,7 @@ export function WorkStandardEditModal({ } else { toast.error("저장에 실패했습니다"); } - } catch (err) { + } catch { toast.error("저장 중 오류가 발생했습니다"); } finally { setSaving(false); @@ -410,15 +1467,11 @@ export function WorkStandardEditModal({ toast.success("원본으로 초기화되었습니다"); await loadData(); } - } catch (err) { + } catch { toast.error("초기화에 실패했습니다"); } }, [workInstructionNo, loadData]); - const updateDetailField = (field: string, value: unknown) => { - setDetailFormData(prev => ({ ...prev, [field]: value })); - }; - return ( { if (!v) onClose(); }}> @@ -473,6 +1526,7 @@ export function WorkStandardEditModal({ const phaseItems = workItemsByPhase[phase.key] || []; const selectedWiId = selectedWorkItemIdByPhase[phase.key] || null; const selectedWi = phaseItems.find(wi => wi.id === selectedWiId) || null; + const selectedDetails = selectedWi?.details || []; return (
@@ -555,7 +1609,7 @@ export function WorkStandardEditModal({ )}
- {/* 우측: 상세 테이블 */} + {/* 우측: 상세 테이블 — 드래그 정렬 지원 */}
{!selectedWi ? (
@@ -568,7 +1622,7 @@ export function WorkStandardEditModal({
{selectedWi.title} - {selectedWi.details?.length || 0}개 + {selectedDetails.length}개
- {/* 상세 추가/수정 다이얼로그 (유형별 동적 필드) */} - { if (!v) setDetailModalOpen(false); }}> - e.stopPropagation()}> - - - 상세 항목 {detailModalMode === "add" ? "추가" : "수정"} - - - 상세 항목의 유형을 선택하고 내용을 입력하세요 - - - -
- {/* 유형 선택 */} -
- - -
- - {/* 체크리스트 */} - {detailFormData.detail_type === "checklist" && ( -
- - updateDetailField("content", e.target.value)} - placeholder="예: 전원 상태 확인" - className="mt-1 h-8 text-xs" - /> -
- )} - - {/* 검사항목 */} - {detailFormData.detail_type === "inspection" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="예: 외경 치수" - className="mt-1 h-8 text-xs" - /> -
-
-
- - updateDetailField("inspection_method", e.target.value)} - placeholder="예: 마이크로미터" - className="mt-1 h-8 text-xs" - /> -
-
- - -
-
-
-
-
- updateDetailField("base_value", e.target.value)} - placeholder="기준값" - className="h-8 text-xs" - /> -
- ± -
- updateDetailField("tolerance", e.target.value)} - placeholder="오차범위" - className="h-8 text-xs" - /> -
-
-
- - )} - - {/* 작업절차 */} - {detailFormData.detail_type === "procedure" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="예: 자재 투입" - className="mt-1 h-8 text-xs" - /> -
-
- - updateDetailField("duration_minutes", e.target.value ? Number(e.target.value) : undefined)} - placeholder="예: 5" - className="mt-1 h-8 text-xs" - /> -
- - )} - - {/* 직접입력 */} - {detailFormData.detail_type === "input" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="예: 작업자 의견" - className="mt-1 h-8 text-xs" - /> -
-
- - -
- - )} - - {/* 문서참조 */} - {detailFormData.detail_type === "lookup" && ( -
- - 해당 품목에 등록된 문서를 자동으로 불러옵니다. - -
- )} - - {/* 설비점검 */} - {detailFormData.detail_type === "equip_inspection" && ( -
- - updateDetailField("content", e.target.value)} - placeholder="예: 설비 가동 전 안전 점검" - className="mt-1 h-8 text-xs" - /> -
- )} - - {/* 설비조건 */} - {detailFormData.detail_type === "equip_condition" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="조건명 (예: 온도, 압력, RPM)" - className="mt-1 h-8 text-xs" - /> -
-
-
-
- -
-
-
-
- updateDetailField("condition_base_value", e.target.value)} - placeholder="기준값" - className="h-8 text-xs" - /> -
- ± -
- updateDetailField("condition_tolerance", e.target.value)} - placeholder="오차범위" - className="h-8 text-xs" - /> -
-
-
- - )} - - {/* 실적등록 */} - {detailFormData.detail_type === "production_result" && ( -
-

작업수량 / 불량수량 / 양품수량

-

실적 입력 항목이 자동으로 생성됩니다.

-
- )} - - {/* 자재투입 */} - {detailFormData.detail_type === "material_input" && ( -
-

BOM 구성 자재 (자동 연동)

-

품목에 등록된 BOM 구성 자재가 자동으로 적용됩니다.

-
- )} - - {/* 필수 여부 (모든 유형 공통) */} - {detailFormData.detail_type && ( -
- - -
- )} - - {/* 비고 (모든 유형 공통) */} - {detailFormData.detail_type && ( -
- - updateDetailField("remark", e.target.value)} - placeholder="비고 입력" - className="mt-1 h-8 text-xs" - /> -
- )} -
- - - - - -
-
+ {/* 상세 추가/수정 다이얼로그 — 원본 DetailFormModal 로직을 이식 */} + setDetailModalOpen(false)} + onSubmit={handleDetailSubmit} + editData={editingDetail} + mode={detailModalMode} + selectedItemCode={itemCode} + selectedProcessCode={currentProcess?.process_code} + /> ); diff --git a/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx index 2c71ed02..74fb5981 100644 --- a/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx @@ -13,6 +13,7 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen import { Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy, + ChevronUp, ChevronDown, ChevronsUpDown, } from "lucide-react"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { cn } from "@/lib/utils"; @@ -472,6 +473,34 @@ export default function ItemInspectionInfoPage() { return Object.values(map); }, [data]); + // 좌측 품목 목록 정렬 (컬럼 헤더 클릭 → asc → desc → 해제 순환) + const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); + const handleSort = (key: string) => { + setSortConfig(prev => { + if (!prev || prev.key !== key) return { key, direction: "asc" }; + if (prev.direction === "asc") return { key, direction: "desc" }; + return null; + }); + }; + const sortedGroupedData = useMemo(() => { + if (!sortConfig) return groupedData; + const { key, direction } = sortConfig; + const mul = direction === "asc" ? 1 : -1; + const getVal = (g: any): string => { + if (key === "inspection_type") { + const code = g.types[0] || ""; + return inspTypeCatOptions.find(o => o.code === code)?.label || code; + } + if (key === "is_active") { + return (g.is_active === "사용" || g.is_active === "true") ? "사용" : "미사용"; + } + return String(g[key] ?? ""); + }; + return [...groupedData].sort((a, b) => { + return getVal(a).localeCompare(getVal(b), "ko", { numeric: true }) * mul; + }); + }, [groupedData, sortConfig, inspTypeCatOptions]); + // 선택된 품목의 그룹 데이터 const selectedGroup = useMemo(() => { if (!selectedItemCode) return null; @@ -989,14 +1018,28 @@ export default function ItemInspectionInfoPage() { {ts.visibleColumns.map((col) => ( - - {col.label} + handleSort(col.key)} + > +
+ {col.label} + {sortConfig?.key === col.key ? ( + sortConfig.direction === "asc" + ? + : + ) : ( + + )} +
))}
- {groupedData.map((group) => ( + {sortedGroupedData.map((group) => ( a.toLowerCase()); + if (!allowed.includes(val.toLowerCase())) return false; } else if (f.operator === "between") { const [from, to] = f.value.split("|"); if (from && val < from) return false; diff --git a/frontend/app/(main)/COMPANY_16/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/COMPANY_16/production/work-instruction/WorkStandardEditModal.tsx index da7e8fd5..96b96535 100644 --- a/frontend/app/(main)/COMPANY_16/production/work-instruction/WorkStandardEditModal.tsx +++ b/frontend/app/(main)/COMPANY_16/production/work-instruction/WorkStandardEditModal.tsx @@ -15,9 +15,11 @@ import { } from "lucide-react"; import { toast } from "sonner"; import { - getWIWorkStandard, copyWorkStandard, saveWIWorkStandard, resetWIWorkStandard, + getWIWorkStandard, saveWIWorkStandard, resetWIWorkStandard, WIWorkItem, WIWorkItemDetail, WIWorkStandardProcess, } from "@/lib/api/workInstruction"; +import { getBomMaterials, BomMaterial } from "@/lib/api/processInfo"; +import { apiClient } from "@/lib/api/client"; interface WorkStandardEditModalProps { open: boolean; @@ -60,12 +62,46 @@ const UNIT_OPTIONS = [ "L/min", "Hz", "dB", "ea", "g", "mg", "ml", "L", ]; +// PLC 데이터 옵션 (원본 DetailFormModal과 동일) +const PLC_DATA_OPTIONS = [ + { value: "PLC_TEMP_01", label: "온도 (PLC_TEMP_01)" }, + { value: "PLC_PRES_01", label: "압력 (PLC_PRES_01)" }, + { value: "PLC_RPM_01", label: "회전수 (PLC_RPM_01)" }, + { value: "PLC_TORQ_01", label: "토크 (PLC_TORQ_01)" }, + { value: "PLC_SPD_01", label: "속도 (PLC_SPD_01)" }, + { value: "PLC_CUR_01", label: "전류 (PLC_CUR_01)" }, + { value: "PLC_VOLT_01", label: "전압 (PLC_VOLT_01)" }, + { value: "PLC_VIB_01", label: "진동 (PLC_VIB_01)" }, + { value: "PLC_HUM_01", label: "습도 (PLC_HUM_01)" }, + { value: "PLC_FLOW_01", label: "유량 (PLC_FLOW_01)" }, +]; + +const PLC_PRODUCTION_OPTIONS: Record = { + work_qty: [ + { value: "PLC_CNT_01", label: "생산카운터 (PLC_CNT_01)" }, + { value: "PLC_CNT_02", label: "완료카운터 (PLC_CNT_02)" }, + { value: "PLC_QTY_01", label: "작업수량 (PLC_QTY_01)" }, + ], + defect_qty: [ + { value: "PLC_NG_01", label: "불량카운터 (PLC_NG_01)" }, + { value: "PLC_NG_02", label: "NG감지기 (PLC_NG_02)" }, + { value: "PLC_REJ_01", label: "리젝트수 (PLC_REJ_01)" }, + ], + good_qty: [ + { value: "PLC_OK_01", label: "양품카운터 (PLC_OK_01)" }, + { value: "PLC_OK_02", label: "합격카운터 (PLC_OK_02)" }, + { value: "PLC_GOOD_01", label: "양품수량 (PLC_GOOD_01)" }, + ], +}; + const getDetailTypeLabel = (type: string) => DETAIL_TYPES.find(d => d.value === type)?.label || type; +// 원본 WorkItemDetailList의 getContentSummary를 그대로 이식 const getContentSummary = (detail: WIWorkItemDetail): string => { const type = detail.detail_type; if (type === "inspection") { + if (detail.process_inspection_apply === "apply") return detail.content || "품목별 검사정보 (자동 연동)"; const parts = [detail.content]; if (detail.inspection_method) parts.push(`[${detail.inspection_method}]`); if (detail.base_value) { @@ -81,7 +117,9 @@ const getContentSummary = (detail: WIWorkItemDetail): string => { return `${detail.content} [${typeMap[detail.input_type] || detail.input_type}]`; } if (type === "lookup") return "품목 등록 문서 (자동 연동)"; - if (type === "equip_inspection") return detail.content || "설비점검"; + 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) { @@ -90,10 +128,1002 @@ const getContentSummary = (detail: WIWorkItemDetail): string => { return parts.join(" "); } if (type === "production_result") return "작업수량 / 불량수량 / 양품수량"; - if (type === "material_input") return "BOM 구성 자재 (자동 연동)"; + if (type === "material_input") return detail.content || "BOM 구성 자재 (자동 연동)"; return detail.content || "-"; }; +// ============================================================ +// 상세 항목 추가/수정 모달 — 원본 DetailFormModal을 내부에 이식 +// 원본: frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx +// ============================================================ +interface DetailFormModalInnerProps { + open: boolean; + onClose: () => void; + onSubmit: (data: Partial) => void; + editData?: WIWorkItemDetail | null; + mode: "add" | "edit"; + selectedItemCode?: string; + selectedProcessCode?: string; +} + +function DetailFormModalInner({ + open, + onClose, + onSubmit, + editData, + mode, + selectedItemCode, + selectedProcessCode, +}: DetailFormModalInnerProps) { + const [formData, setFormData] = useState>({}); + const [bomMaterials, setBomMaterials] = useState([]); + const [bomLoading, setBomLoading] = useState(false); + const [bomChecked, setBomChecked] = useState>(new Set()); + + // 품목검사정보 연동 + const [itemInspections, setItemInspections] = useState([]); + const [itemInspLoading, setItemInspLoading] = useState(false); + + // 품목 카테고리 (type 코드→라벨) + const [itemTypeCatMap, setItemTypeCatMap] = useState>({}); + + // 설비 점검항목 연동 + const [equipInspItems, setEquipInspItems] = useState([]); + const [equipInspLoading, setEquipInspLoading] = useState(false); + + const loadBomMaterials = useCallback(async () => { + if (!selectedItemCode) { + setBomMaterials([]); + return; + } + setBomLoading(true); + try { + const res = await getBomMaterials(selectedItemCode); + if (res.success && res.data) { + setBomMaterials(res.data); + } else { + setBomMaterials([]); + } + } catch { + setBomMaterials([]); + } finally { + setBomLoading(false); + } + }, [selectedItemCode]); + + // BOM 자재 로드 완료 후 체크 상태 초기화 + useEffect(() => { + if (!open || bomMaterials.length === 0) return; + if (mode === "edit" && editData?.selected_bom_items) { + const savedBom = editData.selected_bom_items; + const parsedBom = typeof savedBom === "string" ? JSON.parse(savedBom) : savedBom; + if (Array.isArray(parsedBom)) { + const matIds = new Set(bomMaterials.map((m) => m.id)); + const restored = new Set(); + const seenChildForLegacy = new Set(); + for (const saved of parsedBom) { + if (matIds.has(saved)) { + restored.add(saved); + } else { + const legacyKey = String(saved); + if (seenChildForLegacy.has(legacyKey)) continue; + const firstMat = bomMaterials.find((m) => m.child_item_id === legacyKey); + if (firstMat) { + restored.add(firstMat.id); + seenChildForLegacy.add(legacyKey); + } + } + } + setBomChecked(restored); + return; + } + } + setBomChecked(new Set()); + }, [open, bomMaterials, mode, editData]); + + useEffect(() => { + if (open) { + if (mode === "edit" && editData) { + setFormData({ ...editData }); + } else { + setFormData({ + detail_type: DETAIL_TYPES[0]?.value || "", + content: "", + is_required: "Y", + }); + } + } + }, [open, mode, editData]); + + useEffect(() => { + if (open && formData.detail_type === "material_input") { + loadBomMaterials(); + if (Object.keys(itemTypeCatMap).length === 0) { + apiClient.get("/table-categories/item_info/type/values").then(res => { + if (res.data?.success && res.data.data?.length) { + const map: Record = {}; + const flatten = (arr: any[]) => { for (const v of arr) { map[v.valueCode] = v.valueLabel; if (v.children?.length) flatten(v.children); } }; + flatten(res.data.data); + setItemTypeCatMap(map); + } + }).catch(() => {}); + } + } + }, [open, formData.detail_type, loadBomMaterials, itemTypeCatMap]); + + // 검사항목 적용 시 품목검사정보 로드 + useEffect(() => { + if (!open || formData.detail_type !== "inspection" || formData.process_inspection_apply !== "apply" || !selectedItemCode) { + setItemInspections([]); + return; + } + (async () => { + setItemInspLoading(true); + try { + const res = await apiClient.post("/table-management/tables/item_inspection_info/data", { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: selectedItemCode }] }, + autoFilter: true, + }); + setItemInspections(res.data?.data?.data || res.data?.data?.rows || []); + } catch { setItemInspections([]); } finally { setItemInspLoading(false); } + })(); + }, [open, formData.detail_type, formData.process_inspection_apply, selectedItemCode]); + + // 설비점검 적용 시 해당 공정의 설비 점검항목 로드 + useEffect(() => { + if (!open || formData.detail_type !== "equip_inspection" || formData.equip_inspection_apply !== "apply" || !selectedProcessCode) { + setEquipInspItems([]); + return; + } + (async () => { + setEquipInspLoading(true); + try { + // 1. 해당 공정의 설비 목록 조회 + const equipRes = await apiClient.post("/table-management/tables/process_equipment/data", { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [{ columnName: "process_code", operator: "equals", value: selectedProcessCode }] }, + autoFilter: true, + }); + const equipCodes = (equipRes.data?.data?.data || equipRes.data?.data?.rows || []).map((e: any) => e.equipment_code).filter(Boolean); + if (equipCodes.length === 0) { setEquipInspItems([]); return; } + // 2. 각 설비의 점검항목 조회 + const inspRes = await apiClient.post("/table-management/tables/equipment_inspection_item/data", { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "in", value: equipCodes }] }, + autoFilter: true, + }); + setEquipInspItems(inspRes.data?.data?.data || inspRes.data?.data?.rows || []); + } catch { setEquipInspItems([]); } finally { setEquipInspLoading(false); } + })(); + }, [open, formData.detail_type, formData.equip_inspection_apply, selectedProcessCode]); + + const updateField = (field: string, value: any) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + const handleSubmit = () => { + if (!formData.detail_type) return; + + const type = formData.detail_type; + + if (type === "checklist" && !formData.content?.trim()) return; + if (type === "inspection") { + if (!formData.process_inspection_apply) return; + if (formData.process_inspection_apply === "none" && !formData.content?.trim()) return; + } + if (type === "procedure" && !formData.content?.trim()) return; + if (type === "input" && !formData.content?.trim()) return; + if (type === "equip_inspection" && !formData.equip_inspection_apply) return; + if (type === "equip_condition" && !formData.content?.trim()) return; + + const submitData = { ...formData }; + + // 검사항목 적용 → 품목검사정보 각각 개별 등록 (다중행 자동생성) + if (type === "inspection" && submitData.process_inspection_apply === "apply") { + if (itemInspections.length > 0) { + for (const insp of itemInspections) { + onSubmit({ + ...submitData, + detail_type: "inspection", + content: `${insp.inspection_item_name || insp.inspection_standard || "-"} | ${insp.pass_criteria || ""}`.trim(), + is_required: submitData.is_required || "Y", + }); + } + onClose(); + return; + } + submitData.content = submitData.content || "품목별 검사정보 (자동 연동)"; + } + if (type === "lookup") { + submitData.content = submitData.content || "품목 등록 문서 (자동 연동)"; + } + // 설비점검 적용 → 점검항목 각각 개별 등록 (다중행 자동생성) + if (type === "equip_inspection" && submitData.equip_inspection_apply === "apply") { + if (equipInspItems.length > 0) { + for (const item of equipInspItems) { + const range = (item.lower_limit || item.upper_limit) ? `${item.lower_limit || ""} ~ ${item.upper_limit || ""}${item.unit ? ` ${item.unit}` : ""}` : ""; + onSubmit({ + ...submitData, + detail_type: "equip_inspection", + content: `${item.inspection_item || "-"}${range ? ` | ${range}` : ""}`.trim(), + is_required: submitData.is_required || "Y", + }); + } + onClose(); + return; + } + submitData.content = submitData.content || "설비 점검항목 (설비정보 연동)"; + } + if (type === "production_result") { + submitData.content = submitData.content || "작업수량 / 불량수량 / 양품수량"; + } + if (type === "material_input") { + // 선택된 BOM 자재를 각각 개별 상세 항목으로 등록 (다중행 자동생성) + const checkedMats = bomMaterials.filter(m => bomChecked.has(m.id)); + if (checkedMats.length > 0) { + const resolveType = (code: string) => itemTypeCatMap[code] || code || ""; + for (const mat of checkedMats) { + const typeLabel = resolveType(mat.child_item_type || ""); + const unitLabel = mat.detail_unit || mat.item_unit || ""; + onSubmit({ + ...submitData, + detail_type: "material_input", + content: `${mat.child_item_name || mat.child_item_id}${typeLabel ? ` ${typeLabel}` : ""} | ${mat.quantity || 1} ${unitLabel}`.trim(), + is_required: submitData.is_required || "Y", + bom_item_id: mat.child_item_id, + bom_item_name: mat.child_item_name || "", + bom_qty: String(mat.quantity || 1), + bom_unit: unitLabel, + }); + } + onClose(); + return; + } + submitData.content = submitData.content || "BOM 구성 자재 (자동 연동)"; + submitData.selected_bom_items = Array.from(bomChecked); + } + + onSubmit(submitData); + onClose(); + }; + + const currentType = formData.detail_type || ""; + + return ( + !v && onClose()}> + e.stopPropagation()}> + + + 상세 항목 {mode === "add" ? "추가" : "수정"} + + + 상세 항목의 유형을 선택하고 내용을 입력하세요 + + + +
+ {/* 유형 선택 */} +
+ + +
+ + {/* ============ 체크리스트 ============ */} + {currentType === "checklist" && ( +
+ + updateField("content", e.target.value)} + placeholder="예: 전원 상태 확인" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ )} + + {/* ============ 검사항목 ============ */} + {currentType === "inspection" && ( + <> + {/* 공정검사 적용 여부 */} +
+ +
+ + +
+
+ + {/* 적용 시: 품목검사정보에서 등록된 검사항목 표시 */} + {formData.process_inspection_apply === "apply" && ( +
+

+ 품목별 검사정보 (자동 연동) +

+ {itemInspLoading ? ( +
조회 중...
+ ) : itemInspections.length === 0 ? ( +

등록된 검사항목이 없습니다. 품목검사정보에서 먼저 등록해주세요.

+ ) : ( + + + + + + + + + + {itemInspections.map((insp, idx) => ( + + + + + + ))} + +
검사항목검사유형합격기준
{insp.inspection_item_name || insp.inspection_standard || "-"}{insp.inspection_type || "-"}{insp.pass_criteria || "-"}
+ )} +
+ )} + + {/* 미적용 시: 수동 입력 */} + {formData.process_inspection_apply === "none" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 외경 치수" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+
+ + updateField("inspection_method", e.target.value)} + placeholder="예: 마이크로미터" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + +
+
+ + {/* 기준값 ± 오차범위 + 자동수집 + PLC */} +
+
+
+ updateField("base_value", e.target.value)} + placeholder="기준값" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ ± +
+ updateField("tolerance", e.target.value)} + placeholder="오차범위" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ +
+ + +
+
+ + )} + + )} + + {/* ============ 작업절차 ============ */} + {currentType === "procedure" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 자재 투입" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + + updateField("duration_minutes", e.target.value ? Number(e.target.value) : undefined) + } + placeholder="예: 5" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + )} + + {/* ============ 직접입력 ============ */} + {currentType === "input" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 작업자 의견" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + +
+ + )} + + {/* ============ 문서참조 ============ */} + {currentType === "lookup" && ( + <> +
+ 📄 + + 해당 품목에 등록된 문서를 자동으로 불러옵니다. + +
+
+ +
+

+ 품목이 선택되지 않았습니다. +

+
+
+ + )} + + {/* ============ 설비점검 ============ */} + {currentType === "equip_inspection" && ( + <> +
+ 🏭 + + 공정에 지정된 설비를 자동 참조합니다. + +
+ +
+ +
+ + +
+
+ + {/* 적용 시: 설비 점검항목 표시 */} + {formData.equip_inspection_apply === "apply" && ( +
+

+ 설비 점검항목 (설비정보 연동) +

+ {equipInspLoading ? ( +
조회 중...
+ ) : equipInspItems.length === 0 ? ( +

해당 공정에 지정된 설비의 점검항목이 없습니다.

+ ) : ( + + + + + + + + + + + {equipInspItems.map((item, idx) => ( + + + + + + + ))} + +
설비코드점검항목점검방법기준범위
{item.equipment_code || "-"}{item.inspection_item || "-"}{item.inspection_method || "-"}{item.lower_limit || item.upper_limit ? `${item.lower_limit || ""} ~ ${item.upper_limit || ""}${item.unit ? ` ${item.unit}` : ""}` : "-"}
+ )} +
+ )} + + )} + + {/* ============ 설비조건 ============ */} + {currentType === "equip_condition" && ( + <> +
+ 🏭 + + 공정에 지정된 설비를 자동 참조합니다. + +
+ +
+ +
+ {/* 조건명 + 단위 */} +
+
+ updateField("content", e.target.value)} + placeholder="조건명 (예: 온도, 압력, RPM)" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ +
+
+ + {/* 기준값 ± 오차범위 */} +
+
+ updateField("condition_base_value", e.target.value)} + placeholder="기준값" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ ± +
+ updateField("condition_tolerance", e.target.value)} + placeholder="오차범위" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + {/* 자동수집 */} +
+ + +
+
+
+ + )} + + {/* ============ 실적등록 ============ */} + {currentType === "production_result" && ( +
+ +
+ {/* 작업수량 */} +
+
+ 📦 작업수량 + 생산된 전체 수량 +
+
+ + +
+
+ + {/* 불량수량 */} +
+
+ 🚫 불량수량 + 불량으로 판정된 수량 +
+
+ + +
+
+ + {/* 양품수량 */} +
+
+ ✅ 양품수량 + 작업수량 - 불량수량 +
+
+ + +
+
+
+
+ )} + + {/* ============ 자재투입 ============ */} + {currentType === "material_input" && ( +
+ +
+
+

+ 📦 BOM 구성 자재 (자동 연동) +

+ + {bomMaterials.length > 0 ? `${bomMaterials.length}건` : ""} + +
+ {bomMaterials.length > 0 && ( +
+ 0 + ? "indeterminate" + : false + } + onCheckedChange={(checked) => { + if (checked) { + setBomChecked(new Set(bomMaterials.map((m) => m.id))); + } else { + setBomChecked(new Set()); + } + }} + /> + + 전체 선택 ({bomChecked.size} / {bomMaterials.length}) + +
+ )} +
+ {bomLoading ? ( +
+ + BOM 데이터를 불러오는 중... +
+ ) : !selectedItemCode ? ( +
+ 품목을 먼저 선택하세요. +
+ ) : bomMaterials.length === 0 ? ( +
+ 해당 품목의 BOM 구성 자재가 없습니다. +
+ ) : ( + bomMaterials.map((mat) => { + const resolvedType = itemTypeCatMap[mat.child_item_type || ""] || mat.child_item_type || ""; + const typeColor = + resolvedType.includes("원자재") ? "#16a34a" + : resolvedType.includes("반제품") ? "#2563eb" + : resolvedType.includes("제품") ? "#9333ea" + : "#6b7280"; + return ( +
+ { + setBomChecked((prev) => { + const next = new Set(prev); + if (checked) next.add(mat.id); + else next.delete(mat.id); + return next; + }); + }} + /> +
+
+ {mat.child_item_type && ( + + {resolvedType} + + )} + + {mat.child_item_code || "-"} + + + {mat.child_item_name || "(이름 없음)"} + +
+
+ 소요량: {mat.quantity || "0"} {mat.detail_unit || mat.item_unit || ""} + {mat.process_type ? ` | 공정: ${mat.process_type}` : ""} +
+
+
+ ); + }) + )} +
+
+
+ )} + + {/* 필수 여부 (모든 유형 공통) */} + {currentType && ( +
+ + +
+ )} +
+ + + + + +
+
+ ); +} + +// ============================================================ +// 메인 모달 +// ============================================================ export function WorkStandardEditModal({ open, onClose, @@ -122,9 +1152,12 @@ export function WorkStandardEditModal({ const [detailModalOpen, setDetailModalOpen] = useState(false); const [detailModalMode, setDetailModalMode] = useState<"add" | "edit">("add"); const [detailModalPhase, setDetailModalPhase] = useState("PRE"); - const [detailFormData, setDetailFormData] = useState>({}); const [editingDetail, setEditingDetail] = useState(null); + // 드래그 정렬용 state (phase + workItem 조합으로 스코프 관리) + const [dragIdx, setDragIdx] = useState(null); + const [overIdx, setOverIdx] = useState(null); + // 데이터 로드 const loadData = useCallback(async () => { if (!workInstructionNo || !routingVersionId) return; @@ -163,21 +1196,16 @@ export function WorkStandardEditModal({ return map; }, [currentProcess]); - // 커스텀 복사 확인 후 수정 + // 커스텀 전환 — 로컬 마킹만 수행 (TASK:ERP-012 수정분 보존) + // 기존에는 copyWorkStandard API 호출 + loadData()로 state 전면 재로딩을 해서 + // closure로 잡은 workItem id / detail id가 모두 stale해져서 + // ① 삭제가 무효화되어 UI상 상세가 0개로 보이고 + // ② 추가 state 변경이 반영되지 않아 저장이 먹통이 됨. + // saveWIWorkStandard는 DELETE→INSERT 방식으로 전체 덮어쓰므로 사전 복사 불필요. const ensureCustom = useCallback(async () => { - if (isCustom) return true; - try { - const res = await copyWorkStandard(workInstructionNo, routingVersionId); - if (res.success) { - await loadData(); - setIsCustom(true); - return true; - } - } catch (err) { - toast.error("원본 복사에 실패했습니다"); - } - return false; - }, [isCustom, workInstructionNo, routingVersionId, loadData]); + if (!isCustom) setIsCustom(true); + return true; + }, [isCustom]); // 작업항목 추가 모달 열기 const openAddWorkItem = useCallback((phaseKey: string) => { @@ -204,7 +1232,6 @@ export function WorkStandardEditModal({ if (!ok || !currentProcess) return; if (editingWorkItem) { - // 수정 setProcesses(prev => { const next = [...prev]; const workItems = [...next[selectedProcessIdx].workItems]; @@ -216,7 +1243,6 @@ export function WorkStandardEditModal({ return next; }); } else { - // 추가 const phaseKey = addItemPhase === "IN" ? "IN" : addItemPhase; const phaseItems = currentProcess.workItems.filter( wi => wi.work_phase === phaseKey || (phaseKey === "IN" && wi.work_phase === "MAIN") @@ -271,11 +1297,6 @@ export function WorkStandardEditModal({ setDetailModalPhase(phaseKey); setDetailModalMode("add"); setEditingDetail(null); - setDetailFormData({ - detail_type: DETAIL_TYPES[0].value, - content: "", - is_required: "Y", - }); setDetailModalOpen(true); }, []); @@ -284,37 +1305,13 @@ export function WorkStandardEditModal({ setDetailModalPhase(phaseKey); setDetailModalMode("edit"); setEditingDetail(detail); - setDetailFormData({ ...detail }); setDetailModalOpen(true); }, []); - // 상세 추가/수정 처리 - const handleSaveDetail = useCallback(async () => { - const type = detailFormData.detail_type || ""; - if (!type) return; - - // 유효성 검사 (내용이 필요한 유형) - const needsContent = ["checklist", "procedure", "input", "equip_condition"]; - if (needsContent.includes(type) && !detailFormData.content?.trim()) { - toast.error("내용을 입력하세요"); - return; - } - if (type === "inspection" && !detailFormData.content?.trim()) { - toast.error("검사 항목명을 입력하세요"); - return; - } - + // 상세 추가 처리 — 다중행 자동생성 대응 (DetailFormModalInner의 onSubmit이 for-loop로 여러 번 호출) + const handleAddDetailRow = useCallback((data: Partial) => { const workItemId = selectedWorkItemIdByPhase[detailModalPhase]; if (!workItemId) return; - const ok = await ensureCustom(); - if (!ok) return; - - // content 자동 설정 (UI에서 직접 입력이 없는 유형들) - const submitData = { ...detailFormData }; - if (type === "lookup") submitData.content = submitData.content || "품목 등록 문서 (자동 연동)"; - if (type === "equip_inspection") submitData.content = submitData.content || "설비점검"; - if (type === "production_result") submitData.content = submitData.content || "작업수량 / 불량수량 / 양품수량"; - if (type === "material_input") submitData.content = submitData.content || "BOM 구성 자재 (자동 연동)"; setProcesses(prev => { const next = [...prev]; @@ -322,33 +1319,74 @@ export function WorkStandardEditModal({ const wiIdx = workItems.findIndex(wi => wi.id === workItemId); if (wiIdx < 0) return prev; - if (detailModalMode === "edit" && editingDetail) { - // 수정 - const details = (workItems[wiIdx].details || []).map(d => - d.id === editingDetail.id ? { ...d, ...submitData } : d - ); + const currentDetails = workItems[wiIdx].details || []; + const newDetail: WIWorkItemDetail = { + ...data, + id: `temp-detail-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + work_item_id: workItemId, + sort_order: currentDetails.length + 1, + }; + workItems[wiIdx] = { + ...workItems[wiIdx], + details: [...currentDetails, newDetail], + detail_count: currentDetails.length + 1, + }; + next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; + return next; + }); + setDirty(true); + }, [detailModalPhase, selectedWorkItemIdByPhase, selectedProcessIdx]); + + // 상세 수정 처리 + const handleUpdateDetailRow = useCallback((data: Partial) => { + const workItemId = selectedWorkItemIdByPhase[detailModalPhase]; + if (!workItemId || !editingDetail) return; + + setProcesses(prev => { + const next = [...prev]; + const workItems = [...next[selectedProcessIdx].workItems]; + const wiIdx = workItems.findIndex(wi => wi.id === workItemId); + if (wiIdx < 0) return prev; + + // 같은 유형의 apply 그룹 전체 일괄 업데이트 (필수여부 등) — 원본 WorkItemDetailList 로직과 동일 + const isApplyGroup = + (editingDetail.detail_type === "inspection" && editingDetail.process_inspection_apply === "apply") || + (editingDetail.detail_type === "equip_inspection" && editingDetail.equip_inspection_apply === "apply") || + editingDetail.detail_type === "material_input"; + + if (isApplyGroup && data.is_required !== undefined) { + const details = (workItems[wiIdx].details || []).map(d => { + const isSibling = + d.detail_type === editingDetail.detail_type && ( + d.detail_type === "inspection" ? d.process_inspection_apply === "apply" : + d.detail_type === "equip_inspection" ? d.equip_inspection_apply === "apply" : + d.detail_type === "material_input" + ); + return isSibling ? { ...d, is_required: data.is_required! } : d; + }); workItems[wiIdx] = { ...workItems[wiIdx], details }; } else { - // 추가 - const newDetail: WIWorkItemDetail = { - ...submitData, - id: `temp-detail-${Date.now()}`, - work_item_id: workItemId, - sort_order: (workItems[wiIdx].details?.length || 0) + 1, - }; - workItems[wiIdx] = { - ...workItems[wiIdx], - details: [...(workItems[wiIdx].details || []), newDetail], - detail_count: (workItems[wiIdx].detail_count || 0) + 1, - }; + const details = (workItems[wiIdx].details || []).map(d => + d.id === editingDetail.id ? { ...d, ...data } : d + ); + workItems[wiIdx] = { ...workItems[wiIdx], details }; } next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; return next; }); - - setDetailModalOpen(false); setDirty(true); - }, [detailFormData, detailModalPhase, detailModalMode, editingDetail, selectedWorkItemIdByPhase, ensureCustom, selectedProcessIdx]); + }, [detailModalPhase, selectedWorkItemIdByPhase, selectedProcessIdx, editingDetail]); + + // DetailFormModalInner onSubmit 분기 진입점 + const handleDetailSubmit = useCallback(async (data: Partial) => { + const ok = await ensureCustom(); + if (!ok) return; + if (detailModalMode === "add") { + handleAddDetailRow(data); + } else { + handleUpdateDetailRow(data); + } + }, [detailModalMode, handleAddDetailRow, handleUpdateDetailRow, ensureCustom]); // 상세 삭제 const handleDeleteDetail = useCallback(async (detailId: string, phaseKey: string) => { @@ -374,6 +1412,25 @@ export function WorkStandardEditModal({ setDirty(true); }, [selectedWorkItemIdByPhase, ensureCustom, selectedProcessIdx]); + // 드래그 정렬 — 상세 목록 순서 변경 + const handleReorderDetails = useCallback((phaseKey: string, reordered: WIWorkItemDetail[]) => { + const workItemId = selectedWorkItemIdByPhase[phaseKey]; + if (!workItemId) return; + setProcesses(prev => { + const next = [...prev]; + const workItems = [...next[selectedProcessIdx].workItems]; + const wiIdx = workItems.findIndex(wi => wi.id === workItemId); + if (wiIdx >= 0) { + // sort_order 재할당 + const withOrder = reordered.map((d, i) => ({ ...d, sort_order: i + 1 })); + workItems[wiIdx] = { ...workItems[wiIdx], details: withOrder }; + } + next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; + return next; + }); + setDirty(true); + }, [selectedWorkItemIdByPhase, selectedProcessIdx]); + // 저장 const handleSave = useCallback(async () => { if (!currentProcess) return; @@ -394,7 +1451,7 @@ export function WorkStandardEditModal({ } else { toast.error("저장에 실패했습니다"); } - } catch (err) { + } catch { toast.error("저장 중 오류가 발생했습니다"); } finally { setSaving(false); @@ -410,15 +1467,11 @@ export function WorkStandardEditModal({ toast.success("원본으로 초기화되었습니다"); await loadData(); } - } catch (err) { + } catch { toast.error("초기화에 실패했습니다"); } }, [workInstructionNo, loadData]); - const updateDetailField = (field: string, value: unknown) => { - setDetailFormData(prev => ({ ...prev, [field]: value })); - }; - return ( { if (!v) onClose(); }}> @@ -473,6 +1526,7 @@ export function WorkStandardEditModal({ const phaseItems = workItemsByPhase[phase.key] || []; const selectedWiId = selectedWorkItemIdByPhase[phase.key] || null; const selectedWi = phaseItems.find(wi => wi.id === selectedWiId) || null; + const selectedDetails = selectedWi?.details || []; return (
@@ -555,7 +1609,7 @@ export function WorkStandardEditModal({ )}
- {/* 우측: 상세 테이블 */} + {/* 우측: 상세 테이블 — 드래그 정렬 지원 */}
{!selectedWi ? (
@@ -568,7 +1622,7 @@ export function WorkStandardEditModal({
{selectedWi.title} - {selectedWi.details?.length || 0}개 + {selectedDetails.length}개
- {/* 상세 추가/수정 다이얼로그 (유형별 동적 필드) */} - { if (!v) setDetailModalOpen(false); }}> - e.stopPropagation()}> - - - 상세 항목 {detailModalMode === "add" ? "추가" : "수정"} - - - 상세 항목의 유형을 선택하고 내용을 입력하세요 - - - -
- {/* 유형 선택 */} -
- - -
- - {/* 체크리스트 */} - {detailFormData.detail_type === "checklist" && ( -
- - updateDetailField("content", e.target.value)} - placeholder="예: 전원 상태 확인" - className="mt-1 h-8 text-xs" - /> -
- )} - - {/* 검사항목 */} - {detailFormData.detail_type === "inspection" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="예: 외경 치수" - className="mt-1 h-8 text-xs" - /> -
-
-
- - updateDetailField("inspection_method", e.target.value)} - placeholder="예: 마이크로미터" - className="mt-1 h-8 text-xs" - /> -
-
- - -
-
-
-
-
- updateDetailField("base_value", e.target.value)} - placeholder="기준값" - className="h-8 text-xs" - /> -
- ± -
- updateDetailField("tolerance", e.target.value)} - placeholder="오차범위" - className="h-8 text-xs" - /> -
-
-
- - )} - - {/* 작업절차 */} - {detailFormData.detail_type === "procedure" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="예: 자재 투입" - className="mt-1 h-8 text-xs" - /> -
-
- - updateDetailField("duration_minutes", e.target.value ? Number(e.target.value) : undefined)} - placeholder="예: 5" - className="mt-1 h-8 text-xs" - /> -
- - )} - - {/* 직접입력 */} - {detailFormData.detail_type === "input" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="예: 작업자 의견" - className="mt-1 h-8 text-xs" - /> -
-
- - -
- - )} - - {/* 문서참조 */} - {detailFormData.detail_type === "lookup" && ( -
- - 해당 품목에 등록된 문서를 자동으로 불러옵니다. - -
- )} - - {/* 설비점검 */} - {detailFormData.detail_type === "equip_inspection" && ( -
- - updateDetailField("content", e.target.value)} - placeholder="예: 설비 가동 전 안전 점검" - className="mt-1 h-8 text-xs" - /> -
- )} - - {/* 설비조건 */} - {detailFormData.detail_type === "equip_condition" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="조건명 (예: 온도, 압력, RPM)" - className="mt-1 h-8 text-xs" - /> -
-
-
-
- -
-
-
-
- updateDetailField("condition_base_value", e.target.value)} - placeholder="기준값" - className="h-8 text-xs" - /> -
- ± -
- updateDetailField("condition_tolerance", e.target.value)} - placeholder="오차범위" - className="h-8 text-xs" - /> -
-
-
- - )} - - {/* 실적등록 */} - {detailFormData.detail_type === "production_result" && ( -
-

작업수량 / 불량수량 / 양품수량

-

실적 입력 항목이 자동으로 생성됩니다.

-
- )} - - {/* 자재투입 */} - {detailFormData.detail_type === "material_input" && ( -
-

BOM 구성 자재 (자동 연동)

-

품목에 등록된 BOM 구성 자재가 자동으로 적용됩니다.

-
- )} - - {/* 필수 여부 (모든 유형 공통) */} - {detailFormData.detail_type && ( -
- - -
- )} - - {/* 비고 (모든 유형 공통) */} - {detailFormData.detail_type && ( -
- - updateDetailField("remark", e.target.value)} - placeholder="비고 입력" - className="mt-1 h-8 text-xs" - /> -
- )} -
- - - - - -
-
+ {/* 상세 추가/수정 다이얼로그 — 원본 DetailFormModal 로직을 이식 */} + setDetailModalOpen(false)} + onSubmit={handleDetailSubmit} + editData={editingDetail} + mode={detailModalMode} + selectedItemCode={itemCode} + selectedProcessCode={currentProcess?.process_code} + /> ); diff --git a/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx index 68d40dc0..9e0a914f 100644 --- a/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx @@ -13,6 +13,7 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen import { Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy, + ChevronUp, ChevronDown, ChevronsUpDown, } from "lucide-react"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { cn } from "@/lib/utils"; @@ -464,6 +465,34 @@ export default function ItemInspectionInfoPage() { return Object.values(map); }, [data]); + // 좌측 품목 목록 정렬 (컬럼 헤더 클릭 → asc → desc → 해제 순환) + const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); + const handleSort = (key: string) => { + setSortConfig(prev => { + if (!prev || prev.key !== key) return { key, direction: "asc" }; + if (prev.direction === "asc") return { key, direction: "desc" }; + return null; + }); + }; + const sortedGroupedData = useMemo(() => { + if (!sortConfig) return groupedData; + const { key, direction } = sortConfig; + const mul = direction === "asc" ? 1 : -1; + const getVal = (g: any): string => { + if (key === "inspection_type") { + const code = g.types[0] || ""; + return inspTypeCatOptions.find(o => o.code === code)?.label || code; + } + if (key === "is_active") { + return (g.is_active === "사용" || g.is_active === "true") ? "사용" : "미사용"; + } + return String(g[key] ?? ""); + }; + return [...groupedData].sort((a, b) => { + return getVal(a).localeCompare(getVal(b), "ko", { numeric: true }) * mul; + }); + }, [groupedData, sortConfig, inspTypeCatOptions]); + // 선택된 품목의 그룹 데이터 const selectedGroup = useMemo(() => { if (!selectedItemCode) return null; @@ -981,14 +1010,28 @@ export default function ItemInspectionInfoPage() { {ts.visibleColumns.map((col) => ( - - {col.label} + handleSort(col.key)} + > +
+ {col.label} + {sortConfig?.key === col.key ? ( + sortConfig.direction === "asc" + ? + : + ) : ( + + )} +
))}
- {groupedData.map((group) => ( + {sortedGroupedData.map((group) => ( a.toLowerCase()); + if (!allowed.includes(val.toLowerCase())) return false; } else if (f.operator === "between") { const [from, to] = f.value.split("|"); if (from && val < from) return false; diff --git a/frontend/app/(main)/COMPANY_29/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/COMPANY_29/production/work-instruction/WorkStandardEditModal.tsx index da7e8fd5..96b96535 100644 --- a/frontend/app/(main)/COMPANY_29/production/work-instruction/WorkStandardEditModal.tsx +++ b/frontend/app/(main)/COMPANY_29/production/work-instruction/WorkStandardEditModal.tsx @@ -15,9 +15,11 @@ import { } from "lucide-react"; import { toast } from "sonner"; import { - getWIWorkStandard, copyWorkStandard, saveWIWorkStandard, resetWIWorkStandard, + getWIWorkStandard, saveWIWorkStandard, resetWIWorkStandard, WIWorkItem, WIWorkItemDetail, WIWorkStandardProcess, } from "@/lib/api/workInstruction"; +import { getBomMaterials, BomMaterial } from "@/lib/api/processInfo"; +import { apiClient } from "@/lib/api/client"; interface WorkStandardEditModalProps { open: boolean; @@ -60,12 +62,46 @@ const UNIT_OPTIONS = [ "L/min", "Hz", "dB", "ea", "g", "mg", "ml", "L", ]; +// PLC 데이터 옵션 (원본 DetailFormModal과 동일) +const PLC_DATA_OPTIONS = [ + { value: "PLC_TEMP_01", label: "온도 (PLC_TEMP_01)" }, + { value: "PLC_PRES_01", label: "압력 (PLC_PRES_01)" }, + { value: "PLC_RPM_01", label: "회전수 (PLC_RPM_01)" }, + { value: "PLC_TORQ_01", label: "토크 (PLC_TORQ_01)" }, + { value: "PLC_SPD_01", label: "속도 (PLC_SPD_01)" }, + { value: "PLC_CUR_01", label: "전류 (PLC_CUR_01)" }, + { value: "PLC_VOLT_01", label: "전압 (PLC_VOLT_01)" }, + { value: "PLC_VIB_01", label: "진동 (PLC_VIB_01)" }, + { value: "PLC_HUM_01", label: "습도 (PLC_HUM_01)" }, + { value: "PLC_FLOW_01", label: "유량 (PLC_FLOW_01)" }, +]; + +const PLC_PRODUCTION_OPTIONS: Record = { + work_qty: [ + { value: "PLC_CNT_01", label: "생산카운터 (PLC_CNT_01)" }, + { value: "PLC_CNT_02", label: "완료카운터 (PLC_CNT_02)" }, + { value: "PLC_QTY_01", label: "작업수량 (PLC_QTY_01)" }, + ], + defect_qty: [ + { value: "PLC_NG_01", label: "불량카운터 (PLC_NG_01)" }, + { value: "PLC_NG_02", label: "NG감지기 (PLC_NG_02)" }, + { value: "PLC_REJ_01", label: "리젝트수 (PLC_REJ_01)" }, + ], + good_qty: [ + { value: "PLC_OK_01", label: "양품카운터 (PLC_OK_01)" }, + { value: "PLC_OK_02", label: "합격카운터 (PLC_OK_02)" }, + { value: "PLC_GOOD_01", label: "양품수량 (PLC_GOOD_01)" }, + ], +}; + const getDetailTypeLabel = (type: string) => DETAIL_TYPES.find(d => d.value === type)?.label || type; +// 원본 WorkItemDetailList의 getContentSummary를 그대로 이식 const getContentSummary = (detail: WIWorkItemDetail): string => { const type = detail.detail_type; if (type === "inspection") { + if (detail.process_inspection_apply === "apply") return detail.content || "품목별 검사정보 (자동 연동)"; const parts = [detail.content]; if (detail.inspection_method) parts.push(`[${detail.inspection_method}]`); if (detail.base_value) { @@ -81,7 +117,9 @@ const getContentSummary = (detail: WIWorkItemDetail): string => { return `${detail.content} [${typeMap[detail.input_type] || detail.input_type}]`; } if (type === "lookup") return "품목 등록 문서 (자동 연동)"; - if (type === "equip_inspection") return detail.content || "설비점검"; + 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) { @@ -90,10 +128,1002 @@ const getContentSummary = (detail: WIWorkItemDetail): string => { return parts.join(" "); } if (type === "production_result") return "작업수량 / 불량수량 / 양품수량"; - if (type === "material_input") return "BOM 구성 자재 (자동 연동)"; + if (type === "material_input") return detail.content || "BOM 구성 자재 (자동 연동)"; return detail.content || "-"; }; +// ============================================================ +// 상세 항목 추가/수정 모달 — 원본 DetailFormModal을 내부에 이식 +// 원본: frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx +// ============================================================ +interface DetailFormModalInnerProps { + open: boolean; + onClose: () => void; + onSubmit: (data: Partial) => void; + editData?: WIWorkItemDetail | null; + mode: "add" | "edit"; + selectedItemCode?: string; + selectedProcessCode?: string; +} + +function DetailFormModalInner({ + open, + onClose, + onSubmit, + editData, + mode, + selectedItemCode, + selectedProcessCode, +}: DetailFormModalInnerProps) { + const [formData, setFormData] = useState>({}); + const [bomMaterials, setBomMaterials] = useState([]); + const [bomLoading, setBomLoading] = useState(false); + const [bomChecked, setBomChecked] = useState>(new Set()); + + // 품목검사정보 연동 + const [itemInspections, setItemInspections] = useState([]); + const [itemInspLoading, setItemInspLoading] = useState(false); + + // 품목 카테고리 (type 코드→라벨) + const [itemTypeCatMap, setItemTypeCatMap] = useState>({}); + + // 설비 점검항목 연동 + const [equipInspItems, setEquipInspItems] = useState([]); + const [equipInspLoading, setEquipInspLoading] = useState(false); + + const loadBomMaterials = useCallback(async () => { + if (!selectedItemCode) { + setBomMaterials([]); + return; + } + setBomLoading(true); + try { + const res = await getBomMaterials(selectedItemCode); + if (res.success && res.data) { + setBomMaterials(res.data); + } else { + setBomMaterials([]); + } + } catch { + setBomMaterials([]); + } finally { + setBomLoading(false); + } + }, [selectedItemCode]); + + // BOM 자재 로드 완료 후 체크 상태 초기화 + useEffect(() => { + if (!open || bomMaterials.length === 0) return; + if (mode === "edit" && editData?.selected_bom_items) { + const savedBom = editData.selected_bom_items; + const parsedBom = typeof savedBom === "string" ? JSON.parse(savedBom) : savedBom; + if (Array.isArray(parsedBom)) { + const matIds = new Set(bomMaterials.map((m) => m.id)); + const restored = new Set(); + const seenChildForLegacy = new Set(); + for (const saved of parsedBom) { + if (matIds.has(saved)) { + restored.add(saved); + } else { + const legacyKey = String(saved); + if (seenChildForLegacy.has(legacyKey)) continue; + const firstMat = bomMaterials.find((m) => m.child_item_id === legacyKey); + if (firstMat) { + restored.add(firstMat.id); + seenChildForLegacy.add(legacyKey); + } + } + } + setBomChecked(restored); + return; + } + } + setBomChecked(new Set()); + }, [open, bomMaterials, mode, editData]); + + useEffect(() => { + if (open) { + if (mode === "edit" && editData) { + setFormData({ ...editData }); + } else { + setFormData({ + detail_type: DETAIL_TYPES[0]?.value || "", + content: "", + is_required: "Y", + }); + } + } + }, [open, mode, editData]); + + useEffect(() => { + if (open && formData.detail_type === "material_input") { + loadBomMaterials(); + if (Object.keys(itemTypeCatMap).length === 0) { + apiClient.get("/table-categories/item_info/type/values").then(res => { + if (res.data?.success && res.data.data?.length) { + const map: Record = {}; + const flatten = (arr: any[]) => { for (const v of arr) { map[v.valueCode] = v.valueLabel; if (v.children?.length) flatten(v.children); } }; + flatten(res.data.data); + setItemTypeCatMap(map); + } + }).catch(() => {}); + } + } + }, [open, formData.detail_type, loadBomMaterials, itemTypeCatMap]); + + // 검사항목 적용 시 품목검사정보 로드 + useEffect(() => { + if (!open || formData.detail_type !== "inspection" || formData.process_inspection_apply !== "apply" || !selectedItemCode) { + setItemInspections([]); + return; + } + (async () => { + setItemInspLoading(true); + try { + const res = await apiClient.post("/table-management/tables/item_inspection_info/data", { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: selectedItemCode }] }, + autoFilter: true, + }); + setItemInspections(res.data?.data?.data || res.data?.data?.rows || []); + } catch { setItemInspections([]); } finally { setItemInspLoading(false); } + })(); + }, [open, formData.detail_type, formData.process_inspection_apply, selectedItemCode]); + + // 설비점검 적용 시 해당 공정의 설비 점검항목 로드 + useEffect(() => { + if (!open || formData.detail_type !== "equip_inspection" || formData.equip_inspection_apply !== "apply" || !selectedProcessCode) { + setEquipInspItems([]); + return; + } + (async () => { + setEquipInspLoading(true); + try { + // 1. 해당 공정의 설비 목록 조회 + const equipRes = await apiClient.post("/table-management/tables/process_equipment/data", { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [{ columnName: "process_code", operator: "equals", value: selectedProcessCode }] }, + autoFilter: true, + }); + const equipCodes = (equipRes.data?.data?.data || equipRes.data?.data?.rows || []).map((e: any) => e.equipment_code).filter(Boolean); + if (equipCodes.length === 0) { setEquipInspItems([]); return; } + // 2. 각 설비의 점검항목 조회 + const inspRes = await apiClient.post("/table-management/tables/equipment_inspection_item/data", { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "in", value: equipCodes }] }, + autoFilter: true, + }); + setEquipInspItems(inspRes.data?.data?.data || inspRes.data?.data?.rows || []); + } catch { setEquipInspItems([]); } finally { setEquipInspLoading(false); } + })(); + }, [open, formData.detail_type, formData.equip_inspection_apply, selectedProcessCode]); + + const updateField = (field: string, value: any) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + const handleSubmit = () => { + if (!formData.detail_type) return; + + const type = formData.detail_type; + + if (type === "checklist" && !formData.content?.trim()) return; + if (type === "inspection") { + if (!formData.process_inspection_apply) return; + if (formData.process_inspection_apply === "none" && !formData.content?.trim()) return; + } + if (type === "procedure" && !formData.content?.trim()) return; + if (type === "input" && !formData.content?.trim()) return; + if (type === "equip_inspection" && !formData.equip_inspection_apply) return; + if (type === "equip_condition" && !formData.content?.trim()) return; + + const submitData = { ...formData }; + + // 검사항목 적용 → 품목검사정보 각각 개별 등록 (다중행 자동생성) + if (type === "inspection" && submitData.process_inspection_apply === "apply") { + if (itemInspections.length > 0) { + for (const insp of itemInspections) { + onSubmit({ + ...submitData, + detail_type: "inspection", + content: `${insp.inspection_item_name || insp.inspection_standard || "-"} | ${insp.pass_criteria || ""}`.trim(), + is_required: submitData.is_required || "Y", + }); + } + onClose(); + return; + } + submitData.content = submitData.content || "품목별 검사정보 (자동 연동)"; + } + if (type === "lookup") { + submitData.content = submitData.content || "품목 등록 문서 (자동 연동)"; + } + // 설비점검 적용 → 점검항목 각각 개별 등록 (다중행 자동생성) + if (type === "equip_inspection" && submitData.equip_inspection_apply === "apply") { + if (equipInspItems.length > 0) { + for (const item of equipInspItems) { + const range = (item.lower_limit || item.upper_limit) ? `${item.lower_limit || ""} ~ ${item.upper_limit || ""}${item.unit ? ` ${item.unit}` : ""}` : ""; + onSubmit({ + ...submitData, + detail_type: "equip_inspection", + content: `${item.inspection_item || "-"}${range ? ` | ${range}` : ""}`.trim(), + is_required: submitData.is_required || "Y", + }); + } + onClose(); + return; + } + submitData.content = submitData.content || "설비 점검항목 (설비정보 연동)"; + } + if (type === "production_result") { + submitData.content = submitData.content || "작업수량 / 불량수량 / 양품수량"; + } + if (type === "material_input") { + // 선택된 BOM 자재를 각각 개별 상세 항목으로 등록 (다중행 자동생성) + const checkedMats = bomMaterials.filter(m => bomChecked.has(m.id)); + if (checkedMats.length > 0) { + const resolveType = (code: string) => itemTypeCatMap[code] || code || ""; + for (const mat of checkedMats) { + const typeLabel = resolveType(mat.child_item_type || ""); + const unitLabel = mat.detail_unit || mat.item_unit || ""; + onSubmit({ + ...submitData, + detail_type: "material_input", + content: `${mat.child_item_name || mat.child_item_id}${typeLabel ? ` ${typeLabel}` : ""} | ${mat.quantity || 1} ${unitLabel}`.trim(), + is_required: submitData.is_required || "Y", + bom_item_id: mat.child_item_id, + bom_item_name: mat.child_item_name || "", + bom_qty: String(mat.quantity || 1), + bom_unit: unitLabel, + }); + } + onClose(); + return; + } + submitData.content = submitData.content || "BOM 구성 자재 (자동 연동)"; + submitData.selected_bom_items = Array.from(bomChecked); + } + + onSubmit(submitData); + onClose(); + }; + + const currentType = formData.detail_type || ""; + + return ( + !v && onClose()}> + e.stopPropagation()}> + + + 상세 항목 {mode === "add" ? "추가" : "수정"} + + + 상세 항목의 유형을 선택하고 내용을 입력하세요 + + + +
+ {/* 유형 선택 */} +
+ + +
+ + {/* ============ 체크리스트 ============ */} + {currentType === "checklist" && ( +
+ + updateField("content", e.target.value)} + placeholder="예: 전원 상태 확인" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ )} + + {/* ============ 검사항목 ============ */} + {currentType === "inspection" && ( + <> + {/* 공정검사 적용 여부 */} +
+ +
+ + +
+
+ + {/* 적용 시: 품목검사정보에서 등록된 검사항목 표시 */} + {formData.process_inspection_apply === "apply" && ( +
+

+ 품목별 검사정보 (자동 연동) +

+ {itemInspLoading ? ( +
조회 중...
+ ) : itemInspections.length === 0 ? ( +

등록된 검사항목이 없습니다. 품목검사정보에서 먼저 등록해주세요.

+ ) : ( + + + + + + + + + + {itemInspections.map((insp, idx) => ( + + + + + + ))} + +
검사항목검사유형합격기준
{insp.inspection_item_name || insp.inspection_standard || "-"}{insp.inspection_type || "-"}{insp.pass_criteria || "-"}
+ )} +
+ )} + + {/* 미적용 시: 수동 입력 */} + {formData.process_inspection_apply === "none" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 외경 치수" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+
+ + updateField("inspection_method", e.target.value)} + placeholder="예: 마이크로미터" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + +
+
+ + {/* 기준값 ± 오차범위 + 자동수집 + PLC */} +
+
+
+ updateField("base_value", e.target.value)} + placeholder="기준값" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ ± +
+ updateField("tolerance", e.target.value)} + placeholder="오차범위" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ +
+ + +
+
+ + )} + + )} + + {/* ============ 작업절차 ============ */} + {currentType === "procedure" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 자재 투입" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + + updateField("duration_minutes", e.target.value ? Number(e.target.value) : undefined) + } + placeholder="예: 5" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + )} + + {/* ============ 직접입력 ============ */} + {currentType === "input" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 작업자 의견" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + +
+ + )} + + {/* ============ 문서참조 ============ */} + {currentType === "lookup" && ( + <> +
+ 📄 + + 해당 품목에 등록된 문서를 자동으로 불러옵니다. + +
+
+ +
+

+ 품목이 선택되지 않았습니다. +

+
+
+ + )} + + {/* ============ 설비점검 ============ */} + {currentType === "equip_inspection" && ( + <> +
+ 🏭 + + 공정에 지정된 설비를 자동 참조합니다. + +
+ +
+ +
+ + +
+
+ + {/* 적용 시: 설비 점검항목 표시 */} + {formData.equip_inspection_apply === "apply" && ( +
+

+ 설비 점검항목 (설비정보 연동) +

+ {equipInspLoading ? ( +
조회 중...
+ ) : equipInspItems.length === 0 ? ( +

해당 공정에 지정된 설비의 점검항목이 없습니다.

+ ) : ( + + + + + + + + + + + {equipInspItems.map((item, idx) => ( + + + + + + + ))} + +
설비코드점검항목점검방법기준범위
{item.equipment_code || "-"}{item.inspection_item || "-"}{item.inspection_method || "-"}{item.lower_limit || item.upper_limit ? `${item.lower_limit || ""} ~ ${item.upper_limit || ""}${item.unit ? ` ${item.unit}` : ""}` : "-"}
+ )} +
+ )} + + )} + + {/* ============ 설비조건 ============ */} + {currentType === "equip_condition" && ( + <> +
+ 🏭 + + 공정에 지정된 설비를 자동 참조합니다. + +
+ +
+ +
+ {/* 조건명 + 단위 */} +
+
+ updateField("content", e.target.value)} + placeholder="조건명 (예: 온도, 압력, RPM)" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ +
+
+ + {/* 기준값 ± 오차범위 */} +
+
+ updateField("condition_base_value", e.target.value)} + placeholder="기준값" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ ± +
+ updateField("condition_tolerance", e.target.value)} + placeholder="오차범위" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + {/* 자동수집 */} +
+ + +
+
+
+ + )} + + {/* ============ 실적등록 ============ */} + {currentType === "production_result" && ( +
+ +
+ {/* 작업수량 */} +
+
+ 📦 작업수량 + 생산된 전체 수량 +
+
+ + +
+
+ + {/* 불량수량 */} +
+
+ 🚫 불량수량 + 불량으로 판정된 수량 +
+
+ + +
+
+ + {/* 양품수량 */} +
+
+ ✅ 양품수량 + 작업수량 - 불량수량 +
+
+ + +
+
+
+
+ )} + + {/* ============ 자재투입 ============ */} + {currentType === "material_input" && ( +
+ +
+
+

+ 📦 BOM 구성 자재 (자동 연동) +

+ + {bomMaterials.length > 0 ? `${bomMaterials.length}건` : ""} + +
+ {bomMaterials.length > 0 && ( +
+ 0 + ? "indeterminate" + : false + } + onCheckedChange={(checked) => { + if (checked) { + setBomChecked(new Set(bomMaterials.map((m) => m.id))); + } else { + setBomChecked(new Set()); + } + }} + /> + + 전체 선택 ({bomChecked.size} / {bomMaterials.length}) + +
+ )} +
+ {bomLoading ? ( +
+ + BOM 데이터를 불러오는 중... +
+ ) : !selectedItemCode ? ( +
+ 품목을 먼저 선택하세요. +
+ ) : bomMaterials.length === 0 ? ( +
+ 해당 품목의 BOM 구성 자재가 없습니다. +
+ ) : ( + bomMaterials.map((mat) => { + const resolvedType = itemTypeCatMap[mat.child_item_type || ""] || mat.child_item_type || ""; + const typeColor = + resolvedType.includes("원자재") ? "#16a34a" + : resolvedType.includes("반제품") ? "#2563eb" + : resolvedType.includes("제품") ? "#9333ea" + : "#6b7280"; + return ( +
+ { + setBomChecked((prev) => { + const next = new Set(prev); + if (checked) next.add(mat.id); + else next.delete(mat.id); + return next; + }); + }} + /> +
+
+ {mat.child_item_type && ( + + {resolvedType} + + )} + + {mat.child_item_code || "-"} + + + {mat.child_item_name || "(이름 없음)"} + +
+
+ 소요량: {mat.quantity || "0"} {mat.detail_unit || mat.item_unit || ""} + {mat.process_type ? ` | 공정: ${mat.process_type}` : ""} +
+
+
+ ); + }) + )} +
+
+
+ )} + + {/* 필수 여부 (모든 유형 공통) */} + {currentType && ( +
+ + +
+ )} +
+ + + + + +
+
+ ); +} + +// ============================================================ +// 메인 모달 +// ============================================================ export function WorkStandardEditModal({ open, onClose, @@ -122,9 +1152,12 @@ export function WorkStandardEditModal({ const [detailModalOpen, setDetailModalOpen] = useState(false); const [detailModalMode, setDetailModalMode] = useState<"add" | "edit">("add"); const [detailModalPhase, setDetailModalPhase] = useState("PRE"); - const [detailFormData, setDetailFormData] = useState>({}); const [editingDetail, setEditingDetail] = useState(null); + // 드래그 정렬용 state (phase + workItem 조합으로 스코프 관리) + const [dragIdx, setDragIdx] = useState(null); + const [overIdx, setOverIdx] = useState(null); + // 데이터 로드 const loadData = useCallback(async () => { if (!workInstructionNo || !routingVersionId) return; @@ -163,21 +1196,16 @@ export function WorkStandardEditModal({ return map; }, [currentProcess]); - // 커스텀 복사 확인 후 수정 + // 커스텀 전환 — 로컬 마킹만 수행 (TASK:ERP-012 수정분 보존) + // 기존에는 copyWorkStandard API 호출 + loadData()로 state 전면 재로딩을 해서 + // closure로 잡은 workItem id / detail id가 모두 stale해져서 + // ① 삭제가 무효화되어 UI상 상세가 0개로 보이고 + // ② 추가 state 변경이 반영되지 않아 저장이 먹통이 됨. + // saveWIWorkStandard는 DELETE→INSERT 방식으로 전체 덮어쓰므로 사전 복사 불필요. const ensureCustom = useCallback(async () => { - if (isCustom) return true; - try { - const res = await copyWorkStandard(workInstructionNo, routingVersionId); - if (res.success) { - await loadData(); - setIsCustom(true); - return true; - } - } catch (err) { - toast.error("원본 복사에 실패했습니다"); - } - return false; - }, [isCustom, workInstructionNo, routingVersionId, loadData]); + if (!isCustom) setIsCustom(true); + return true; + }, [isCustom]); // 작업항목 추가 모달 열기 const openAddWorkItem = useCallback((phaseKey: string) => { @@ -204,7 +1232,6 @@ export function WorkStandardEditModal({ if (!ok || !currentProcess) return; if (editingWorkItem) { - // 수정 setProcesses(prev => { const next = [...prev]; const workItems = [...next[selectedProcessIdx].workItems]; @@ -216,7 +1243,6 @@ export function WorkStandardEditModal({ return next; }); } else { - // 추가 const phaseKey = addItemPhase === "IN" ? "IN" : addItemPhase; const phaseItems = currentProcess.workItems.filter( wi => wi.work_phase === phaseKey || (phaseKey === "IN" && wi.work_phase === "MAIN") @@ -271,11 +1297,6 @@ export function WorkStandardEditModal({ setDetailModalPhase(phaseKey); setDetailModalMode("add"); setEditingDetail(null); - setDetailFormData({ - detail_type: DETAIL_TYPES[0].value, - content: "", - is_required: "Y", - }); setDetailModalOpen(true); }, []); @@ -284,37 +1305,13 @@ export function WorkStandardEditModal({ setDetailModalPhase(phaseKey); setDetailModalMode("edit"); setEditingDetail(detail); - setDetailFormData({ ...detail }); setDetailModalOpen(true); }, []); - // 상세 추가/수정 처리 - const handleSaveDetail = useCallback(async () => { - const type = detailFormData.detail_type || ""; - if (!type) return; - - // 유효성 검사 (내용이 필요한 유형) - const needsContent = ["checklist", "procedure", "input", "equip_condition"]; - if (needsContent.includes(type) && !detailFormData.content?.trim()) { - toast.error("내용을 입력하세요"); - return; - } - if (type === "inspection" && !detailFormData.content?.trim()) { - toast.error("검사 항목명을 입력하세요"); - return; - } - + // 상세 추가 처리 — 다중행 자동생성 대응 (DetailFormModalInner의 onSubmit이 for-loop로 여러 번 호출) + const handleAddDetailRow = useCallback((data: Partial) => { const workItemId = selectedWorkItemIdByPhase[detailModalPhase]; if (!workItemId) return; - const ok = await ensureCustom(); - if (!ok) return; - - // content 자동 설정 (UI에서 직접 입력이 없는 유형들) - const submitData = { ...detailFormData }; - if (type === "lookup") submitData.content = submitData.content || "품목 등록 문서 (자동 연동)"; - if (type === "equip_inspection") submitData.content = submitData.content || "설비점검"; - if (type === "production_result") submitData.content = submitData.content || "작업수량 / 불량수량 / 양품수량"; - if (type === "material_input") submitData.content = submitData.content || "BOM 구성 자재 (자동 연동)"; setProcesses(prev => { const next = [...prev]; @@ -322,33 +1319,74 @@ export function WorkStandardEditModal({ const wiIdx = workItems.findIndex(wi => wi.id === workItemId); if (wiIdx < 0) return prev; - if (detailModalMode === "edit" && editingDetail) { - // 수정 - const details = (workItems[wiIdx].details || []).map(d => - d.id === editingDetail.id ? { ...d, ...submitData } : d - ); + const currentDetails = workItems[wiIdx].details || []; + const newDetail: WIWorkItemDetail = { + ...data, + id: `temp-detail-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + work_item_id: workItemId, + sort_order: currentDetails.length + 1, + }; + workItems[wiIdx] = { + ...workItems[wiIdx], + details: [...currentDetails, newDetail], + detail_count: currentDetails.length + 1, + }; + next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; + return next; + }); + setDirty(true); + }, [detailModalPhase, selectedWorkItemIdByPhase, selectedProcessIdx]); + + // 상세 수정 처리 + const handleUpdateDetailRow = useCallback((data: Partial) => { + const workItemId = selectedWorkItemIdByPhase[detailModalPhase]; + if (!workItemId || !editingDetail) return; + + setProcesses(prev => { + const next = [...prev]; + const workItems = [...next[selectedProcessIdx].workItems]; + const wiIdx = workItems.findIndex(wi => wi.id === workItemId); + if (wiIdx < 0) return prev; + + // 같은 유형의 apply 그룹 전체 일괄 업데이트 (필수여부 등) — 원본 WorkItemDetailList 로직과 동일 + const isApplyGroup = + (editingDetail.detail_type === "inspection" && editingDetail.process_inspection_apply === "apply") || + (editingDetail.detail_type === "equip_inspection" && editingDetail.equip_inspection_apply === "apply") || + editingDetail.detail_type === "material_input"; + + if (isApplyGroup && data.is_required !== undefined) { + const details = (workItems[wiIdx].details || []).map(d => { + const isSibling = + d.detail_type === editingDetail.detail_type && ( + d.detail_type === "inspection" ? d.process_inspection_apply === "apply" : + d.detail_type === "equip_inspection" ? d.equip_inspection_apply === "apply" : + d.detail_type === "material_input" + ); + return isSibling ? { ...d, is_required: data.is_required! } : d; + }); workItems[wiIdx] = { ...workItems[wiIdx], details }; } else { - // 추가 - const newDetail: WIWorkItemDetail = { - ...submitData, - id: `temp-detail-${Date.now()}`, - work_item_id: workItemId, - sort_order: (workItems[wiIdx].details?.length || 0) + 1, - }; - workItems[wiIdx] = { - ...workItems[wiIdx], - details: [...(workItems[wiIdx].details || []), newDetail], - detail_count: (workItems[wiIdx].detail_count || 0) + 1, - }; + const details = (workItems[wiIdx].details || []).map(d => + d.id === editingDetail.id ? { ...d, ...data } : d + ); + workItems[wiIdx] = { ...workItems[wiIdx], details }; } next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; return next; }); - - setDetailModalOpen(false); setDirty(true); - }, [detailFormData, detailModalPhase, detailModalMode, editingDetail, selectedWorkItemIdByPhase, ensureCustom, selectedProcessIdx]); + }, [detailModalPhase, selectedWorkItemIdByPhase, selectedProcessIdx, editingDetail]); + + // DetailFormModalInner onSubmit 분기 진입점 + const handleDetailSubmit = useCallback(async (data: Partial) => { + const ok = await ensureCustom(); + if (!ok) return; + if (detailModalMode === "add") { + handleAddDetailRow(data); + } else { + handleUpdateDetailRow(data); + } + }, [detailModalMode, handleAddDetailRow, handleUpdateDetailRow, ensureCustom]); // 상세 삭제 const handleDeleteDetail = useCallback(async (detailId: string, phaseKey: string) => { @@ -374,6 +1412,25 @@ export function WorkStandardEditModal({ setDirty(true); }, [selectedWorkItemIdByPhase, ensureCustom, selectedProcessIdx]); + // 드래그 정렬 — 상세 목록 순서 변경 + const handleReorderDetails = useCallback((phaseKey: string, reordered: WIWorkItemDetail[]) => { + const workItemId = selectedWorkItemIdByPhase[phaseKey]; + if (!workItemId) return; + setProcesses(prev => { + const next = [...prev]; + const workItems = [...next[selectedProcessIdx].workItems]; + const wiIdx = workItems.findIndex(wi => wi.id === workItemId); + if (wiIdx >= 0) { + // sort_order 재할당 + const withOrder = reordered.map((d, i) => ({ ...d, sort_order: i + 1 })); + workItems[wiIdx] = { ...workItems[wiIdx], details: withOrder }; + } + next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; + return next; + }); + setDirty(true); + }, [selectedWorkItemIdByPhase, selectedProcessIdx]); + // 저장 const handleSave = useCallback(async () => { if (!currentProcess) return; @@ -394,7 +1451,7 @@ export function WorkStandardEditModal({ } else { toast.error("저장에 실패했습니다"); } - } catch (err) { + } catch { toast.error("저장 중 오류가 발생했습니다"); } finally { setSaving(false); @@ -410,15 +1467,11 @@ export function WorkStandardEditModal({ toast.success("원본으로 초기화되었습니다"); await loadData(); } - } catch (err) { + } catch { toast.error("초기화에 실패했습니다"); } }, [workInstructionNo, loadData]); - const updateDetailField = (field: string, value: unknown) => { - setDetailFormData(prev => ({ ...prev, [field]: value })); - }; - return ( { if (!v) onClose(); }}> @@ -473,6 +1526,7 @@ export function WorkStandardEditModal({ const phaseItems = workItemsByPhase[phase.key] || []; const selectedWiId = selectedWorkItemIdByPhase[phase.key] || null; const selectedWi = phaseItems.find(wi => wi.id === selectedWiId) || null; + const selectedDetails = selectedWi?.details || []; return (
@@ -555,7 +1609,7 @@ export function WorkStandardEditModal({ )}
- {/* 우측: 상세 테이블 */} + {/* 우측: 상세 테이블 — 드래그 정렬 지원 */}
{!selectedWi ? (
@@ -568,7 +1622,7 @@ export function WorkStandardEditModal({
{selectedWi.title} - {selectedWi.details?.length || 0}개 + {selectedDetails.length}개
- {/* 상세 추가/수정 다이얼로그 (유형별 동적 필드) */} - { if (!v) setDetailModalOpen(false); }}> - e.stopPropagation()}> - - - 상세 항목 {detailModalMode === "add" ? "추가" : "수정"} - - - 상세 항목의 유형을 선택하고 내용을 입력하세요 - - - -
- {/* 유형 선택 */} -
- - -
- - {/* 체크리스트 */} - {detailFormData.detail_type === "checklist" && ( -
- - updateDetailField("content", e.target.value)} - placeholder="예: 전원 상태 확인" - className="mt-1 h-8 text-xs" - /> -
- )} - - {/* 검사항목 */} - {detailFormData.detail_type === "inspection" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="예: 외경 치수" - className="mt-1 h-8 text-xs" - /> -
-
-
- - updateDetailField("inspection_method", e.target.value)} - placeholder="예: 마이크로미터" - className="mt-1 h-8 text-xs" - /> -
-
- - -
-
-
-
-
- updateDetailField("base_value", e.target.value)} - placeholder="기준값" - className="h-8 text-xs" - /> -
- ± -
- updateDetailField("tolerance", e.target.value)} - placeholder="오차범위" - className="h-8 text-xs" - /> -
-
-
- - )} - - {/* 작업절차 */} - {detailFormData.detail_type === "procedure" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="예: 자재 투입" - className="mt-1 h-8 text-xs" - /> -
-
- - updateDetailField("duration_minutes", e.target.value ? Number(e.target.value) : undefined)} - placeholder="예: 5" - className="mt-1 h-8 text-xs" - /> -
- - )} - - {/* 직접입력 */} - {detailFormData.detail_type === "input" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="예: 작업자 의견" - className="mt-1 h-8 text-xs" - /> -
-
- - -
- - )} - - {/* 문서참조 */} - {detailFormData.detail_type === "lookup" && ( -
- - 해당 품목에 등록된 문서를 자동으로 불러옵니다. - -
- )} - - {/* 설비점검 */} - {detailFormData.detail_type === "equip_inspection" && ( -
- - updateDetailField("content", e.target.value)} - placeholder="예: 설비 가동 전 안전 점검" - className="mt-1 h-8 text-xs" - /> -
- )} - - {/* 설비조건 */} - {detailFormData.detail_type === "equip_condition" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="조건명 (예: 온도, 압력, RPM)" - className="mt-1 h-8 text-xs" - /> -
-
-
-
- -
-
-
-
- updateDetailField("condition_base_value", e.target.value)} - placeholder="기준값" - className="h-8 text-xs" - /> -
- ± -
- updateDetailField("condition_tolerance", e.target.value)} - placeholder="오차범위" - className="h-8 text-xs" - /> -
-
-
- - )} - - {/* 실적등록 */} - {detailFormData.detail_type === "production_result" && ( -
-

작업수량 / 불량수량 / 양품수량

-

실적 입력 항목이 자동으로 생성됩니다.

-
- )} - - {/* 자재투입 */} - {detailFormData.detail_type === "material_input" && ( -
-

BOM 구성 자재 (자동 연동)

-

품목에 등록된 BOM 구성 자재가 자동으로 적용됩니다.

-
- )} - - {/* 필수 여부 (모든 유형 공통) */} - {detailFormData.detail_type && ( -
- - -
- )} - - {/* 비고 (모든 유형 공통) */} - {detailFormData.detail_type && ( -
- - updateDetailField("remark", e.target.value)} - placeholder="비고 입력" - className="mt-1 h-8 text-xs" - /> -
- )} -
- - - - - -
-
+ {/* 상세 추가/수정 다이얼로그 — 원본 DetailFormModal 로직을 이식 */} + setDetailModalOpen(false)} + onSubmit={handleDetailSubmit} + editData={editingDetail} + mode={detailModalMode} + selectedItemCode={itemCode} + selectedProcessCode={currentProcess?.process_code} + /> ); diff --git a/frontend/app/(main)/COMPANY_29/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_29/quality/item-inspection/page.tsx index 2c71ed02..74fb5981 100644 --- a/frontend/app/(main)/COMPANY_29/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_29/quality/item-inspection/page.tsx @@ -13,6 +13,7 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen import { Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy, + ChevronUp, ChevronDown, ChevronsUpDown, } from "lucide-react"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { cn } from "@/lib/utils"; @@ -472,6 +473,34 @@ export default function ItemInspectionInfoPage() { return Object.values(map); }, [data]); + // 좌측 품목 목록 정렬 (컬럼 헤더 클릭 → asc → desc → 해제 순환) + const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); + const handleSort = (key: string) => { + setSortConfig(prev => { + if (!prev || prev.key !== key) return { key, direction: "asc" }; + if (prev.direction === "asc") return { key, direction: "desc" }; + return null; + }); + }; + const sortedGroupedData = useMemo(() => { + if (!sortConfig) return groupedData; + const { key, direction } = sortConfig; + const mul = direction === "asc" ? 1 : -1; + const getVal = (g: any): string => { + if (key === "inspection_type") { + const code = g.types[0] || ""; + return inspTypeCatOptions.find(o => o.code === code)?.label || code; + } + if (key === "is_active") { + return (g.is_active === "사용" || g.is_active === "true") ? "사용" : "미사용"; + } + return String(g[key] ?? ""); + }; + return [...groupedData].sort((a, b) => { + return getVal(a).localeCompare(getVal(b), "ko", { numeric: true }) * mul; + }); + }, [groupedData, sortConfig, inspTypeCatOptions]); + // 선택된 품목의 그룹 데이터 const selectedGroup = useMemo(() => { if (!selectedItemCode) return null; @@ -989,14 +1018,28 @@ export default function ItemInspectionInfoPage() { {ts.visibleColumns.map((col) => ( - - {col.label} + handleSort(col.key)} + > +
+ {col.label} + {sortConfig?.key === col.key ? ( + sortConfig.direction === "asc" + ? + : + ) : ( + + )} +
))}
- {groupedData.map((group) => ( + {sortedGroupedData.map((group) => ( a.toLowerCase()); + if (!allowed.includes(val.toLowerCase())) return false; } else if (f.operator === "between") { const [from, to] = f.value.split("|"); if (from && val < from) return false; diff --git a/frontend/app/(main)/COMPANY_30/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/COMPANY_30/production/work-instruction/WorkStandardEditModal.tsx index da7e8fd5..96b96535 100644 --- a/frontend/app/(main)/COMPANY_30/production/work-instruction/WorkStandardEditModal.tsx +++ b/frontend/app/(main)/COMPANY_30/production/work-instruction/WorkStandardEditModal.tsx @@ -15,9 +15,11 @@ import { } from "lucide-react"; import { toast } from "sonner"; import { - getWIWorkStandard, copyWorkStandard, saveWIWorkStandard, resetWIWorkStandard, + getWIWorkStandard, saveWIWorkStandard, resetWIWorkStandard, WIWorkItem, WIWorkItemDetail, WIWorkStandardProcess, } from "@/lib/api/workInstruction"; +import { getBomMaterials, BomMaterial } from "@/lib/api/processInfo"; +import { apiClient } from "@/lib/api/client"; interface WorkStandardEditModalProps { open: boolean; @@ -60,12 +62,46 @@ const UNIT_OPTIONS = [ "L/min", "Hz", "dB", "ea", "g", "mg", "ml", "L", ]; +// PLC 데이터 옵션 (원본 DetailFormModal과 동일) +const PLC_DATA_OPTIONS = [ + { value: "PLC_TEMP_01", label: "온도 (PLC_TEMP_01)" }, + { value: "PLC_PRES_01", label: "압력 (PLC_PRES_01)" }, + { value: "PLC_RPM_01", label: "회전수 (PLC_RPM_01)" }, + { value: "PLC_TORQ_01", label: "토크 (PLC_TORQ_01)" }, + { value: "PLC_SPD_01", label: "속도 (PLC_SPD_01)" }, + { value: "PLC_CUR_01", label: "전류 (PLC_CUR_01)" }, + { value: "PLC_VOLT_01", label: "전압 (PLC_VOLT_01)" }, + { value: "PLC_VIB_01", label: "진동 (PLC_VIB_01)" }, + { value: "PLC_HUM_01", label: "습도 (PLC_HUM_01)" }, + { value: "PLC_FLOW_01", label: "유량 (PLC_FLOW_01)" }, +]; + +const PLC_PRODUCTION_OPTIONS: Record = { + work_qty: [ + { value: "PLC_CNT_01", label: "생산카운터 (PLC_CNT_01)" }, + { value: "PLC_CNT_02", label: "완료카운터 (PLC_CNT_02)" }, + { value: "PLC_QTY_01", label: "작업수량 (PLC_QTY_01)" }, + ], + defect_qty: [ + { value: "PLC_NG_01", label: "불량카운터 (PLC_NG_01)" }, + { value: "PLC_NG_02", label: "NG감지기 (PLC_NG_02)" }, + { value: "PLC_REJ_01", label: "리젝트수 (PLC_REJ_01)" }, + ], + good_qty: [ + { value: "PLC_OK_01", label: "양품카운터 (PLC_OK_01)" }, + { value: "PLC_OK_02", label: "합격카운터 (PLC_OK_02)" }, + { value: "PLC_GOOD_01", label: "양품수량 (PLC_GOOD_01)" }, + ], +}; + const getDetailTypeLabel = (type: string) => DETAIL_TYPES.find(d => d.value === type)?.label || type; +// 원본 WorkItemDetailList의 getContentSummary를 그대로 이식 const getContentSummary = (detail: WIWorkItemDetail): string => { const type = detail.detail_type; if (type === "inspection") { + if (detail.process_inspection_apply === "apply") return detail.content || "품목별 검사정보 (자동 연동)"; const parts = [detail.content]; if (detail.inspection_method) parts.push(`[${detail.inspection_method}]`); if (detail.base_value) { @@ -81,7 +117,9 @@ const getContentSummary = (detail: WIWorkItemDetail): string => { return `${detail.content} [${typeMap[detail.input_type] || detail.input_type}]`; } if (type === "lookup") return "품목 등록 문서 (자동 연동)"; - if (type === "equip_inspection") return detail.content || "설비점검"; + 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) { @@ -90,10 +128,1002 @@ const getContentSummary = (detail: WIWorkItemDetail): string => { return parts.join(" "); } if (type === "production_result") return "작업수량 / 불량수량 / 양품수량"; - if (type === "material_input") return "BOM 구성 자재 (자동 연동)"; + if (type === "material_input") return detail.content || "BOM 구성 자재 (자동 연동)"; return detail.content || "-"; }; +// ============================================================ +// 상세 항목 추가/수정 모달 — 원본 DetailFormModal을 내부에 이식 +// 원본: frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx +// ============================================================ +interface DetailFormModalInnerProps { + open: boolean; + onClose: () => void; + onSubmit: (data: Partial) => void; + editData?: WIWorkItemDetail | null; + mode: "add" | "edit"; + selectedItemCode?: string; + selectedProcessCode?: string; +} + +function DetailFormModalInner({ + open, + onClose, + onSubmit, + editData, + mode, + selectedItemCode, + selectedProcessCode, +}: DetailFormModalInnerProps) { + const [formData, setFormData] = useState>({}); + const [bomMaterials, setBomMaterials] = useState([]); + const [bomLoading, setBomLoading] = useState(false); + const [bomChecked, setBomChecked] = useState>(new Set()); + + // 품목검사정보 연동 + const [itemInspections, setItemInspections] = useState([]); + const [itemInspLoading, setItemInspLoading] = useState(false); + + // 품목 카테고리 (type 코드→라벨) + const [itemTypeCatMap, setItemTypeCatMap] = useState>({}); + + // 설비 점검항목 연동 + const [equipInspItems, setEquipInspItems] = useState([]); + const [equipInspLoading, setEquipInspLoading] = useState(false); + + const loadBomMaterials = useCallback(async () => { + if (!selectedItemCode) { + setBomMaterials([]); + return; + } + setBomLoading(true); + try { + const res = await getBomMaterials(selectedItemCode); + if (res.success && res.data) { + setBomMaterials(res.data); + } else { + setBomMaterials([]); + } + } catch { + setBomMaterials([]); + } finally { + setBomLoading(false); + } + }, [selectedItemCode]); + + // BOM 자재 로드 완료 후 체크 상태 초기화 + useEffect(() => { + if (!open || bomMaterials.length === 0) return; + if (mode === "edit" && editData?.selected_bom_items) { + const savedBom = editData.selected_bom_items; + const parsedBom = typeof savedBom === "string" ? JSON.parse(savedBom) : savedBom; + if (Array.isArray(parsedBom)) { + const matIds = new Set(bomMaterials.map((m) => m.id)); + const restored = new Set(); + const seenChildForLegacy = new Set(); + for (const saved of parsedBom) { + if (matIds.has(saved)) { + restored.add(saved); + } else { + const legacyKey = String(saved); + if (seenChildForLegacy.has(legacyKey)) continue; + const firstMat = bomMaterials.find((m) => m.child_item_id === legacyKey); + if (firstMat) { + restored.add(firstMat.id); + seenChildForLegacy.add(legacyKey); + } + } + } + setBomChecked(restored); + return; + } + } + setBomChecked(new Set()); + }, [open, bomMaterials, mode, editData]); + + useEffect(() => { + if (open) { + if (mode === "edit" && editData) { + setFormData({ ...editData }); + } else { + setFormData({ + detail_type: DETAIL_TYPES[0]?.value || "", + content: "", + is_required: "Y", + }); + } + } + }, [open, mode, editData]); + + useEffect(() => { + if (open && formData.detail_type === "material_input") { + loadBomMaterials(); + if (Object.keys(itemTypeCatMap).length === 0) { + apiClient.get("/table-categories/item_info/type/values").then(res => { + if (res.data?.success && res.data.data?.length) { + const map: Record = {}; + const flatten = (arr: any[]) => { for (const v of arr) { map[v.valueCode] = v.valueLabel; if (v.children?.length) flatten(v.children); } }; + flatten(res.data.data); + setItemTypeCatMap(map); + } + }).catch(() => {}); + } + } + }, [open, formData.detail_type, loadBomMaterials, itemTypeCatMap]); + + // 검사항목 적용 시 품목검사정보 로드 + useEffect(() => { + if (!open || formData.detail_type !== "inspection" || formData.process_inspection_apply !== "apply" || !selectedItemCode) { + setItemInspections([]); + return; + } + (async () => { + setItemInspLoading(true); + try { + const res = await apiClient.post("/table-management/tables/item_inspection_info/data", { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: selectedItemCode }] }, + autoFilter: true, + }); + setItemInspections(res.data?.data?.data || res.data?.data?.rows || []); + } catch { setItemInspections([]); } finally { setItemInspLoading(false); } + })(); + }, [open, formData.detail_type, formData.process_inspection_apply, selectedItemCode]); + + // 설비점검 적용 시 해당 공정의 설비 점검항목 로드 + useEffect(() => { + if (!open || formData.detail_type !== "equip_inspection" || formData.equip_inspection_apply !== "apply" || !selectedProcessCode) { + setEquipInspItems([]); + return; + } + (async () => { + setEquipInspLoading(true); + try { + // 1. 해당 공정의 설비 목록 조회 + const equipRes = await apiClient.post("/table-management/tables/process_equipment/data", { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [{ columnName: "process_code", operator: "equals", value: selectedProcessCode }] }, + autoFilter: true, + }); + const equipCodes = (equipRes.data?.data?.data || equipRes.data?.data?.rows || []).map((e: any) => e.equipment_code).filter(Boolean); + if (equipCodes.length === 0) { setEquipInspItems([]); return; } + // 2. 각 설비의 점검항목 조회 + const inspRes = await apiClient.post("/table-management/tables/equipment_inspection_item/data", { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "in", value: equipCodes }] }, + autoFilter: true, + }); + setEquipInspItems(inspRes.data?.data?.data || inspRes.data?.data?.rows || []); + } catch { setEquipInspItems([]); } finally { setEquipInspLoading(false); } + })(); + }, [open, formData.detail_type, formData.equip_inspection_apply, selectedProcessCode]); + + const updateField = (field: string, value: any) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + const handleSubmit = () => { + if (!formData.detail_type) return; + + const type = formData.detail_type; + + if (type === "checklist" && !formData.content?.trim()) return; + if (type === "inspection") { + if (!formData.process_inspection_apply) return; + if (formData.process_inspection_apply === "none" && !formData.content?.trim()) return; + } + if (type === "procedure" && !formData.content?.trim()) return; + if (type === "input" && !formData.content?.trim()) return; + if (type === "equip_inspection" && !formData.equip_inspection_apply) return; + if (type === "equip_condition" && !formData.content?.trim()) return; + + const submitData = { ...formData }; + + // 검사항목 적용 → 품목검사정보 각각 개별 등록 (다중행 자동생성) + if (type === "inspection" && submitData.process_inspection_apply === "apply") { + if (itemInspections.length > 0) { + for (const insp of itemInspections) { + onSubmit({ + ...submitData, + detail_type: "inspection", + content: `${insp.inspection_item_name || insp.inspection_standard || "-"} | ${insp.pass_criteria || ""}`.trim(), + is_required: submitData.is_required || "Y", + }); + } + onClose(); + return; + } + submitData.content = submitData.content || "품목별 검사정보 (자동 연동)"; + } + if (type === "lookup") { + submitData.content = submitData.content || "품목 등록 문서 (자동 연동)"; + } + // 설비점검 적용 → 점검항목 각각 개별 등록 (다중행 자동생성) + if (type === "equip_inspection" && submitData.equip_inspection_apply === "apply") { + if (equipInspItems.length > 0) { + for (const item of equipInspItems) { + const range = (item.lower_limit || item.upper_limit) ? `${item.lower_limit || ""} ~ ${item.upper_limit || ""}${item.unit ? ` ${item.unit}` : ""}` : ""; + onSubmit({ + ...submitData, + detail_type: "equip_inspection", + content: `${item.inspection_item || "-"}${range ? ` | ${range}` : ""}`.trim(), + is_required: submitData.is_required || "Y", + }); + } + onClose(); + return; + } + submitData.content = submitData.content || "설비 점검항목 (설비정보 연동)"; + } + if (type === "production_result") { + submitData.content = submitData.content || "작업수량 / 불량수량 / 양품수량"; + } + if (type === "material_input") { + // 선택된 BOM 자재를 각각 개별 상세 항목으로 등록 (다중행 자동생성) + const checkedMats = bomMaterials.filter(m => bomChecked.has(m.id)); + if (checkedMats.length > 0) { + const resolveType = (code: string) => itemTypeCatMap[code] || code || ""; + for (const mat of checkedMats) { + const typeLabel = resolveType(mat.child_item_type || ""); + const unitLabel = mat.detail_unit || mat.item_unit || ""; + onSubmit({ + ...submitData, + detail_type: "material_input", + content: `${mat.child_item_name || mat.child_item_id}${typeLabel ? ` ${typeLabel}` : ""} | ${mat.quantity || 1} ${unitLabel}`.trim(), + is_required: submitData.is_required || "Y", + bom_item_id: mat.child_item_id, + bom_item_name: mat.child_item_name || "", + bom_qty: String(mat.quantity || 1), + bom_unit: unitLabel, + }); + } + onClose(); + return; + } + submitData.content = submitData.content || "BOM 구성 자재 (자동 연동)"; + submitData.selected_bom_items = Array.from(bomChecked); + } + + onSubmit(submitData); + onClose(); + }; + + const currentType = formData.detail_type || ""; + + return ( + !v && onClose()}> + e.stopPropagation()}> + + + 상세 항목 {mode === "add" ? "추가" : "수정"} + + + 상세 항목의 유형을 선택하고 내용을 입력하세요 + + + +
+ {/* 유형 선택 */} +
+ + +
+ + {/* ============ 체크리스트 ============ */} + {currentType === "checklist" && ( +
+ + updateField("content", e.target.value)} + placeholder="예: 전원 상태 확인" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ )} + + {/* ============ 검사항목 ============ */} + {currentType === "inspection" && ( + <> + {/* 공정검사 적용 여부 */} +
+ +
+ + +
+
+ + {/* 적용 시: 품목검사정보에서 등록된 검사항목 표시 */} + {formData.process_inspection_apply === "apply" && ( +
+

+ 품목별 검사정보 (자동 연동) +

+ {itemInspLoading ? ( +
조회 중...
+ ) : itemInspections.length === 0 ? ( +

등록된 검사항목이 없습니다. 품목검사정보에서 먼저 등록해주세요.

+ ) : ( + + + + + + + + + + {itemInspections.map((insp, idx) => ( + + + + + + ))} + +
검사항목검사유형합격기준
{insp.inspection_item_name || insp.inspection_standard || "-"}{insp.inspection_type || "-"}{insp.pass_criteria || "-"}
+ )} +
+ )} + + {/* 미적용 시: 수동 입력 */} + {formData.process_inspection_apply === "none" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 외경 치수" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+
+ + updateField("inspection_method", e.target.value)} + placeholder="예: 마이크로미터" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + +
+
+ + {/* 기준값 ± 오차범위 + 자동수집 + PLC */} +
+
+
+ updateField("base_value", e.target.value)} + placeholder="기준값" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ ± +
+ updateField("tolerance", e.target.value)} + placeholder="오차범위" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ +
+ + +
+
+ + )} + + )} + + {/* ============ 작업절차 ============ */} + {currentType === "procedure" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 자재 투입" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + + updateField("duration_minutes", e.target.value ? Number(e.target.value) : undefined) + } + placeholder="예: 5" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + )} + + {/* ============ 직접입력 ============ */} + {currentType === "input" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 작업자 의견" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + +
+ + )} + + {/* ============ 문서참조 ============ */} + {currentType === "lookup" && ( + <> +
+ 📄 + + 해당 품목에 등록된 문서를 자동으로 불러옵니다. + +
+
+ +
+

+ 품목이 선택되지 않았습니다. +

+
+
+ + )} + + {/* ============ 설비점검 ============ */} + {currentType === "equip_inspection" && ( + <> +
+ 🏭 + + 공정에 지정된 설비를 자동 참조합니다. + +
+ +
+ +
+ + +
+
+ + {/* 적용 시: 설비 점검항목 표시 */} + {formData.equip_inspection_apply === "apply" && ( +
+

+ 설비 점검항목 (설비정보 연동) +

+ {equipInspLoading ? ( +
조회 중...
+ ) : equipInspItems.length === 0 ? ( +

해당 공정에 지정된 설비의 점검항목이 없습니다.

+ ) : ( + + + + + + + + + + + {equipInspItems.map((item, idx) => ( + + + + + + + ))} + +
설비코드점검항목점검방법기준범위
{item.equipment_code || "-"}{item.inspection_item || "-"}{item.inspection_method || "-"}{item.lower_limit || item.upper_limit ? `${item.lower_limit || ""} ~ ${item.upper_limit || ""}${item.unit ? ` ${item.unit}` : ""}` : "-"}
+ )} +
+ )} + + )} + + {/* ============ 설비조건 ============ */} + {currentType === "equip_condition" && ( + <> +
+ 🏭 + + 공정에 지정된 설비를 자동 참조합니다. + +
+ +
+ +
+ {/* 조건명 + 단위 */} +
+
+ updateField("content", e.target.value)} + placeholder="조건명 (예: 온도, 압력, RPM)" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ +
+
+ + {/* 기준값 ± 오차범위 */} +
+
+ updateField("condition_base_value", e.target.value)} + placeholder="기준값" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ ± +
+ updateField("condition_tolerance", e.target.value)} + placeholder="오차범위" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + {/* 자동수집 */} +
+ + +
+
+
+ + )} + + {/* ============ 실적등록 ============ */} + {currentType === "production_result" && ( +
+ +
+ {/* 작업수량 */} +
+
+ 📦 작업수량 + 생산된 전체 수량 +
+
+ + +
+
+ + {/* 불량수량 */} +
+
+ 🚫 불량수량 + 불량으로 판정된 수량 +
+
+ + +
+
+ + {/* 양품수량 */} +
+
+ ✅ 양품수량 + 작업수량 - 불량수량 +
+
+ + +
+
+
+
+ )} + + {/* ============ 자재투입 ============ */} + {currentType === "material_input" && ( +
+ +
+
+

+ 📦 BOM 구성 자재 (자동 연동) +

+ + {bomMaterials.length > 0 ? `${bomMaterials.length}건` : ""} + +
+ {bomMaterials.length > 0 && ( +
+ 0 + ? "indeterminate" + : false + } + onCheckedChange={(checked) => { + if (checked) { + setBomChecked(new Set(bomMaterials.map((m) => m.id))); + } else { + setBomChecked(new Set()); + } + }} + /> + + 전체 선택 ({bomChecked.size} / {bomMaterials.length}) + +
+ )} +
+ {bomLoading ? ( +
+ + BOM 데이터를 불러오는 중... +
+ ) : !selectedItemCode ? ( +
+ 품목을 먼저 선택하세요. +
+ ) : bomMaterials.length === 0 ? ( +
+ 해당 품목의 BOM 구성 자재가 없습니다. +
+ ) : ( + bomMaterials.map((mat) => { + const resolvedType = itemTypeCatMap[mat.child_item_type || ""] || mat.child_item_type || ""; + const typeColor = + resolvedType.includes("원자재") ? "#16a34a" + : resolvedType.includes("반제품") ? "#2563eb" + : resolvedType.includes("제품") ? "#9333ea" + : "#6b7280"; + return ( +
+ { + setBomChecked((prev) => { + const next = new Set(prev); + if (checked) next.add(mat.id); + else next.delete(mat.id); + return next; + }); + }} + /> +
+
+ {mat.child_item_type && ( + + {resolvedType} + + )} + + {mat.child_item_code || "-"} + + + {mat.child_item_name || "(이름 없음)"} + +
+
+ 소요량: {mat.quantity || "0"} {mat.detail_unit || mat.item_unit || ""} + {mat.process_type ? ` | 공정: ${mat.process_type}` : ""} +
+
+
+ ); + }) + )} +
+
+
+ )} + + {/* 필수 여부 (모든 유형 공통) */} + {currentType && ( +
+ + +
+ )} +
+ + + + + +
+
+ ); +} + +// ============================================================ +// 메인 모달 +// ============================================================ export function WorkStandardEditModal({ open, onClose, @@ -122,9 +1152,12 @@ export function WorkStandardEditModal({ const [detailModalOpen, setDetailModalOpen] = useState(false); const [detailModalMode, setDetailModalMode] = useState<"add" | "edit">("add"); const [detailModalPhase, setDetailModalPhase] = useState("PRE"); - const [detailFormData, setDetailFormData] = useState>({}); const [editingDetail, setEditingDetail] = useState(null); + // 드래그 정렬용 state (phase + workItem 조합으로 스코프 관리) + const [dragIdx, setDragIdx] = useState(null); + const [overIdx, setOverIdx] = useState(null); + // 데이터 로드 const loadData = useCallback(async () => { if (!workInstructionNo || !routingVersionId) return; @@ -163,21 +1196,16 @@ export function WorkStandardEditModal({ return map; }, [currentProcess]); - // 커스텀 복사 확인 후 수정 + // 커스텀 전환 — 로컬 마킹만 수행 (TASK:ERP-012 수정분 보존) + // 기존에는 copyWorkStandard API 호출 + loadData()로 state 전면 재로딩을 해서 + // closure로 잡은 workItem id / detail id가 모두 stale해져서 + // ① 삭제가 무효화되어 UI상 상세가 0개로 보이고 + // ② 추가 state 변경이 반영되지 않아 저장이 먹통이 됨. + // saveWIWorkStandard는 DELETE→INSERT 방식으로 전체 덮어쓰므로 사전 복사 불필요. const ensureCustom = useCallback(async () => { - if (isCustom) return true; - try { - const res = await copyWorkStandard(workInstructionNo, routingVersionId); - if (res.success) { - await loadData(); - setIsCustom(true); - return true; - } - } catch (err) { - toast.error("원본 복사에 실패했습니다"); - } - return false; - }, [isCustom, workInstructionNo, routingVersionId, loadData]); + if (!isCustom) setIsCustom(true); + return true; + }, [isCustom]); // 작업항목 추가 모달 열기 const openAddWorkItem = useCallback((phaseKey: string) => { @@ -204,7 +1232,6 @@ export function WorkStandardEditModal({ if (!ok || !currentProcess) return; if (editingWorkItem) { - // 수정 setProcesses(prev => { const next = [...prev]; const workItems = [...next[selectedProcessIdx].workItems]; @@ -216,7 +1243,6 @@ export function WorkStandardEditModal({ return next; }); } else { - // 추가 const phaseKey = addItemPhase === "IN" ? "IN" : addItemPhase; const phaseItems = currentProcess.workItems.filter( wi => wi.work_phase === phaseKey || (phaseKey === "IN" && wi.work_phase === "MAIN") @@ -271,11 +1297,6 @@ export function WorkStandardEditModal({ setDetailModalPhase(phaseKey); setDetailModalMode("add"); setEditingDetail(null); - setDetailFormData({ - detail_type: DETAIL_TYPES[0].value, - content: "", - is_required: "Y", - }); setDetailModalOpen(true); }, []); @@ -284,37 +1305,13 @@ export function WorkStandardEditModal({ setDetailModalPhase(phaseKey); setDetailModalMode("edit"); setEditingDetail(detail); - setDetailFormData({ ...detail }); setDetailModalOpen(true); }, []); - // 상세 추가/수정 처리 - const handleSaveDetail = useCallback(async () => { - const type = detailFormData.detail_type || ""; - if (!type) return; - - // 유효성 검사 (내용이 필요한 유형) - const needsContent = ["checklist", "procedure", "input", "equip_condition"]; - if (needsContent.includes(type) && !detailFormData.content?.trim()) { - toast.error("내용을 입력하세요"); - return; - } - if (type === "inspection" && !detailFormData.content?.trim()) { - toast.error("검사 항목명을 입력하세요"); - return; - } - + // 상세 추가 처리 — 다중행 자동생성 대응 (DetailFormModalInner의 onSubmit이 for-loop로 여러 번 호출) + const handleAddDetailRow = useCallback((data: Partial) => { const workItemId = selectedWorkItemIdByPhase[detailModalPhase]; if (!workItemId) return; - const ok = await ensureCustom(); - if (!ok) return; - - // content 자동 설정 (UI에서 직접 입력이 없는 유형들) - const submitData = { ...detailFormData }; - if (type === "lookup") submitData.content = submitData.content || "품목 등록 문서 (자동 연동)"; - if (type === "equip_inspection") submitData.content = submitData.content || "설비점검"; - if (type === "production_result") submitData.content = submitData.content || "작업수량 / 불량수량 / 양품수량"; - if (type === "material_input") submitData.content = submitData.content || "BOM 구성 자재 (자동 연동)"; setProcesses(prev => { const next = [...prev]; @@ -322,33 +1319,74 @@ export function WorkStandardEditModal({ const wiIdx = workItems.findIndex(wi => wi.id === workItemId); if (wiIdx < 0) return prev; - if (detailModalMode === "edit" && editingDetail) { - // 수정 - const details = (workItems[wiIdx].details || []).map(d => - d.id === editingDetail.id ? { ...d, ...submitData } : d - ); + const currentDetails = workItems[wiIdx].details || []; + const newDetail: WIWorkItemDetail = { + ...data, + id: `temp-detail-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + work_item_id: workItemId, + sort_order: currentDetails.length + 1, + }; + workItems[wiIdx] = { + ...workItems[wiIdx], + details: [...currentDetails, newDetail], + detail_count: currentDetails.length + 1, + }; + next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; + return next; + }); + setDirty(true); + }, [detailModalPhase, selectedWorkItemIdByPhase, selectedProcessIdx]); + + // 상세 수정 처리 + const handleUpdateDetailRow = useCallback((data: Partial) => { + const workItemId = selectedWorkItemIdByPhase[detailModalPhase]; + if (!workItemId || !editingDetail) return; + + setProcesses(prev => { + const next = [...prev]; + const workItems = [...next[selectedProcessIdx].workItems]; + const wiIdx = workItems.findIndex(wi => wi.id === workItemId); + if (wiIdx < 0) return prev; + + // 같은 유형의 apply 그룹 전체 일괄 업데이트 (필수여부 등) — 원본 WorkItemDetailList 로직과 동일 + const isApplyGroup = + (editingDetail.detail_type === "inspection" && editingDetail.process_inspection_apply === "apply") || + (editingDetail.detail_type === "equip_inspection" && editingDetail.equip_inspection_apply === "apply") || + editingDetail.detail_type === "material_input"; + + if (isApplyGroup && data.is_required !== undefined) { + const details = (workItems[wiIdx].details || []).map(d => { + const isSibling = + d.detail_type === editingDetail.detail_type && ( + d.detail_type === "inspection" ? d.process_inspection_apply === "apply" : + d.detail_type === "equip_inspection" ? d.equip_inspection_apply === "apply" : + d.detail_type === "material_input" + ); + return isSibling ? { ...d, is_required: data.is_required! } : d; + }); workItems[wiIdx] = { ...workItems[wiIdx], details }; } else { - // 추가 - const newDetail: WIWorkItemDetail = { - ...submitData, - id: `temp-detail-${Date.now()}`, - work_item_id: workItemId, - sort_order: (workItems[wiIdx].details?.length || 0) + 1, - }; - workItems[wiIdx] = { - ...workItems[wiIdx], - details: [...(workItems[wiIdx].details || []), newDetail], - detail_count: (workItems[wiIdx].detail_count || 0) + 1, - }; + const details = (workItems[wiIdx].details || []).map(d => + d.id === editingDetail.id ? { ...d, ...data } : d + ); + workItems[wiIdx] = { ...workItems[wiIdx], details }; } next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; return next; }); - - setDetailModalOpen(false); setDirty(true); - }, [detailFormData, detailModalPhase, detailModalMode, editingDetail, selectedWorkItemIdByPhase, ensureCustom, selectedProcessIdx]); + }, [detailModalPhase, selectedWorkItemIdByPhase, selectedProcessIdx, editingDetail]); + + // DetailFormModalInner onSubmit 분기 진입점 + const handleDetailSubmit = useCallback(async (data: Partial) => { + const ok = await ensureCustom(); + if (!ok) return; + if (detailModalMode === "add") { + handleAddDetailRow(data); + } else { + handleUpdateDetailRow(data); + } + }, [detailModalMode, handleAddDetailRow, handleUpdateDetailRow, ensureCustom]); // 상세 삭제 const handleDeleteDetail = useCallback(async (detailId: string, phaseKey: string) => { @@ -374,6 +1412,25 @@ export function WorkStandardEditModal({ setDirty(true); }, [selectedWorkItemIdByPhase, ensureCustom, selectedProcessIdx]); + // 드래그 정렬 — 상세 목록 순서 변경 + const handleReorderDetails = useCallback((phaseKey: string, reordered: WIWorkItemDetail[]) => { + const workItemId = selectedWorkItemIdByPhase[phaseKey]; + if (!workItemId) return; + setProcesses(prev => { + const next = [...prev]; + const workItems = [...next[selectedProcessIdx].workItems]; + const wiIdx = workItems.findIndex(wi => wi.id === workItemId); + if (wiIdx >= 0) { + // sort_order 재할당 + const withOrder = reordered.map((d, i) => ({ ...d, sort_order: i + 1 })); + workItems[wiIdx] = { ...workItems[wiIdx], details: withOrder }; + } + next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; + return next; + }); + setDirty(true); + }, [selectedWorkItemIdByPhase, selectedProcessIdx]); + // 저장 const handleSave = useCallback(async () => { if (!currentProcess) return; @@ -394,7 +1451,7 @@ export function WorkStandardEditModal({ } else { toast.error("저장에 실패했습니다"); } - } catch (err) { + } catch { toast.error("저장 중 오류가 발생했습니다"); } finally { setSaving(false); @@ -410,15 +1467,11 @@ export function WorkStandardEditModal({ toast.success("원본으로 초기화되었습니다"); await loadData(); } - } catch (err) { + } catch { toast.error("초기화에 실패했습니다"); } }, [workInstructionNo, loadData]); - const updateDetailField = (field: string, value: unknown) => { - setDetailFormData(prev => ({ ...prev, [field]: value })); - }; - return ( { if (!v) onClose(); }}> @@ -473,6 +1526,7 @@ export function WorkStandardEditModal({ const phaseItems = workItemsByPhase[phase.key] || []; const selectedWiId = selectedWorkItemIdByPhase[phase.key] || null; const selectedWi = phaseItems.find(wi => wi.id === selectedWiId) || null; + const selectedDetails = selectedWi?.details || []; return (
@@ -555,7 +1609,7 @@ export function WorkStandardEditModal({ )}
- {/* 우측: 상세 테이블 */} + {/* 우측: 상세 테이블 — 드래그 정렬 지원 */}
{!selectedWi ? (
@@ -568,7 +1622,7 @@ export function WorkStandardEditModal({
{selectedWi.title} - {selectedWi.details?.length || 0}개 + {selectedDetails.length}개
- {/* 상세 추가/수정 다이얼로그 (유형별 동적 필드) */} - { if (!v) setDetailModalOpen(false); }}> - e.stopPropagation()}> - - - 상세 항목 {detailModalMode === "add" ? "추가" : "수정"} - - - 상세 항목의 유형을 선택하고 내용을 입력하세요 - - - -
- {/* 유형 선택 */} -
- - -
- - {/* 체크리스트 */} - {detailFormData.detail_type === "checklist" && ( -
- - updateDetailField("content", e.target.value)} - placeholder="예: 전원 상태 확인" - className="mt-1 h-8 text-xs" - /> -
- )} - - {/* 검사항목 */} - {detailFormData.detail_type === "inspection" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="예: 외경 치수" - className="mt-1 h-8 text-xs" - /> -
-
-
- - updateDetailField("inspection_method", e.target.value)} - placeholder="예: 마이크로미터" - className="mt-1 h-8 text-xs" - /> -
-
- - -
-
-
-
-
- updateDetailField("base_value", e.target.value)} - placeholder="기준값" - className="h-8 text-xs" - /> -
- ± -
- updateDetailField("tolerance", e.target.value)} - placeholder="오차범위" - className="h-8 text-xs" - /> -
-
-
- - )} - - {/* 작업절차 */} - {detailFormData.detail_type === "procedure" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="예: 자재 투입" - className="mt-1 h-8 text-xs" - /> -
-
- - updateDetailField("duration_minutes", e.target.value ? Number(e.target.value) : undefined)} - placeholder="예: 5" - className="mt-1 h-8 text-xs" - /> -
- - )} - - {/* 직접입력 */} - {detailFormData.detail_type === "input" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="예: 작업자 의견" - className="mt-1 h-8 text-xs" - /> -
-
- - -
- - )} - - {/* 문서참조 */} - {detailFormData.detail_type === "lookup" && ( -
- - 해당 품목에 등록된 문서를 자동으로 불러옵니다. - -
- )} - - {/* 설비점검 */} - {detailFormData.detail_type === "equip_inspection" && ( -
- - updateDetailField("content", e.target.value)} - placeholder="예: 설비 가동 전 안전 점검" - className="mt-1 h-8 text-xs" - /> -
- )} - - {/* 설비조건 */} - {detailFormData.detail_type === "equip_condition" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="조건명 (예: 온도, 압력, RPM)" - className="mt-1 h-8 text-xs" - /> -
-
-
-
- -
-
-
-
- updateDetailField("condition_base_value", e.target.value)} - placeholder="기준값" - className="h-8 text-xs" - /> -
- ± -
- updateDetailField("condition_tolerance", e.target.value)} - placeholder="오차범위" - className="h-8 text-xs" - /> -
-
-
- - )} - - {/* 실적등록 */} - {detailFormData.detail_type === "production_result" && ( -
-

작업수량 / 불량수량 / 양품수량

-

실적 입력 항목이 자동으로 생성됩니다.

-
- )} - - {/* 자재투입 */} - {detailFormData.detail_type === "material_input" && ( -
-

BOM 구성 자재 (자동 연동)

-

품목에 등록된 BOM 구성 자재가 자동으로 적용됩니다.

-
- )} - - {/* 필수 여부 (모든 유형 공통) */} - {detailFormData.detail_type && ( -
- - -
- )} - - {/* 비고 (모든 유형 공통) */} - {detailFormData.detail_type && ( -
- - updateDetailField("remark", e.target.value)} - placeholder="비고 입력" - className="mt-1 h-8 text-xs" - /> -
- )} -
- - - - - -
-
+ {/* 상세 추가/수정 다이얼로그 — 원본 DetailFormModal 로직을 이식 */} + setDetailModalOpen(false)} + onSubmit={handleDetailSubmit} + editData={editingDetail} + mode={detailModalMode} + selectedItemCode={itemCode} + selectedProcessCode={currentProcess?.process_code} + /> ); diff --git a/frontend/app/(main)/COMPANY_30/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_30/quality/item-inspection/page.tsx index 0e976d47..6bb55a70 100644 --- a/frontend/app/(main)/COMPANY_30/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_30/quality/item-inspection/page.tsx @@ -13,6 +13,7 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen import { Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy, + ChevronUp, ChevronDown, ChevronsUpDown, } from "lucide-react"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { cn } from "@/lib/utils"; @@ -472,6 +473,34 @@ export default function ItemInspectionInfoPage() { return Object.values(map); }, [data]); + // 좌측 품목 목록 정렬 (컬럼 헤더 클릭 → asc → desc → 해제 순환) + const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); + const handleSort = (key: string) => { + setSortConfig(prev => { + if (!prev || prev.key !== key) return { key, direction: "asc" }; + if (prev.direction === "asc") return { key, direction: "desc" }; + return null; + }); + }; + const sortedGroupedData = useMemo(() => { + if (!sortConfig) return groupedData; + const { key, direction } = sortConfig; + const mul = direction === "asc" ? 1 : -1; + const getVal = (g: any): string => { + if (key === "inspection_type") { + const code = g.types[0] || ""; + return inspTypeCatOptions.find(o => o.code === code)?.label || code; + } + if (key === "is_active") { + return (g.is_active === "사용" || g.is_active === "true") ? "사용" : "미사용"; + } + return String(g[key] ?? ""); + }; + return [...groupedData].sort((a, b) => { + return getVal(a).localeCompare(getVal(b), "ko", { numeric: true }) * mul; + }); + }, [groupedData, sortConfig, inspTypeCatOptions]); + // 선택된 품목의 그룹 데이터 const selectedGroup = useMemo(() => { if (!selectedItemCode) return null; @@ -989,14 +1018,28 @@ export default function ItemInspectionInfoPage() { {ts.visibleColumns.map((col) => ( - - {col.label} + handleSort(col.key)} + > +
+ {col.label} + {sortConfig?.key === col.key ? ( + sortConfig.direction === "asc" + ? + : + ) : ( + + )} +
))}
- {groupedData.map((group) => ( + {sortedGroupedData.map((group) => ( a.toLowerCase()); + if (!allowed.includes(val.toLowerCase())) return false; } else if (f.operator === "between") { const [from, to] = f.value.split("|"); if (from && val < from) return false; diff --git a/frontend/app/(main)/COMPANY_7/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/COMPANY_7/production/work-instruction/WorkStandardEditModal.tsx index da7e8fd5..96b96535 100644 --- a/frontend/app/(main)/COMPANY_7/production/work-instruction/WorkStandardEditModal.tsx +++ b/frontend/app/(main)/COMPANY_7/production/work-instruction/WorkStandardEditModal.tsx @@ -15,9 +15,11 @@ import { } from "lucide-react"; import { toast } from "sonner"; import { - getWIWorkStandard, copyWorkStandard, saveWIWorkStandard, resetWIWorkStandard, + getWIWorkStandard, saveWIWorkStandard, resetWIWorkStandard, WIWorkItem, WIWorkItemDetail, WIWorkStandardProcess, } from "@/lib/api/workInstruction"; +import { getBomMaterials, BomMaterial } from "@/lib/api/processInfo"; +import { apiClient } from "@/lib/api/client"; interface WorkStandardEditModalProps { open: boolean; @@ -60,12 +62,46 @@ const UNIT_OPTIONS = [ "L/min", "Hz", "dB", "ea", "g", "mg", "ml", "L", ]; +// PLC 데이터 옵션 (원본 DetailFormModal과 동일) +const PLC_DATA_OPTIONS = [ + { value: "PLC_TEMP_01", label: "온도 (PLC_TEMP_01)" }, + { value: "PLC_PRES_01", label: "압력 (PLC_PRES_01)" }, + { value: "PLC_RPM_01", label: "회전수 (PLC_RPM_01)" }, + { value: "PLC_TORQ_01", label: "토크 (PLC_TORQ_01)" }, + { value: "PLC_SPD_01", label: "속도 (PLC_SPD_01)" }, + { value: "PLC_CUR_01", label: "전류 (PLC_CUR_01)" }, + { value: "PLC_VOLT_01", label: "전압 (PLC_VOLT_01)" }, + { value: "PLC_VIB_01", label: "진동 (PLC_VIB_01)" }, + { value: "PLC_HUM_01", label: "습도 (PLC_HUM_01)" }, + { value: "PLC_FLOW_01", label: "유량 (PLC_FLOW_01)" }, +]; + +const PLC_PRODUCTION_OPTIONS: Record = { + work_qty: [ + { value: "PLC_CNT_01", label: "생산카운터 (PLC_CNT_01)" }, + { value: "PLC_CNT_02", label: "완료카운터 (PLC_CNT_02)" }, + { value: "PLC_QTY_01", label: "작업수량 (PLC_QTY_01)" }, + ], + defect_qty: [ + { value: "PLC_NG_01", label: "불량카운터 (PLC_NG_01)" }, + { value: "PLC_NG_02", label: "NG감지기 (PLC_NG_02)" }, + { value: "PLC_REJ_01", label: "리젝트수 (PLC_REJ_01)" }, + ], + good_qty: [ + { value: "PLC_OK_01", label: "양품카운터 (PLC_OK_01)" }, + { value: "PLC_OK_02", label: "합격카운터 (PLC_OK_02)" }, + { value: "PLC_GOOD_01", label: "양품수량 (PLC_GOOD_01)" }, + ], +}; + const getDetailTypeLabel = (type: string) => DETAIL_TYPES.find(d => d.value === type)?.label || type; +// 원본 WorkItemDetailList의 getContentSummary를 그대로 이식 const getContentSummary = (detail: WIWorkItemDetail): string => { const type = detail.detail_type; if (type === "inspection") { + if (detail.process_inspection_apply === "apply") return detail.content || "품목별 검사정보 (자동 연동)"; const parts = [detail.content]; if (detail.inspection_method) parts.push(`[${detail.inspection_method}]`); if (detail.base_value) { @@ -81,7 +117,9 @@ const getContentSummary = (detail: WIWorkItemDetail): string => { return `${detail.content} [${typeMap[detail.input_type] || detail.input_type}]`; } if (type === "lookup") return "품목 등록 문서 (자동 연동)"; - if (type === "equip_inspection") return detail.content || "설비점검"; + 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) { @@ -90,10 +128,1002 @@ const getContentSummary = (detail: WIWorkItemDetail): string => { return parts.join(" "); } if (type === "production_result") return "작업수량 / 불량수량 / 양품수량"; - if (type === "material_input") return "BOM 구성 자재 (자동 연동)"; + if (type === "material_input") return detail.content || "BOM 구성 자재 (자동 연동)"; return detail.content || "-"; }; +// ============================================================ +// 상세 항목 추가/수정 모달 — 원본 DetailFormModal을 내부에 이식 +// 원본: frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx +// ============================================================ +interface DetailFormModalInnerProps { + open: boolean; + onClose: () => void; + onSubmit: (data: Partial) => void; + editData?: WIWorkItemDetail | null; + mode: "add" | "edit"; + selectedItemCode?: string; + selectedProcessCode?: string; +} + +function DetailFormModalInner({ + open, + onClose, + onSubmit, + editData, + mode, + selectedItemCode, + selectedProcessCode, +}: DetailFormModalInnerProps) { + const [formData, setFormData] = useState>({}); + const [bomMaterials, setBomMaterials] = useState([]); + const [bomLoading, setBomLoading] = useState(false); + const [bomChecked, setBomChecked] = useState>(new Set()); + + // 품목검사정보 연동 + const [itemInspections, setItemInspections] = useState([]); + const [itemInspLoading, setItemInspLoading] = useState(false); + + // 품목 카테고리 (type 코드→라벨) + const [itemTypeCatMap, setItemTypeCatMap] = useState>({}); + + // 설비 점검항목 연동 + const [equipInspItems, setEquipInspItems] = useState([]); + const [equipInspLoading, setEquipInspLoading] = useState(false); + + const loadBomMaterials = useCallback(async () => { + if (!selectedItemCode) { + setBomMaterials([]); + return; + } + setBomLoading(true); + try { + const res = await getBomMaterials(selectedItemCode); + if (res.success && res.data) { + setBomMaterials(res.data); + } else { + setBomMaterials([]); + } + } catch { + setBomMaterials([]); + } finally { + setBomLoading(false); + } + }, [selectedItemCode]); + + // BOM 자재 로드 완료 후 체크 상태 초기화 + useEffect(() => { + if (!open || bomMaterials.length === 0) return; + if (mode === "edit" && editData?.selected_bom_items) { + const savedBom = editData.selected_bom_items; + const parsedBom = typeof savedBom === "string" ? JSON.parse(savedBom) : savedBom; + if (Array.isArray(parsedBom)) { + const matIds = new Set(bomMaterials.map((m) => m.id)); + const restored = new Set(); + const seenChildForLegacy = new Set(); + for (const saved of parsedBom) { + if (matIds.has(saved)) { + restored.add(saved); + } else { + const legacyKey = String(saved); + if (seenChildForLegacy.has(legacyKey)) continue; + const firstMat = bomMaterials.find((m) => m.child_item_id === legacyKey); + if (firstMat) { + restored.add(firstMat.id); + seenChildForLegacy.add(legacyKey); + } + } + } + setBomChecked(restored); + return; + } + } + setBomChecked(new Set()); + }, [open, bomMaterials, mode, editData]); + + useEffect(() => { + if (open) { + if (mode === "edit" && editData) { + setFormData({ ...editData }); + } else { + setFormData({ + detail_type: DETAIL_TYPES[0]?.value || "", + content: "", + is_required: "Y", + }); + } + } + }, [open, mode, editData]); + + useEffect(() => { + if (open && formData.detail_type === "material_input") { + loadBomMaterials(); + if (Object.keys(itemTypeCatMap).length === 0) { + apiClient.get("/table-categories/item_info/type/values").then(res => { + if (res.data?.success && res.data.data?.length) { + const map: Record = {}; + const flatten = (arr: any[]) => { for (const v of arr) { map[v.valueCode] = v.valueLabel; if (v.children?.length) flatten(v.children); } }; + flatten(res.data.data); + setItemTypeCatMap(map); + } + }).catch(() => {}); + } + } + }, [open, formData.detail_type, loadBomMaterials, itemTypeCatMap]); + + // 검사항목 적용 시 품목검사정보 로드 + useEffect(() => { + if (!open || formData.detail_type !== "inspection" || formData.process_inspection_apply !== "apply" || !selectedItemCode) { + setItemInspections([]); + return; + } + (async () => { + setItemInspLoading(true); + try { + const res = await apiClient.post("/table-management/tables/item_inspection_info/data", { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: selectedItemCode }] }, + autoFilter: true, + }); + setItemInspections(res.data?.data?.data || res.data?.data?.rows || []); + } catch { setItemInspections([]); } finally { setItemInspLoading(false); } + })(); + }, [open, formData.detail_type, formData.process_inspection_apply, selectedItemCode]); + + // 설비점검 적용 시 해당 공정의 설비 점검항목 로드 + useEffect(() => { + if (!open || formData.detail_type !== "equip_inspection" || formData.equip_inspection_apply !== "apply" || !selectedProcessCode) { + setEquipInspItems([]); + return; + } + (async () => { + setEquipInspLoading(true); + try { + // 1. 해당 공정의 설비 목록 조회 + const equipRes = await apiClient.post("/table-management/tables/process_equipment/data", { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [{ columnName: "process_code", operator: "equals", value: selectedProcessCode }] }, + autoFilter: true, + }); + const equipCodes = (equipRes.data?.data?.data || equipRes.data?.data?.rows || []).map((e: any) => e.equipment_code).filter(Boolean); + if (equipCodes.length === 0) { setEquipInspItems([]); return; } + // 2. 각 설비의 점검항목 조회 + const inspRes = await apiClient.post("/table-management/tables/equipment_inspection_item/data", { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "in", value: equipCodes }] }, + autoFilter: true, + }); + setEquipInspItems(inspRes.data?.data?.data || inspRes.data?.data?.rows || []); + } catch { setEquipInspItems([]); } finally { setEquipInspLoading(false); } + })(); + }, [open, formData.detail_type, formData.equip_inspection_apply, selectedProcessCode]); + + const updateField = (field: string, value: any) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + const handleSubmit = () => { + if (!formData.detail_type) return; + + const type = formData.detail_type; + + if (type === "checklist" && !formData.content?.trim()) return; + if (type === "inspection") { + if (!formData.process_inspection_apply) return; + if (formData.process_inspection_apply === "none" && !formData.content?.trim()) return; + } + if (type === "procedure" && !formData.content?.trim()) return; + if (type === "input" && !formData.content?.trim()) return; + if (type === "equip_inspection" && !formData.equip_inspection_apply) return; + if (type === "equip_condition" && !formData.content?.trim()) return; + + const submitData = { ...formData }; + + // 검사항목 적용 → 품목검사정보 각각 개별 등록 (다중행 자동생성) + if (type === "inspection" && submitData.process_inspection_apply === "apply") { + if (itemInspections.length > 0) { + for (const insp of itemInspections) { + onSubmit({ + ...submitData, + detail_type: "inspection", + content: `${insp.inspection_item_name || insp.inspection_standard || "-"} | ${insp.pass_criteria || ""}`.trim(), + is_required: submitData.is_required || "Y", + }); + } + onClose(); + return; + } + submitData.content = submitData.content || "품목별 검사정보 (자동 연동)"; + } + if (type === "lookup") { + submitData.content = submitData.content || "품목 등록 문서 (자동 연동)"; + } + // 설비점검 적용 → 점검항목 각각 개별 등록 (다중행 자동생성) + if (type === "equip_inspection" && submitData.equip_inspection_apply === "apply") { + if (equipInspItems.length > 0) { + for (const item of equipInspItems) { + const range = (item.lower_limit || item.upper_limit) ? `${item.lower_limit || ""} ~ ${item.upper_limit || ""}${item.unit ? ` ${item.unit}` : ""}` : ""; + onSubmit({ + ...submitData, + detail_type: "equip_inspection", + content: `${item.inspection_item || "-"}${range ? ` | ${range}` : ""}`.trim(), + is_required: submitData.is_required || "Y", + }); + } + onClose(); + return; + } + submitData.content = submitData.content || "설비 점검항목 (설비정보 연동)"; + } + if (type === "production_result") { + submitData.content = submitData.content || "작업수량 / 불량수량 / 양품수량"; + } + if (type === "material_input") { + // 선택된 BOM 자재를 각각 개별 상세 항목으로 등록 (다중행 자동생성) + const checkedMats = bomMaterials.filter(m => bomChecked.has(m.id)); + if (checkedMats.length > 0) { + const resolveType = (code: string) => itemTypeCatMap[code] || code || ""; + for (const mat of checkedMats) { + const typeLabel = resolveType(mat.child_item_type || ""); + const unitLabel = mat.detail_unit || mat.item_unit || ""; + onSubmit({ + ...submitData, + detail_type: "material_input", + content: `${mat.child_item_name || mat.child_item_id}${typeLabel ? ` ${typeLabel}` : ""} | ${mat.quantity || 1} ${unitLabel}`.trim(), + is_required: submitData.is_required || "Y", + bom_item_id: mat.child_item_id, + bom_item_name: mat.child_item_name || "", + bom_qty: String(mat.quantity || 1), + bom_unit: unitLabel, + }); + } + onClose(); + return; + } + submitData.content = submitData.content || "BOM 구성 자재 (자동 연동)"; + submitData.selected_bom_items = Array.from(bomChecked); + } + + onSubmit(submitData); + onClose(); + }; + + const currentType = formData.detail_type || ""; + + return ( + !v && onClose()}> + e.stopPropagation()}> + + + 상세 항목 {mode === "add" ? "추가" : "수정"} + + + 상세 항목의 유형을 선택하고 내용을 입력하세요 + + + +
+ {/* 유형 선택 */} +
+ + +
+ + {/* ============ 체크리스트 ============ */} + {currentType === "checklist" && ( +
+ + updateField("content", e.target.value)} + placeholder="예: 전원 상태 확인" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ )} + + {/* ============ 검사항목 ============ */} + {currentType === "inspection" && ( + <> + {/* 공정검사 적용 여부 */} +
+ +
+ + +
+
+ + {/* 적용 시: 품목검사정보에서 등록된 검사항목 표시 */} + {formData.process_inspection_apply === "apply" && ( +
+

+ 품목별 검사정보 (자동 연동) +

+ {itemInspLoading ? ( +
조회 중...
+ ) : itemInspections.length === 0 ? ( +

등록된 검사항목이 없습니다. 품목검사정보에서 먼저 등록해주세요.

+ ) : ( + + + + + + + + + + {itemInspections.map((insp, idx) => ( + + + + + + ))} + +
검사항목검사유형합격기준
{insp.inspection_item_name || insp.inspection_standard || "-"}{insp.inspection_type || "-"}{insp.pass_criteria || "-"}
+ )} +
+ )} + + {/* 미적용 시: 수동 입력 */} + {formData.process_inspection_apply === "none" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 외경 치수" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+
+ + updateField("inspection_method", e.target.value)} + placeholder="예: 마이크로미터" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + +
+
+ + {/* 기준값 ± 오차범위 + 자동수집 + PLC */} +
+
+
+ updateField("base_value", e.target.value)} + placeholder="기준값" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ ± +
+ updateField("tolerance", e.target.value)} + placeholder="오차범위" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ +
+ + +
+
+ + )} + + )} + + {/* ============ 작업절차 ============ */} + {currentType === "procedure" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 자재 투입" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + + updateField("duration_minutes", e.target.value ? Number(e.target.value) : undefined) + } + placeholder="예: 5" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + )} + + {/* ============ 직접입력 ============ */} + {currentType === "input" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 작업자 의견" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + +
+ + )} + + {/* ============ 문서참조 ============ */} + {currentType === "lookup" && ( + <> +
+ 📄 + + 해당 품목에 등록된 문서를 자동으로 불러옵니다. + +
+
+ +
+

+ 품목이 선택되지 않았습니다. +

+
+
+ + )} + + {/* ============ 설비점검 ============ */} + {currentType === "equip_inspection" && ( + <> +
+ 🏭 + + 공정에 지정된 설비를 자동 참조합니다. + +
+ +
+ +
+ + +
+
+ + {/* 적용 시: 설비 점검항목 표시 */} + {formData.equip_inspection_apply === "apply" && ( +
+

+ 설비 점검항목 (설비정보 연동) +

+ {equipInspLoading ? ( +
조회 중...
+ ) : equipInspItems.length === 0 ? ( +

해당 공정에 지정된 설비의 점검항목이 없습니다.

+ ) : ( + + + + + + + + + + + {equipInspItems.map((item, idx) => ( + + + + + + + ))} + +
설비코드점검항목점검방법기준범위
{item.equipment_code || "-"}{item.inspection_item || "-"}{item.inspection_method || "-"}{item.lower_limit || item.upper_limit ? `${item.lower_limit || ""} ~ ${item.upper_limit || ""}${item.unit ? ` ${item.unit}` : ""}` : "-"}
+ )} +
+ )} + + )} + + {/* ============ 설비조건 ============ */} + {currentType === "equip_condition" && ( + <> +
+ 🏭 + + 공정에 지정된 설비를 자동 참조합니다. + +
+ +
+ +
+ {/* 조건명 + 단위 */} +
+
+ updateField("content", e.target.value)} + placeholder="조건명 (예: 온도, 압력, RPM)" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ +
+
+ + {/* 기준값 ± 오차범위 */} +
+
+ updateField("condition_base_value", e.target.value)} + placeholder="기준값" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ ± +
+ updateField("condition_tolerance", e.target.value)} + placeholder="오차범위" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + {/* 자동수집 */} +
+ + +
+
+
+ + )} + + {/* ============ 실적등록 ============ */} + {currentType === "production_result" && ( +
+ +
+ {/* 작업수량 */} +
+
+ 📦 작업수량 + 생산된 전체 수량 +
+
+ + +
+
+ + {/* 불량수량 */} +
+
+ 🚫 불량수량 + 불량으로 판정된 수량 +
+
+ + +
+
+ + {/* 양품수량 */} +
+
+ ✅ 양품수량 + 작업수량 - 불량수량 +
+
+ + +
+
+
+
+ )} + + {/* ============ 자재투입 ============ */} + {currentType === "material_input" && ( +
+ +
+
+

+ 📦 BOM 구성 자재 (자동 연동) +

+ + {bomMaterials.length > 0 ? `${bomMaterials.length}건` : ""} + +
+ {bomMaterials.length > 0 && ( +
+ 0 + ? "indeterminate" + : false + } + onCheckedChange={(checked) => { + if (checked) { + setBomChecked(new Set(bomMaterials.map((m) => m.id))); + } else { + setBomChecked(new Set()); + } + }} + /> + + 전체 선택 ({bomChecked.size} / {bomMaterials.length}) + +
+ )} +
+ {bomLoading ? ( +
+ + BOM 데이터를 불러오는 중... +
+ ) : !selectedItemCode ? ( +
+ 품목을 먼저 선택하세요. +
+ ) : bomMaterials.length === 0 ? ( +
+ 해당 품목의 BOM 구성 자재가 없습니다. +
+ ) : ( + bomMaterials.map((mat) => { + const resolvedType = itemTypeCatMap[mat.child_item_type || ""] || mat.child_item_type || ""; + const typeColor = + resolvedType.includes("원자재") ? "#16a34a" + : resolvedType.includes("반제품") ? "#2563eb" + : resolvedType.includes("제품") ? "#9333ea" + : "#6b7280"; + return ( +
+ { + setBomChecked((prev) => { + const next = new Set(prev); + if (checked) next.add(mat.id); + else next.delete(mat.id); + return next; + }); + }} + /> +
+
+ {mat.child_item_type && ( + + {resolvedType} + + )} + + {mat.child_item_code || "-"} + + + {mat.child_item_name || "(이름 없음)"} + +
+
+ 소요량: {mat.quantity || "0"} {mat.detail_unit || mat.item_unit || ""} + {mat.process_type ? ` | 공정: ${mat.process_type}` : ""} +
+
+
+ ); + }) + )} +
+
+
+ )} + + {/* 필수 여부 (모든 유형 공통) */} + {currentType && ( +
+ + +
+ )} +
+ + + + + +
+
+ ); +} + +// ============================================================ +// 메인 모달 +// ============================================================ export function WorkStandardEditModal({ open, onClose, @@ -122,9 +1152,12 @@ export function WorkStandardEditModal({ const [detailModalOpen, setDetailModalOpen] = useState(false); const [detailModalMode, setDetailModalMode] = useState<"add" | "edit">("add"); const [detailModalPhase, setDetailModalPhase] = useState("PRE"); - const [detailFormData, setDetailFormData] = useState>({}); const [editingDetail, setEditingDetail] = useState(null); + // 드래그 정렬용 state (phase + workItem 조합으로 스코프 관리) + const [dragIdx, setDragIdx] = useState(null); + const [overIdx, setOverIdx] = useState(null); + // 데이터 로드 const loadData = useCallback(async () => { if (!workInstructionNo || !routingVersionId) return; @@ -163,21 +1196,16 @@ export function WorkStandardEditModal({ return map; }, [currentProcess]); - // 커스텀 복사 확인 후 수정 + // 커스텀 전환 — 로컬 마킹만 수행 (TASK:ERP-012 수정분 보존) + // 기존에는 copyWorkStandard API 호출 + loadData()로 state 전면 재로딩을 해서 + // closure로 잡은 workItem id / detail id가 모두 stale해져서 + // ① 삭제가 무효화되어 UI상 상세가 0개로 보이고 + // ② 추가 state 변경이 반영되지 않아 저장이 먹통이 됨. + // saveWIWorkStandard는 DELETE→INSERT 방식으로 전체 덮어쓰므로 사전 복사 불필요. const ensureCustom = useCallback(async () => { - if (isCustom) return true; - try { - const res = await copyWorkStandard(workInstructionNo, routingVersionId); - if (res.success) { - await loadData(); - setIsCustom(true); - return true; - } - } catch (err) { - toast.error("원본 복사에 실패했습니다"); - } - return false; - }, [isCustom, workInstructionNo, routingVersionId, loadData]); + if (!isCustom) setIsCustom(true); + return true; + }, [isCustom]); // 작업항목 추가 모달 열기 const openAddWorkItem = useCallback((phaseKey: string) => { @@ -204,7 +1232,6 @@ export function WorkStandardEditModal({ if (!ok || !currentProcess) return; if (editingWorkItem) { - // 수정 setProcesses(prev => { const next = [...prev]; const workItems = [...next[selectedProcessIdx].workItems]; @@ -216,7 +1243,6 @@ export function WorkStandardEditModal({ return next; }); } else { - // 추가 const phaseKey = addItemPhase === "IN" ? "IN" : addItemPhase; const phaseItems = currentProcess.workItems.filter( wi => wi.work_phase === phaseKey || (phaseKey === "IN" && wi.work_phase === "MAIN") @@ -271,11 +1297,6 @@ export function WorkStandardEditModal({ setDetailModalPhase(phaseKey); setDetailModalMode("add"); setEditingDetail(null); - setDetailFormData({ - detail_type: DETAIL_TYPES[0].value, - content: "", - is_required: "Y", - }); setDetailModalOpen(true); }, []); @@ -284,37 +1305,13 @@ export function WorkStandardEditModal({ setDetailModalPhase(phaseKey); setDetailModalMode("edit"); setEditingDetail(detail); - setDetailFormData({ ...detail }); setDetailModalOpen(true); }, []); - // 상세 추가/수정 처리 - const handleSaveDetail = useCallback(async () => { - const type = detailFormData.detail_type || ""; - if (!type) return; - - // 유효성 검사 (내용이 필요한 유형) - const needsContent = ["checklist", "procedure", "input", "equip_condition"]; - if (needsContent.includes(type) && !detailFormData.content?.trim()) { - toast.error("내용을 입력하세요"); - return; - } - if (type === "inspection" && !detailFormData.content?.trim()) { - toast.error("검사 항목명을 입력하세요"); - return; - } - + // 상세 추가 처리 — 다중행 자동생성 대응 (DetailFormModalInner의 onSubmit이 for-loop로 여러 번 호출) + const handleAddDetailRow = useCallback((data: Partial) => { const workItemId = selectedWorkItemIdByPhase[detailModalPhase]; if (!workItemId) return; - const ok = await ensureCustom(); - if (!ok) return; - - // content 자동 설정 (UI에서 직접 입력이 없는 유형들) - const submitData = { ...detailFormData }; - if (type === "lookup") submitData.content = submitData.content || "품목 등록 문서 (자동 연동)"; - if (type === "equip_inspection") submitData.content = submitData.content || "설비점검"; - if (type === "production_result") submitData.content = submitData.content || "작업수량 / 불량수량 / 양품수량"; - if (type === "material_input") submitData.content = submitData.content || "BOM 구성 자재 (자동 연동)"; setProcesses(prev => { const next = [...prev]; @@ -322,33 +1319,74 @@ export function WorkStandardEditModal({ const wiIdx = workItems.findIndex(wi => wi.id === workItemId); if (wiIdx < 0) return prev; - if (detailModalMode === "edit" && editingDetail) { - // 수정 - const details = (workItems[wiIdx].details || []).map(d => - d.id === editingDetail.id ? { ...d, ...submitData } : d - ); + const currentDetails = workItems[wiIdx].details || []; + const newDetail: WIWorkItemDetail = { + ...data, + id: `temp-detail-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + work_item_id: workItemId, + sort_order: currentDetails.length + 1, + }; + workItems[wiIdx] = { + ...workItems[wiIdx], + details: [...currentDetails, newDetail], + detail_count: currentDetails.length + 1, + }; + next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; + return next; + }); + setDirty(true); + }, [detailModalPhase, selectedWorkItemIdByPhase, selectedProcessIdx]); + + // 상세 수정 처리 + const handleUpdateDetailRow = useCallback((data: Partial) => { + const workItemId = selectedWorkItemIdByPhase[detailModalPhase]; + if (!workItemId || !editingDetail) return; + + setProcesses(prev => { + const next = [...prev]; + const workItems = [...next[selectedProcessIdx].workItems]; + const wiIdx = workItems.findIndex(wi => wi.id === workItemId); + if (wiIdx < 0) return prev; + + // 같은 유형의 apply 그룹 전체 일괄 업데이트 (필수여부 등) — 원본 WorkItemDetailList 로직과 동일 + const isApplyGroup = + (editingDetail.detail_type === "inspection" && editingDetail.process_inspection_apply === "apply") || + (editingDetail.detail_type === "equip_inspection" && editingDetail.equip_inspection_apply === "apply") || + editingDetail.detail_type === "material_input"; + + if (isApplyGroup && data.is_required !== undefined) { + const details = (workItems[wiIdx].details || []).map(d => { + const isSibling = + d.detail_type === editingDetail.detail_type && ( + d.detail_type === "inspection" ? d.process_inspection_apply === "apply" : + d.detail_type === "equip_inspection" ? d.equip_inspection_apply === "apply" : + d.detail_type === "material_input" + ); + return isSibling ? { ...d, is_required: data.is_required! } : d; + }); workItems[wiIdx] = { ...workItems[wiIdx], details }; } else { - // 추가 - const newDetail: WIWorkItemDetail = { - ...submitData, - id: `temp-detail-${Date.now()}`, - work_item_id: workItemId, - sort_order: (workItems[wiIdx].details?.length || 0) + 1, - }; - workItems[wiIdx] = { - ...workItems[wiIdx], - details: [...(workItems[wiIdx].details || []), newDetail], - detail_count: (workItems[wiIdx].detail_count || 0) + 1, - }; + const details = (workItems[wiIdx].details || []).map(d => + d.id === editingDetail.id ? { ...d, ...data } : d + ); + workItems[wiIdx] = { ...workItems[wiIdx], details }; } next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; return next; }); - - setDetailModalOpen(false); setDirty(true); - }, [detailFormData, detailModalPhase, detailModalMode, editingDetail, selectedWorkItemIdByPhase, ensureCustom, selectedProcessIdx]); + }, [detailModalPhase, selectedWorkItemIdByPhase, selectedProcessIdx, editingDetail]); + + // DetailFormModalInner onSubmit 분기 진입점 + const handleDetailSubmit = useCallback(async (data: Partial) => { + const ok = await ensureCustom(); + if (!ok) return; + if (detailModalMode === "add") { + handleAddDetailRow(data); + } else { + handleUpdateDetailRow(data); + } + }, [detailModalMode, handleAddDetailRow, handleUpdateDetailRow, ensureCustom]); // 상세 삭제 const handleDeleteDetail = useCallback(async (detailId: string, phaseKey: string) => { @@ -374,6 +1412,25 @@ export function WorkStandardEditModal({ setDirty(true); }, [selectedWorkItemIdByPhase, ensureCustom, selectedProcessIdx]); + // 드래그 정렬 — 상세 목록 순서 변경 + const handleReorderDetails = useCallback((phaseKey: string, reordered: WIWorkItemDetail[]) => { + const workItemId = selectedWorkItemIdByPhase[phaseKey]; + if (!workItemId) return; + setProcesses(prev => { + const next = [...prev]; + const workItems = [...next[selectedProcessIdx].workItems]; + const wiIdx = workItems.findIndex(wi => wi.id === workItemId); + if (wiIdx >= 0) { + // sort_order 재할당 + const withOrder = reordered.map((d, i) => ({ ...d, sort_order: i + 1 })); + workItems[wiIdx] = { ...workItems[wiIdx], details: withOrder }; + } + next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; + return next; + }); + setDirty(true); + }, [selectedWorkItemIdByPhase, selectedProcessIdx]); + // 저장 const handleSave = useCallback(async () => { if (!currentProcess) return; @@ -394,7 +1451,7 @@ export function WorkStandardEditModal({ } else { toast.error("저장에 실패했습니다"); } - } catch (err) { + } catch { toast.error("저장 중 오류가 발생했습니다"); } finally { setSaving(false); @@ -410,15 +1467,11 @@ export function WorkStandardEditModal({ toast.success("원본으로 초기화되었습니다"); await loadData(); } - } catch (err) { + } catch { toast.error("초기화에 실패했습니다"); } }, [workInstructionNo, loadData]); - const updateDetailField = (field: string, value: unknown) => { - setDetailFormData(prev => ({ ...prev, [field]: value })); - }; - return ( { if (!v) onClose(); }}> @@ -473,6 +1526,7 @@ export function WorkStandardEditModal({ const phaseItems = workItemsByPhase[phase.key] || []; const selectedWiId = selectedWorkItemIdByPhase[phase.key] || null; const selectedWi = phaseItems.find(wi => wi.id === selectedWiId) || null; + const selectedDetails = selectedWi?.details || []; return (
@@ -555,7 +1609,7 @@ export function WorkStandardEditModal({ )}
- {/* 우측: 상세 테이블 */} + {/* 우측: 상세 테이블 — 드래그 정렬 지원 */}
{!selectedWi ? (
@@ -568,7 +1622,7 @@ export function WorkStandardEditModal({
{selectedWi.title} - {selectedWi.details?.length || 0}개 + {selectedDetails.length}개
- {/* 상세 추가/수정 다이얼로그 (유형별 동적 필드) */} - { if (!v) setDetailModalOpen(false); }}> - e.stopPropagation()}> - - - 상세 항목 {detailModalMode === "add" ? "추가" : "수정"} - - - 상세 항목의 유형을 선택하고 내용을 입력하세요 - - - -
- {/* 유형 선택 */} -
- - -
- - {/* 체크리스트 */} - {detailFormData.detail_type === "checklist" && ( -
- - updateDetailField("content", e.target.value)} - placeholder="예: 전원 상태 확인" - className="mt-1 h-8 text-xs" - /> -
- )} - - {/* 검사항목 */} - {detailFormData.detail_type === "inspection" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="예: 외경 치수" - className="mt-1 h-8 text-xs" - /> -
-
-
- - updateDetailField("inspection_method", e.target.value)} - placeholder="예: 마이크로미터" - className="mt-1 h-8 text-xs" - /> -
-
- - -
-
-
-
-
- updateDetailField("base_value", e.target.value)} - placeholder="기준값" - className="h-8 text-xs" - /> -
- ± -
- updateDetailField("tolerance", e.target.value)} - placeholder="오차범위" - className="h-8 text-xs" - /> -
-
-
- - )} - - {/* 작업절차 */} - {detailFormData.detail_type === "procedure" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="예: 자재 투입" - className="mt-1 h-8 text-xs" - /> -
-
- - updateDetailField("duration_minutes", e.target.value ? Number(e.target.value) : undefined)} - placeholder="예: 5" - className="mt-1 h-8 text-xs" - /> -
- - )} - - {/* 직접입력 */} - {detailFormData.detail_type === "input" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="예: 작업자 의견" - className="mt-1 h-8 text-xs" - /> -
-
- - -
- - )} - - {/* 문서참조 */} - {detailFormData.detail_type === "lookup" && ( -
- - 해당 품목에 등록된 문서를 자동으로 불러옵니다. - -
- )} - - {/* 설비점검 */} - {detailFormData.detail_type === "equip_inspection" && ( -
- - updateDetailField("content", e.target.value)} - placeholder="예: 설비 가동 전 안전 점검" - className="mt-1 h-8 text-xs" - /> -
- )} - - {/* 설비조건 */} - {detailFormData.detail_type === "equip_condition" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="조건명 (예: 온도, 압력, RPM)" - className="mt-1 h-8 text-xs" - /> -
-
-
-
- -
-
-
-
- updateDetailField("condition_base_value", e.target.value)} - placeholder="기준값" - className="h-8 text-xs" - /> -
- ± -
- updateDetailField("condition_tolerance", e.target.value)} - placeholder="오차범위" - className="h-8 text-xs" - /> -
-
-
- - )} - - {/* 실적등록 */} - {detailFormData.detail_type === "production_result" && ( -
-

작업수량 / 불량수량 / 양품수량

-

실적 입력 항목이 자동으로 생성됩니다.

-
- )} - - {/* 자재투입 */} - {detailFormData.detail_type === "material_input" && ( -
-

BOM 구성 자재 (자동 연동)

-

품목에 등록된 BOM 구성 자재가 자동으로 적용됩니다.

-
- )} - - {/* 필수 여부 (모든 유형 공통) */} - {detailFormData.detail_type && ( -
- - -
- )} - - {/* 비고 (모든 유형 공통) */} - {detailFormData.detail_type && ( -
- - updateDetailField("remark", e.target.value)} - placeholder="비고 입력" - className="mt-1 h-8 text-xs" - /> -
- )} -
- - - - - -
-
+ {/* 상세 추가/수정 다이얼로그 — 원본 DetailFormModal 로직을 이식 */} + setDetailModalOpen(false)} + onSubmit={handleDetailSubmit} + editData={editingDetail} + mode={detailModalMode} + selectedItemCode={itemCode} + selectedProcessCode={currentProcess?.process_code} + /> ); diff --git a/frontend/app/(main)/COMPANY_7/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_7/purchase/order/page.tsx index 9ec4e88e..96265a22 100644 --- a/frontend/app/(main)/COMPANY_7/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_7/purchase/order/page.tsx @@ -153,7 +153,7 @@ export default function PurchaseOrderPage() { const [itemSearchResults, setItemSearchResults] = useState([]); const [itemSearchLoading, setItemSearchLoading] = useState(false); const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); - const [itemSearchDivision, setItemSearchDivision] = useState("all"); + const [itemSearchMode, setItemSearchMode] = useState<"all" | "supplier">("all"); const [itemPage, setItemPage] = useState(1); const [itemPageSize, setItemPageSize] = useState(20); const [itemTotalPages, setItemTotalPages] = useState(0); @@ -265,11 +265,6 @@ export default function PurchaseOrderPage() { } catch { /* skip */ } } setCategoryOptions(optMap); - const divs = optMap["item_division"] || []; - const purchaseDiv = divs.find((o) => o.label === "구매관리") - || divs.find((o) => o.label === "원자재") - || divs.find((o) => o.label === "부자재"); - if (purchaseDiv) setItemSearchDivision(purchaseDiv.code); }; loadCategories(); }, []); @@ -549,17 +544,20 @@ export default function PurchaseOrderPage() { if (itemSearchKeyword) { filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); } - // 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응) - if (itemSearchDivision !== "all") { - const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || ""; - const divValues = [itemSearchDivision]; - if (divLabel) divValues.push(divLabel); - filters.push({ columnName: "division", operator: "in", value: divValues }); + // 구매관리 품목만 (코드 + 라벨 양쪽 대응) — 두 모드 공통 고정 필터 + const purchaseDiv = categoryOptions["item_division"]?.find((o) => o.label === "구매관리"); + if (purchaseDiv) { + filters.push({ columnName: "division", operator: "in", value: [purchaseDiv.code, purchaseDiv.label] }); } - // 공급업체 선택 시 supplier_item_mapping으로 매핑 id 정규화 → 서버 필터 적용 + // 거래처별 품목 모드일 때만 supplier_item_mapping 매핑 필터 적용 const supplierCode = masterForm.supplier_code; - if (supplierCode) { + if (itemSearchMode === "supplier") { + if (!supplierCode) { + setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1); + setItemSearchLoading(false); + return; + } try { const mappingRes = await apiClient.post(`/table-management/tables/supplier_item_mapping/data`, { page: 1, size: 0, @@ -1231,13 +1229,11 @@ export default function PurchaseOrderPage() { onKeyDown={(e) => e.key === "Enter" && triggerNewSearch()} className="h-9 flex-1" /> - setItemSearchMode(v as "all" | "supplier")}> + - 전체 - {(categoryOptions["item_division"] || []).map((o) => ( - {o.label} - ))} + 전체 구매품목 + 거래처별 품목 + + + + + ); +} + +// ============================================================ +// 메인 모달 +// ============================================================ export function WorkStandardEditModal({ open, onClose, @@ -122,9 +1152,12 @@ export function WorkStandardEditModal({ const [detailModalOpen, setDetailModalOpen] = useState(false); const [detailModalMode, setDetailModalMode] = useState<"add" | "edit">("add"); const [detailModalPhase, setDetailModalPhase] = useState("PRE"); - const [detailFormData, setDetailFormData] = useState>({}); const [editingDetail, setEditingDetail] = useState(null); + // 드래그 정렬용 state (phase + workItem 조합으로 스코프 관리) + const [dragIdx, setDragIdx] = useState(null); + const [overIdx, setOverIdx] = useState(null); + // 데이터 로드 const loadData = useCallback(async () => { if (!workInstructionNo || !routingVersionId) return; @@ -163,21 +1196,16 @@ export function WorkStandardEditModal({ return map; }, [currentProcess]); - // 커스텀 복사 확인 후 수정 + // 커스텀 전환 — 로컬 마킹만 수행 (TASK:ERP-012 수정분 보존) + // 기존에는 copyWorkStandard API 호출 + loadData()로 state 전면 재로딩을 해서 + // closure로 잡은 workItem id / detail id가 모두 stale해져서 + // ① 삭제가 무효화되어 UI상 상세가 0개로 보이고 + // ② 추가 state 변경이 반영되지 않아 저장이 먹통이 됨. + // saveWIWorkStandard는 DELETE→INSERT 방식으로 전체 덮어쓰므로 사전 복사 불필요. const ensureCustom = useCallback(async () => { - if (isCustom) return true; - try { - const res = await copyWorkStandard(workInstructionNo, routingVersionId); - if (res.success) { - await loadData(); - setIsCustom(true); - return true; - } - } catch (err) { - toast.error("원본 복사에 실패했습니다"); - } - return false; - }, [isCustom, workInstructionNo, routingVersionId, loadData]); + if (!isCustom) setIsCustom(true); + return true; + }, [isCustom]); // 작업항목 추가 모달 열기 const openAddWorkItem = useCallback((phaseKey: string) => { @@ -204,7 +1232,6 @@ export function WorkStandardEditModal({ if (!ok || !currentProcess) return; if (editingWorkItem) { - // 수정 setProcesses(prev => { const next = [...prev]; const workItems = [...next[selectedProcessIdx].workItems]; @@ -216,7 +1243,6 @@ export function WorkStandardEditModal({ return next; }); } else { - // 추가 const phaseKey = addItemPhase === "IN" ? "IN" : addItemPhase; const phaseItems = currentProcess.workItems.filter( wi => wi.work_phase === phaseKey || (phaseKey === "IN" && wi.work_phase === "MAIN") @@ -271,11 +1297,6 @@ export function WorkStandardEditModal({ setDetailModalPhase(phaseKey); setDetailModalMode("add"); setEditingDetail(null); - setDetailFormData({ - detail_type: DETAIL_TYPES[0].value, - content: "", - is_required: "Y", - }); setDetailModalOpen(true); }, []); @@ -284,37 +1305,13 @@ export function WorkStandardEditModal({ setDetailModalPhase(phaseKey); setDetailModalMode("edit"); setEditingDetail(detail); - setDetailFormData({ ...detail }); setDetailModalOpen(true); }, []); - // 상세 추가/수정 처리 - const handleSaveDetail = useCallback(async () => { - const type = detailFormData.detail_type || ""; - if (!type) return; - - // 유효성 검사 (내용이 필요한 유형) - const needsContent = ["checklist", "procedure", "input", "equip_condition"]; - if (needsContent.includes(type) && !detailFormData.content?.trim()) { - toast.error("내용을 입력하세요"); - return; - } - if (type === "inspection" && !detailFormData.content?.trim()) { - toast.error("검사 항목명을 입력하세요"); - return; - } - + // 상세 추가 처리 — 다중행 자동생성 대응 (DetailFormModalInner의 onSubmit이 for-loop로 여러 번 호출) + const handleAddDetailRow = useCallback((data: Partial) => { const workItemId = selectedWorkItemIdByPhase[detailModalPhase]; if (!workItemId) return; - const ok = await ensureCustom(); - if (!ok) return; - - // content 자동 설정 (UI에서 직접 입력이 없는 유형들) - const submitData = { ...detailFormData }; - if (type === "lookup") submitData.content = submitData.content || "품목 등록 문서 (자동 연동)"; - if (type === "equip_inspection") submitData.content = submitData.content || "설비점검"; - if (type === "production_result") submitData.content = submitData.content || "작업수량 / 불량수량 / 양품수량"; - if (type === "material_input") submitData.content = submitData.content || "BOM 구성 자재 (자동 연동)"; setProcesses(prev => { const next = [...prev]; @@ -322,33 +1319,74 @@ export function WorkStandardEditModal({ const wiIdx = workItems.findIndex(wi => wi.id === workItemId); if (wiIdx < 0) return prev; - if (detailModalMode === "edit" && editingDetail) { - // 수정 - const details = (workItems[wiIdx].details || []).map(d => - d.id === editingDetail.id ? { ...d, ...submitData } : d - ); + const currentDetails = workItems[wiIdx].details || []; + const newDetail: WIWorkItemDetail = { + ...data, + id: `temp-detail-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + work_item_id: workItemId, + sort_order: currentDetails.length + 1, + }; + workItems[wiIdx] = { + ...workItems[wiIdx], + details: [...currentDetails, newDetail], + detail_count: currentDetails.length + 1, + }; + next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; + return next; + }); + setDirty(true); + }, [detailModalPhase, selectedWorkItemIdByPhase, selectedProcessIdx]); + + // 상세 수정 처리 + const handleUpdateDetailRow = useCallback((data: Partial) => { + const workItemId = selectedWorkItemIdByPhase[detailModalPhase]; + if (!workItemId || !editingDetail) return; + + setProcesses(prev => { + const next = [...prev]; + const workItems = [...next[selectedProcessIdx].workItems]; + const wiIdx = workItems.findIndex(wi => wi.id === workItemId); + if (wiIdx < 0) return prev; + + // 같은 유형의 apply 그룹 전체 일괄 업데이트 (필수여부 등) — 원본 WorkItemDetailList 로직과 동일 + const isApplyGroup = + (editingDetail.detail_type === "inspection" && editingDetail.process_inspection_apply === "apply") || + (editingDetail.detail_type === "equip_inspection" && editingDetail.equip_inspection_apply === "apply") || + editingDetail.detail_type === "material_input"; + + if (isApplyGroup && data.is_required !== undefined) { + const details = (workItems[wiIdx].details || []).map(d => { + const isSibling = + d.detail_type === editingDetail.detail_type && ( + d.detail_type === "inspection" ? d.process_inspection_apply === "apply" : + d.detail_type === "equip_inspection" ? d.equip_inspection_apply === "apply" : + d.detail_type === "material_input" + ); + return isSibling ? { ...d, is_required: data.is_required! } : d; + }); workItems[wiIdx] = { ...workItems[wiIdx], details }; } else { - // 추가 - const newDetail: WIWorkItemDetail = { - ...submitData, - id: `temp-detail-${Date.now()}`, - work_item_id: workItemId, - sort_order: (workItems[wiIdx].details?.length || 0) + 1, - }; - workItems[wiIdx] = { - ...workItems[wiIdx], - details: [...(workItems[wiIdx].details || []), newDetail], - detail_count: (workItems[wiIdx].detail_count || 0) + 1, - }; + const details = (workItems[wiIdx].details || []).map(d => + d.id === editingDetail.id ? { ...d, ...data } : d + ); + workItems[wiIdx] = { ...workItems[wiIdx], details }; } next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; return next; }); - - setDetailModalOpen(false); setDirty(true); - }, [detailFormData, detailModalPhase, detailModalMode, editingDetail, selectedWorkItemIdByPhase, ensureCustom, selectedProcessIdx]); + }, [detailModalPhase, selectedWorkItemIdByPhase, selectedProcessIdx, editingDetail]); + + // DetailFormModalInner onSubmit 분기 진입점 + const handleDetailSubmit = useCallback(async (data: Partial) => { + const ok = await ensureCustom(); + if (!ok) return; + if (detailModalMode === "add") { + handleAddDetailRow(data); + } else { + handleUpdateDetailRow(data); + } + }, [detailModalMode, handleAddDetailRow, handleUpdateDetailRow, ensureCustom]); // 상세 삭제 const handleDeleteDetail = useCallback(async (detailId: string, phaseKey: string) => { @@ -374,6 +1412,25 @@ export function WorkStandardEditModal({ setDirty(true); }, [selectedWorkItemIdByPhase, ensureCustom, selectedProcessIdx]); + // 드래그 정렬 — 상세 목록 순서 변경 + const handleReorderDetails = useCallback((phaseKey: string, reordered: WIWorkItemDetail[]) => { + const workItemId = selectedWorkItemIdByPhase[phaseKey]; + if (!workItemId) return; + setProcesses(prev => { + const next = [...prev]; + const workItems = [...next[selectedProcessIdx].workItems]; + const wiIdx = workItems.findIndex(wi => wi.id === workItemId); + if (wiIdx >= 0) { + // sort_order 재할당 + const withOrder = reordered.map((d, i) => ({ ...d, sort_order: i + 1 })); + workItems[wiIdx] = { ...workItems[wiIdx], details: withOrder }; + } + next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; + return next; + }); + setDirty(true); + }, [selectedWorkItemIdByPhase, selectedProcessIdx]); + // 저장 const handleSave = useCallback(async () => { if (!currentProcess) return; @@ -394,7 +1451,7 @@ export function WorkStandardEditModal({ } else { toast.error("저장에 실패했습니다"); } - } catch (err) { + } catch { toast.error("저장 중 오류가 발생했습니다"); } finally { setSaving(false); @@ -410,15 +1467,11 @@ export function WorkStandardEditModal({ toast.success("원본으로 초기화되었습니다"); await loadData(); } - } catch (err) { + } catch { toast.error("초기화에 실패했습니다"); } }, [workInstructionNo, loadData]); - const updateDetailField = (field: string, value: unknown) => { - setDetailFormData(prev => ({ ...prev, [field]: value })); - }; - return ( { if (!v) onClose(); }}> @@ -473,6 +1526,7 @@ export function WorkStandardEditModal({ const phaseItems = workItemsByPhase[phase.key] || []; const selectedWiId = selectedWorkItemIdByPhase[phase.key] || null; const selectedWi = phaseItems.find(wi => wi.id === selectedWiId) || null; + const selectedDetails = selectedWi?.details || []; return (
@@ -555,7 +1609,7 @@ export function WorkStandardEditModal({ )}
- {/* 우측: 상세 테이블 */} + {/* 우측: 상세 테이블 — 드래그 정렬 지원 */}
{!selectedWi ? (
@@ -568,7 +1622,7 @@ export function WorkStandardEditModal({
{selectedWi.title} - {selectedWi.details?.length || 0}개 + {selectedDetails.length}개
- {/* 상세 추가/수정 다이얼로그 (유형별 동적 필드) */} - { if (!v) setDetailModalOpen(false); }}> - e.stopPropagation()}> - - - 상세 항목 {detailModalMode === "add" ? "추가" : "수정"} - - - 상세 항목의 유형을 선택하고 내용을 입력하세요 - - - -
- {/* 유형 선택 */} -
- - -
- - {/* 체크리스트 */} - {detailFormData.detail_type === "checklist" && ( -
- - updateDetailField("content", e.target.value)} - placeholder="예: 전원 상태 확인" - className="mt-1 h-8 text-xs" - /> -
- )} - - {/* 검사항목 */} - {detailFormData.detail_type === "inspection" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="예: 외경 치수" - className="mt-1 h-8 text-xs" - /> -
-
-
- - updateDetailField("inspection_method", e.target.value)} - placeholder="예: 마이크로미터" - className="mt-1 h-8 text-xs" - /> -
-
- - -
-
-
-
-
- updateDetailField("base_value", e.target.value)} - placeholder="기준값" - className="h-8 text-xs" - /> -
- ± -
- updateDetailField("tolerance", e.target.value)} - placeholder="오차범위" - className="h-8 text-xs" - /> -
-
-
- - )} - - {/* 작업절차 */} - {detailFormData.detail_type === "procedure" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="예: 자재 투입" - className="mt-1 h-8 text-xs" - /> -
-
- - updateDetailField("duration_minutes", e.target.value ? Number(e.target.value) : undefined)} - placeholder="예: 5" - className="mt-1 h-8 text-xs" - /> -
- - )} - - {/* 직접입력 */} - {detailFormData.detail_type === "input" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="예: 작업자 의견" - className="mt-1 h-8 text-xs" - /> -
-
- - -
- - )} - - {/* 문서참조 */} - {detailFormData.detail_type === "lookup" && ( -
- - 해당 품목에 등록된 문서를 자동으로 불러옵니다. - -
- )} - - {/* 설비점검 */} - {detailFormData.detail_type === "equip_inspection" && ( -
- - updateDetailField("content", e.target.value)} - placeholder="예: 설비 가동 전 안전 점검" - className="mt-1 h-8 text-xs" - /> -
- )} - - {/* 설비조건 */} - {detailFormData.detail_type === "equip_condition" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="조건명 (예: 온도, 압력, RPM)" - className="mt-1 h-8 text-xs" - /> -
-
-
-
- -
-
-
-
- updateDetailField("condition_base_value", e.target.value)} - placeholder="기준값" - className="h-8 text-xs" - /> -
- ± -
- updateDetailField("condition_tolerance", e.target.value)} - placeholder="오차범위" - className="h-8 text-xs" - /> -
-
-
- - )} - - {/* 실적등록 */} - {detailFormData.detail_type === "production_result" && ( -
-

작업수량 / 불량수량 / 양품수량

-

실적 입력 항목이 자동으로 생성됩니다.

-
- )} - - {/* 자재투입 */} - {detailFormData.detail_type === "material_input" && ( -
-

BOM 구성 자재 (자동 연동)

-

품목에 등록된 BOM 구성 자재가 자동으로 적용됩니다.

-
- )} - - {/* 필수 여부 (모든 유형 공통) */} - {detailFormData.detail_type && ( -
- - -
- )} - - {/* 비고 (모든 유형 공통) */} - {detailFormData.detail_type && ( -
- - updateDetailField("remark", e.target.value)} - placeholder="비고 입력" - className="mt-1 h-8 text-xs" - /> -
- )} -
- - - - - -
-
+ {/* 상세 추가/수정 다이얼로그 — 원본 DetailFormModal 로직을 이식 */} + setDetailModalOpen(false)} + onSubmit={handleDetailSubmit} + editData={editingDetail} + mode={detailModalMode} + selectedItemCode={itemCode} + selectedProcessCode={currentProcess?.process_code} + /> ); diff --git a/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx index 2c71ed02..74fb5981 100644 --- a/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_8/quality/item-inspection/page.tsx @@ -13,6 +13,7 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen import { Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy, + ChevronUp, ChevronDown, ChevronsUpDown, } from "lucide-react"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { cn } from "@/lib/utils"; @@ -472,6 +473,34 @@ export default function ItemInspectionInfoPage() { return Object.values(map); }, [data]); + // 좌측 품목 목록 정렬 (컬럼 헤더 클릭 → asc → desc → 해제 순환) + const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); + const handleSort = (key: string) => { + setSortConfig(prev => { + if (!prev || prev.key !== key) return { key, direction: "asc" }; + if (prev.direction === "asc") return { key, direction: "desc" }; + return null; + }); + }; + const sortedGroupedData = useMemo(() => { + if (!sortConfig) return groupedData; + const { key, direction } = sortConfig; + const mul = direction === "asc" ? 1 : -1; + const getVal = (g: any): string => { + if (key === "inspection_type") { + const code = g.types[0] || ""; + return inspTypeCatOptions.find(o => o.code === code)?.label || code; + } + if (key === "is_active") { + return (g.is_active === "사용" || g.is_active === "true") ? "사용" : "미사용"; + } + return String(g[key] ?? ""); + }; + return [...groupedData].sort((a, b) => { + return getVal(a).localeCompare(getVal(b), "ko", { numeric: true }) * mul; + }); + }, [groupedData, sortConfig, inspTypeCatOptions]); + // 선택된 품목의 그룹 데이터 const selectedGroup = useMemo(() => { if (!selectedItemCode) return null; @@ -989,14 +1018,28 @@ export default function ItemInspectionInfoPage() { {ts.visibleColumns.map((col) => ( - - {col.label} + handleSort(col.key)} + > +
+ {col.label} + {sortConfig?.key === col.key ? ( + sortConfig.direction === "asc" + ? + : + ) : ( + + )} +
))}
- {groupedData.map((group) => ( + {sortedGroupedData.map((group) => ( a.toLowerCase()); + if (!allowed.includes(val.toLowerCase())) return false; } else if (f.operator === "between") { const [from, to] = f.value.split("|"); if (from && val < from) return false; diff --git a/frontend/app/(main)/COMPANY_9/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/COMPANY_9/production/work-instruction/WorkStandardEditModal.tsx index da7e8fd5..96b96535 100644 --- a/frontend/app/(main)/COMPANY_9/production/work-instruction/WorkStandardEditModal.tsx +++ b/frontend/app/(main)/COMPANY_9/production/work-instruction/WorkStandardEditModal.tsx @@ -15,9 +15,11 @@ import { } from "lucide-react"; import { toast } from "sonner"; import { - getWIWorkStandard, copyWorkStandard, saveWIWorkStandard, resetWIWorkStandard, + getWIWorkStandard, saveWIWorkStandard, resetWIWorkStandard, WIWorkItem, WIWorkItemDetail, WIWorkStandardProcess, } from "@/lib/api/workInstruction"; +import { getBomMaterials, BomMaterial } from "@/lib/api/processInfo"; +import { apiClient } from "@/lib/api/client"; interface WorkStandardEditModalProps { open: boolean; @@ -60,12 +62,46 @@ const UNIT_OPTIONS = [ "L/min", "Hz", "dB", "ea", "g", "mg", "ml", "L", ]; +// PLC 데이터 옵션 (원본 DetailFormModal과 동일) +const PLC_DATA_OPTIONS = [ + { value: "PLC_TEMP_01", label: "온도 (PLC_TEMP_01)" }, + { value: "PLC_PRES_01", label: "압력 (PLC_PRES_01)" }, + { value: "PLC_RPM_01", label: "회전수 (PLC_RPM_01)" }, + { value: "PLC_TORQ_01", label: "토크 (PLC_TORQ_01)" }, + { value: "PLC_SPD_01", label: "속도 (PLC_SPD_01)" }, + { value: "PLC_CUR_01", label: "전류 (PLC_CUR_01)" }, + { value: "PLC_VOLT_01", label: "전압 (PLC_VOLT_01)" }, + { value: "PLC_VIB_01", label: "진동 (PLC_VIB_01)" }, + { value: "PLC_HUM_01", label: "습도 (PLC_HUM_01)" }, + { value: "PLC_FLOW_01", label: "유량 (PLC_FLOW_01)" }, +]; + +const PLC_PRODUCTION_OPTIONS: Record = { + work_qty: [ + { value: "PLC_CNT_01", label: "생산카운터 (PLC_CNT_01)" }, + { value: "PLC_CNT_02", label: "완료카운터 (PLC_CNT_02)" }, + { value: "PLC_QTY_01", label: "작업수량 (PLC_QTY_01)" }, + ], + defect_qty: [ + { value: "PLC_NG_01", label: "불량카운터 (PLC_NG_01)" }, + { value: "PLC_NG_02", label: "NG감지기 (PLC_NG_02)" }, + { value: "PLC_REJ_01", label: "리젝트수 (PLC_REJ_01)" }, + ], + good_qty: [ + { value: "PLC_OK_01", label: "양품카운터 (PLC_OK_01)" }, + { value: "PLC_OK_02", label: "합격카운터 (PLC_OK_02)" }, + { value: "PLC_GOOD_01", label: "양품수량 (PLC_GOOD_01)" }, + ], +}; + const getDetailTypeLabel = (type: string) => DETAIL_TYPES.find(d => d.value === type)?.label || type; +// 원본 WorkItemDetailList의 getContentSummary를 그대로 이식 const getContentSummary = (detail: WIWorkItemDetail): string => { const type = detail.detail_type; if (type === "inspection") { + if (detail.process_inspection_apply === "apply") return detail.content || "품목별 검사정보 (자동 연동)"; const parts = [detail.content]; if (detail.inspection_method) parts.push(`[${detail.inspection_method}]`); if (detail.base_value) { @@ -81,7 +117,9 @@ const getContentSummary = (detail: WIWorkItemDetail): string => { return `${detail.content} [${typeMap[detail.input_type] || detail.input_type}]`; } if (type === "lookup") return "품목 등록 문서 (자동 연동)"; - if (type === "equip_inspection") return detail.content || "설비점검"; + 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) { @@ -90,10 +128,1002 @@ const getContentSummary = (detail: WIWorkItemDetail): string => { return parts.join(" "); } if (type === "production_result") return "작업수량 / 불량수량 / 양품수량"; - if (type === "material_input") return "BOM 구성 자재 (자동 연동)"; + if (type === "material_input") return detail.content || "BOM 구성 자재 (자동 연동)"; return detail.content || "-"; }; +// ============================================================ +// 상세 항목 추가/수정 모달 — 원본 DetailFormModal을 내부에 이식 +// 원본: frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx +// ============================================================ +interface DetailFormModalInnerProps { + open: boolean; + onClose: () => void; + onSubmit: (data: Partial) => void; + editData?: WIWorkItemDetail | null; + mode: "add" | "edit"; + selectedItemCode?: string; + selectedProcessCode?: string; +} + +function DetailFormModalInner({ + open, + onClose, + onSubmit, + editData, + mode, + selectedItemCode, + selectedProcessCode, +}: DetailFormModalInnerProps) { + const [formData, setFormData] = useState>({}); + const [bomMaterials, setBomMaterials] = useState([]); + const [bomLoading, setBomLoading] = useState(false); + const [bomChecked, setBomChecked] = useState>(new Set()); + + // 품목검사정보 연동 + const [itemInspections, setItemInspections] = useState([]); + const [itemInspLoading, setItemInspLoading] = useState(false); + + // 품목 카테고리 (type 코드→라벨) + const [itemTypeCatMap, setItemTypeCatMap] = useState>({}); + + // 설비 점검항목 연동 + const [equipInspItems, setEquipInspItems] = useState([]); + const [equipInspLoading, setEquipInspLoading] = useState(false); + + const loadBomMaterials = useCallback(async () => { + if (!selectedItemCode) { + setBomMaterials([]); + return; + } + setBomLoading(true); + try { + const res = await getBomMaterials(selectedItemCode); + if (res.success && res.data) { + setBomMaterials(res.data); + } else { + setBomMaterials([]); + } + } catch { + setBomMaterials([]); + } finally { + setBomLoading(false); + } + }, [selectedItemCode]); + + // BOM 자재 로드 완료 후 체크 상태 초기화 + useEffect(() => { + if (!open || bomMaterials.length === 0) return; + if (mode === "edit" && editData?.selected_bom_items) { + const savedBom = editData.selected_bom_items; + const parsedBom = typeof savedBom === "string" ? JSON.parse(savedBom) : savedBom; + if (Array.isArray(parsedBom)) { + const matIds = new Set(bomMaterials.map((m) => m.id)); + const restored = new Set(); + const seenChildForLegacy = new Set(); + for (const saved of parsedBom) { + if (matIds.has(saved)) { + restored.add(saved); + } else { + const legacyKey = String(saved); + if (seenChildForLegacy.has(legacyKey)) continue; + const firstMat = bomMaterials.find((m) => m.child_item_id === legacyKey); + if (firstMat) { + restored.add(firstMat.id); + seenChildForLegacy.add(legacyKey); + } + } + } + setBomChecked(restored); + return; + } + } + setBomChecked(new Set()); + }, [open, bomMaterials, mode, editData]); + + useEffect(() => { + if (open) { + if (mode === "edit" && editData) { + setFormData({ ...editData }); + } else { + setFormData({ + detail_type: DETAIL_TYPES[0]?.value || "", + content: "", + is_required: "Y", + }); + } + } + }, [open, mode, editData]); + + useEffect(() => { + if (open && formData.detail_type === "material_input") { + loadBomMaterials(); + if (Object.keys(itemTypeCatMap).length === 0) { + apiClient.get("/table-categories/item_info/type/values").then(res => { + if (res.data?.success && res.data.data?.length) { + const map: Record = {}; + const flatten = (arr: any[]) => { for (const v of arr) { map[v.valueCode] = v.valueLabel; if (v.children?.length) flatten(v.children); } }; + flatten(res.data.data); + setItemTypeCatMap(map); + } + }).catch(() => {}); + } + } + }, [open, formData.detail_type, loadBomMaterials, itemTypeCatMap]); + + // 검사항목 적용 시 품목검사정보 로드 + useEffect(() => { + if (!open || formData.detail_type !== "inspection" || formData.process_inspection_apply !== "apply" || !selectedItemCode) { + setItemInspections([]); + return; + } + (async () => { + setItemInspLoading(true); + try { + const res = await apiClient.post("/table-management/tables/item_inspection_info/data", { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: selectedItemCode }] }, + autoFilter: true, + }); + setItemInspections(res.data?.data?.data || res.data?.data?.rows || []); + } catch { setItemInspections([]); } finally { setItemInspLoading(false); } + })(); + }, [open, formData.detail_type, formData.process_inspection_apply, selectedItemCode]); + + // 설비점검 적용 시 해당 공정의 설비 점검항목 로드 + useEffect(() => { + if (!open || formData.detail_type !== "equip_inspection" || formData.equip_inspection_apply !== "apply" || !selectedProcessCode) { + setEquipInspItems([]); + return; + } + (async () => { + setEquipInspLoading(true); + try { + // 1. 해당 공정의 설비 목록 조회 + const equipRes = await apiClient.post("/table-management/tables/process_equipment/data", { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [{ columnName: "process_code", operator: "equals", value: selectedProcessCode }] }, + autoFilter: true, + }); + const equipCodes = (equipRes.data?.data?.data || equipRes.data?.data?.rows || []).map((e: any) => e.equipment_code).filter(Boolean); + if (equipCodes.length === 0) { setEquipInspItems([]); return; } + // 2. 각 설비의 점검항목 조회 + const inspRes = await apiClient.post("/table-management/tables/equipment_inspection_item/data", { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "in", value: equipCodes }] }, + autoFilter: true, + }); + setEquipInspItems(inspRes.data?.data?.data || inspRes.data?.data?.rows || []); + } catch { setEquipInspItems([]); } finally { setEquipInspLoading(false); } + })(); + }, [open, formData.detail_type, formData.equip_inspection_apply, selectedProcessCode]); + + const updateField = (field: string, value: any) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + const handleSubmit = () => { + if (!formData.detail_type) return; + + const type = formData.detail_type; + + if (type === "checklist" && !formData.content?.trim()) return; + if (type === "inspection") { + if (!formData.process_inspection_apply) return; + if (formData.process_inspection_apply === "none" && !formData.content?.trim()) return; + } + if (type === "procedure" && !formData.content?.trim()) return; + if (type === "input" && !formData.content?.trim()) return; + if (type === "equip_inspection" && !formData.equip_inspection_apply) return; + if (type === "equip_condition" && !formData.content?.trim()) return; + + const submitData = { ...formData }; + + // 검사항목 적용 → 품목검사정보 각각 개별 등록 (다중행 자동생성) + if (type === "inspection" && submitData.process_inspection_apply === "apply") { + if (itemInspections.length > 0) { + for (const insp of itemInspections) { + onSubmit({ + ...submitData, + detail_type: "inspection", + content: `${insp.inspection_item_name || insp.inspection_standard || "-"} | ${insp.pass_criteria || ""}`.trim(), + is_required: submitData.is_required || "Y", + }); + } + onClose(); + return; + } + submitData.content = submitData.content || "품목별 검사정보 (자동 연동)"; + } + if (type === "lookup") { + submitData.content = submitData.content || "품목 등록 문서 (자동 연동)"; + } + // 설비점검 적용 → 점검항목 각각 개별 등록 (다중행 자동생성) + if (type === "equip_inspection" && submitData.equip_inspection_apply === "apply") { + if (equipInspItems.length > 0) { + for (const item of equipInspItems) { + const range = (item.lower_limit || item.upper_limit) ? `${item.lower_limit || ""} ~ ${item.upper_limit || ""}${item.unit ? ` ${item.unit}` : ""}` : ""; + onSubmit({ + ...submitData, + detail_type: "equip_inspection", + content: `${item.inspection_item || "-"}${range ? ` | ${range}` : ""}`.trim(), + is_required: submitData.is_required || "Y", + }); + } + onClose(); + return; + } + submitData.content = submitData.content || "설비 점검항목 (설비정보 연동)"; + } + if (type === "production_result") { + submitData.content = submitData.content || "작업수량 / 불량수량 / 양품수량"; + } + if (type === "material_input") { + // 선택된 BOM 자재를 각각 개별 상세 항목으로 등록 (다중행 자동생성) + const checkedMats = bomMaterials.filter(m => bomChecked.has(m.id)); + if (checkedMats.length > 0) { + const resolveType = (code: string) => itemTypeCatMap[code] || code || ""; + for (const mat of checkedMats) { + const typeLabel = resolveType(mat.child_item_type || ""); + const unitLabel = mat.detail_unit || mat.item_unit || ""; + onSubmit({ + ...submitData, + detail_type: "material_input", + content: `${mat.child_item_name || mat.child_item_id}${typeLabel ? ` ${typeLabel}` : ""} | ${mat.quantity || 1} ${unitLabel}`.trim(), + is_required: submitData.is_required || "Y", + bom_item_id: mat.child_item_id, + bom_item_name: mat.child_item_name || "", + bom_qty: String(mat.quantity || 1), + bom_unit: unitLabel, + }); + } + onClose(); + return; + } + submitData.content = submitData.content || "BOM 구성 자재 (자동 연동)"; + submitData.selected_bom_items = Array.from(bomChecked); + } + + onSubmit(submitData); + onClose(); + }; + + const currentType = formData.detail_type || ""; + + return ( + !v && onClose()}> + e.stopPropagation()}> + + + 상세 항목 {mode === "add" ? "추가" : "수정"} + + + 상세 항목의 유형을 선택하고 내용을 입력하세요 + + + +
+ {/* 유형 선택 */} +
+ + +
+ + {/* ============ 체크리스트 ============ */} + {currentType === "checklist" && ( +
+ + updateField("content", e.target.value)} + placeholder="예: 전원 상태 확인" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ )} + + {/* ============ 검사항목 ============ */} + {currentType === "inspection" && ( + <> + {/* 공정검사 적용 여부 */} +
+ +
+ + +
+
+ + {/* 적용 시: 품목검사정보에서 등록된 검사항목 표시 */} + {formData.process_inspection_apply === "apply" && ( +
+

+ 품목별 검사정보 (자동 연동) +

+ {itemInspLoading ? ( +
조회 중...
+ ) : itemInspections.length === 0 ? ( +

등록된 검사항목이 없습니다. 품목검사정보에서 먼저 등록해주세요.

+ ) : ( + + + + + + + + + + {itemInspections.map((insp, idx) => ( + + + + + + ))} + +
검사항목검사유형합격기준
{insp.inspection_item_name || insp.inspection_standard || "-"}{insp.inspection_type || "-"}{insp.pass_criteria || "-"}
+ )} +
+ )} + + {/* 미적용 시: 수동 입력 */} + {formData.process_inspection_apply === "none" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 외경 치수" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+
+ + updateField("inspection_method", e.target.value)} + placeholder="예: 마이크로미터" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + +
+
+ + {/* 기준값 ± 오차범위 + 자동수집 + PLC */} +
+
+
+ updateField("base_value", e.target.value)} + placeholder="기준값" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ ± +
+ updateField("tolerance", e.target.value)} + placeholder="오차범위" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ +
+ + +
+
+ + )} + + )} + + {/* ============ 작업절차 ============ */} + {currentType === "procedure" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 자재 투입" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + + updateField("duration_minutes", e.target.value ? Number(e.target.value) : undefined) + } + placeholder="예: 5" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + )} + + {/* ============ 직접입력 ============ */} + {currentType === "input" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 작업자 의견" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + +
+ + )} + + {/* ============ 문서참조 ============ */} + {currentType === "lookup" && ( + <> +
+ 📄 + + 해당 품목에 등록된 문서를 자동으로 불러옵니다. + +
+
+ +
+

+ 품목이 선택되지 않았습니다. +

+
+
+ + )} + + {/* ============ 설비점검 ============ */} + {currentType === "equip_inspection" && ( + <> +
+ 🏭 + + 공정에 지정된 설비를 자동 참조합니다. + +
+ +
+ +
+ + +
+
+ + {/* 적용 시: 설비 점검항목 표시 */} + {formData.equip_inspection_apply === "apply" && ( +
+

+ 설비 점검항목 (설비정보 연동) +

+ {equipInspLoading ? ( +
조회 중...
+ ) : equipInspItems.length === 0 ? ( +

해당 공정에 지정된 설비의 점검항목이 없습니다.

+ ) : ( + + + + + + + + + + + {equipInspItems.map((item, idx) => ( + + + + + + + ))} + +
설비코드점검항목점검방법기준범위
{item.equipment_code || "-"}{item.inspection_item || "-"}{item.inspection_method || "-"}{item.lower_limit || item.upper_limit ? `${item.lower_limit || ""} ~ ${item.upper_limit || ""}${item.unit ? ` ${item.unit}` : ""}` : "-"}
+ )} +
+ )} + + )} + + {/* ============ 설비조건 ============ */} + {currentType === "equip_condition" && ( + <> +
+ 🏭 + + 공정에 지정된 설비를 자동 참조합니다. + +
+ +
+ +
+ {/* 조건명 + 단위 */} +
+
+ updateField("content", e.target.value)} + placeholder="조건명 (예: 온도, 압력, RPM)" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ +
+
+ + {/* 기준값 ± 오차범위 */} +
+
+ updateField("condition_base_value", e.target.value)} + placeholder="기준값" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ ± +
+ updateField("condition_tolerance", e.target.value)} + placeholder="오차범위" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + {/* 자동수집 */} +
+ + +
+
+
+ + )} + + {/* ============ 실적등록 ============ */} + {currentType === "production_result" && ( +
+ +
+ {/* 작업수량 */} +
+
+ 📦 작업수량 + 생산된 전체 수량 +
+
+ + +
+
+ + {/* 불량수량 */} +
+
+ 🚫 불량수량 + 불량으로 판정된 수량 +
+
+ + +
+
+ + {/* 양품수량 */} +
+
+ ✅ 양품수량 + 작업수량 - 불량수량 +
+
+ + +
+
+
+
+ )} + + {/* ============ 자재투입 ============ */} + {currentType === "material_input" && ( +
+ +
+
+

+ 📦 BOM 구성 자재 (자동 연동) +

+ + {bomMaterials.length > 0 ? `${bomMaterials.length}건` : ""} + +
+ {bomMaterials.length > 0 && ( +
+ 0 + ? "indeterminate" + : false + } + onCheckedChange={(checked) => { + if (checked) { + setBomChecked(new Set(bomMaterials.map((m) => m.id))); + } else { + setBomChecked(new Set()); + } + }} + /> + + 전체 선택 ({bomChecked.size} / {bomMaterials.length}) + +
+ )} +
+ {bomLoading ? ( +
+ + BOM 데이터를 불러오는 중... +
+ ) : !selectedItemCode ? ( +
+ 품목을 먼저 선택하세요. +
+ ) : bomMaterials.length === 0 ? ( +
+ 해당 품목의 BOM 구성 자재가 없습니다. +
+ ) : ( + bomMaterials.map((mat) => { + const resolvedType = itemTypeCatMap[mat.child_item_type || ""] || mat.child_item_type || ""; + const typeColor = + resolvedType.includes("원자재") ? "#16a34a" + : resolvedType.includes("반제품") ? "#2563eb" + : resolvedType.includes("제품") ? "#9333ea" + : "#6b7280"; + return ( +
+ { + setBomChecked((prev) => { + const next = new Set(prev); + if (checked) next.add(mat.id); + else next.delete(mat.id); + return next; + }); + }} + /> +
+
+ {mat.child_item_type && ( + + {resolvedType} + + )} + + {mat.child_item_code || "-"} + + + {mat.child_item_name || "(이름 없음)"} + +
+
+ 소요량: {mat.quantity || "0"} {mat.detail_unit || mat.item_unit || ""} + {mat.process_type ? ` | 공정: ${mat.process_type}` : ""} +
+
+
+ ); + }) + )} +
+
+
+ )} + + {/* 필수 여부 (모든 유형 공통) */} + {currentType && ( +
+ + +
+ )} +
+ + + + + +
+
+ ); +} + +// ============================================================ +// 메인 모달 +// ============================================================ export function WorkStandardEditModal({ open, onClose, @@ -122,9 +1152,12 @@ export function WorkStandardEditModal({ const [detailModalOpen, setDetailModalOpen] = useState(false); const [detailModalMode, setDetailModalMode] = useState<"add" | "edit">("add"); const [detailModalPhase, setDetailModalPhase] = useState("PRE"); - const [detailFormData, setDetailFormData] = useState>({}); const [editingDetail, setEditingDetail] = useState(null); + // 드래그 정렬용 state (phase + workItem 조합으로 스코프 관리) + const [dragIdx, setDragIdx] = useState(null); + const [overIdx, setOverIdx] = useState(null); + // 데이터 로드 const loadData = useCallback(async () => { if (!workInstructionNo || !routingVersionId) return; @@ -163,21 +1196,16 @@ export function WorkStandardEditModal({ return map; }, [currentProcess]); - // 커스텀 복사 확인 후 수정 + // 커스텀 전환 — 로컬 마킹만 수행 (TASK:ERP-012 수정분 보존) + // 기존에는 copyWorkStandard API 호출 + loadData()로 state 전면 재로딩을 해서 + // closure로 잡은 workItem id / detail id가 모두 stale해져서 + // ① 삭제가 무효화되어 UI상 상세가 0개로 보이고 + // ② 추가 state 변경이 반영되지 않아 저장이 먹통이 됨. + // saveWIWorkStandard는 DELETE→INSERT 방식으로 전체 덮어쓰므로 사전 복사 불필요. const ensureCustom = useCallback(async () => { - if (isCustom) return true; - try { - const res = await copyWorkStandard(workInstructionNo, routingVersionId); - if (res.success) { - await loadData(); - setIsCustom(true); - return true; - } - } catch (err) { - toast.error("원본 복사에 실패했습니다"); - } - return false; - }, [isCustom, workInstructionNo, routingVersionId, loadData]); + if (!isCustom) setIsCustom(true); + return true; + }, [isCustom]); // 작업항목 추가 모달 열기 const openAddWorkItem = useCallback((phaseKey: string) => { @@ -204,7 +1232,6 @@ export function WorkStandardEditModal({ if (!ok || !currentProcess) return; if (editingWorkItem) { - // 수정 setProcesses(prev => { const next = [...prev]; const workItems = [...next[selectedProcessIdx].workItems]; @@ -216,7 +1243,6 @@ export function WorkStandardEditModal({ return next; }); } else { - // 추가 const phaseKey = addItemPhase === "IN" ? "IN" : addItemPhase; const phaseItems = currentProcess.workItems.filter( wi => wi.work_phase === phaseKey || (phaseKey === "IN" && wi.work_phase === "MAIN") @@ -271,11 +1297,6 @@ export function WorkStandardEditModal({ setDetailModalPhase(phaseKey); setDetailModalMode("add"); setEditingDetail(null); - setDetailFormData({ - detail_type: DETAIL_TYPES[0].value, - content: "", - is_required: "Y", - }); setDetailModalOpen(true); }, []); @@ -284,37 +1305,13 @@ export function WorkStandardEditModal({ setDetailModalPhase(phaseKey); setDetailModalMode("edit"); setEditingDetail(detail); - setDetailFormData({ ...detail }); setDetailModalOpen(true); }, []); - // 상세 추가/수정 처리 - const handleSaveDetail = useCallback(async () => { - const type = detailFormData.detail_type || ""; - if (!type) return; - - // 유효성 검사 (내용이 필요한 유형) - const needsContent = ["checklist", "procedure", "input", "equip_condition"]; - if (needsContent.includes(type) && !detailFormData.content?.trim()) { - toast.error("내용을 입력하세요"); - return; - } - if (type === "inspection" && !detailFormData.content?.trim()) { - toast.error("검사 항목명을 입력하세요"); - return; - } - + // 상세 추가 처리 — 다중행 자동생성 대응 (DetailFormModalInner의 onSubmit이 for-loop로 여러 번 호출) + const handleAddDetailRow = useCallback((data: Partial) => { const workItemId = selectedWorkItemIdByPhase[detailModalPhase]; if (!workItemId) return; - const ok = await ensureCustom(); - if (!ok) return; - - // content 자동 설정 (UI에서 직접 입력이 없는 유형들) - const submitData = { ...detailFormData }; - if (type === "lookup") submitData.content = submitData.content || "품목 등록 문서 (자동 연동)"; - if (type === "equip_inspection") submitData.content = submitData.content || "설비점검"; - if (type === "production_result") submitData.content = submitData.content || "작업수량 / 불량수량 / 양품수량"; - if (type === "material_input") submitData.content = submitData.content || "BOM 구성 자재 (자동 연동)"; setProcesses(prev => { const next = [...prev]; @@ -322,33 +1319,74 @@ export function WorkStandardEditModal({ const wiIdx = workItems.findIndex(wi => wi.id === workItemId); if (wiIdx < 0) return prev; - if (detailModalMode === "edit" && editingDetail) { - // 수정 - const details = (workItems[wiIdx].details || []).map(d => - d.id === editingDetail.id ? { ...d, ...submitData } : d - ); + const currentDetails = workItems[wiIdx].details || []; + const newDetail: WIWorkItemDetail = { + ...data, + id: `temp-detail-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + work_item_id: workItemId, + sort_order: currentDetails.length + 1, + }; + workItems[wiIdx] = { + ...workItems[wiIdx], + details: [...currentDetails, newDetail], + detail_count: currentDetails.length + 1, + }; + next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; + return next; + }); + setDirty(true); + }, [detailModalPhase, selectedWorkItemIdByPhase, selectedProcessIdx]); + + // 상세 수정 처리 + const handleUpdateDetailRow = useCallback((data: Partial) => { + const workItemId = selectedWorkItemIdByPhase[detailModalPhase]; + if (!workItemId || !editingDetail) return; + + setProcesses(prev => { + const next = [...prev]; + const workItems = [...next[selectedProcessIdx].workItems]; + const wiIdx = workItems.findIndex(wi => wi.id === workItemId); + if (wiIdx < 0) return prev; + + // 같은 유형의 apply 그룹 전체 일괄 업데이트 (필수여부 등) — 원본 WorkItemDetailList 로직과 동일 + const isApplyGroup = + (editingDetail.detail_type === "inspection" && editingDetail.process_inspection_apply === "apply") || + (editingDetail.detail_type === "equip_inspection" && editingDetail.equip_inspection_apply === "apply") || + editingDetail.detail_type === "material_input"; + + if (isApplyGroup && data.is_required !== undefined) { + const details = (workItems[wiIdx].details || []).map(d => { + const isSibling = + d.detail_type === editingDetail.detail_type && ( + d.detail_type === "inspection" ? d.process_inspection_apply === "apply" : + d.detail_type === "equip_inspection" ? d.equip_inspection_apply === "apply" : + d.detail_type === "material_input" + ); + return isSibling ? { ...d, is_required: data.is_required! } : d; + }); workItems[wiIdx] = { ...workItems[wiIdx], details }; } else { - // 추가 - const newDetail: WIWorkItemDetail = { - ...submitData, - id: `temp-detail-${Date.now()}`, - work_item_id: workItemId, - sort_order: (workItems[wiIdx].details?.length || 0) + 1, - }; - workItems[wiIdx] = { - ...workItems[wiIdx], - details: [...(workItems[wiIdx].details || []), newDetail], - detail_count: (workItems[wiIdx].detail_count || 0) + 1, - }; + const details = (workItems[wiIdx].details || []).map(d => + d.id === editingDetail.id ? { ...d, ...data } : d + ); + workItems[wiIdx] = { ...workItems[wiIdx], details }; } next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; return next; }); - - setDetailModalOpen(false); setDirty(true); - }, [detailFormData, detailModalPhase, detailModalMode, editingDetail, selectedWorkItemIdByPhase, ensureCustom, selectedProcessIdx]); + }, [detailModalPhase, selectedWorkItemIdByPhase, selectedProcessIdx, editingDetail]); + + // DetailFormModalInner onSubmit 분기 진입점 + const handleDetailSubmit = useCallback(async (data: Partial) => { + const ok = await ensureCustom(); + if (!ok) return; + if (detailModalMode === "add") { + handleAddDetailRow(data); + } else { + handleUpdateDetailRow(data); + } + }, [detailModalMode, handleAddDetailRow, handleUpdateDetailRow, ensureCustom]); // 상세 삭제 const handleDeleteDetail = useCallback(async (detailId: string, phaseKey: string) => { @@ -374,6 +1412,25 @@ export function WorkStandardEditModal({ setDirty(true); }, [selectedWorkItemIdByPhase, ensureCustom, selectedProcessIdx]); + // 드래그 정렬 — 상세 목록 순서 변경 + const handleReorderDetails = useCallback((phaseKey: string, reordered: WIWorkItemDetail[]) => { + const workItemId = selectedWorkItemIdByPhase[phaseKey]; + if (!workItemId) return; + setProcesses(prev => { + const next = [...prev]; + const workItems = [...next[selectedProcessIdx].workItems]; + const wiIdx = workItems.findIndex(wi => wi.id === workItemId); + if (wiIdx >= 0) { + // sort_order 재할당 + const withOrder = reordered.map((d, i) => ({ ...d, sort_order: i + 1 })); + workItems[wiIdx] = { ...workItems[wiIdx], details: withOrder }; + } + next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; + return next; + }); + setDirty(true); + }, [selectedWorkItemIdByPhase, selectedProcessIdx]); + // 저장 const handleSave = useCallback(async () => { if (!currentProcess) return; @@ -394,7 +1451,7 @@ export function WorkStandardEditModal({ } else { toast.error("저장에 실패했습니다"); } - } catch (err) { + } catch { toast.error("저장 중 오류가 발생했습니다"); } finally { setSaving(false); @@ -410,15 +1467,11 @@ export function WorkStandardEditModal({ toast.success("원본으로 초기화되었습니다"); await loadData(); } - } catch (err) { + } catch { toast.error("초기화에 실패했습니다"); } }, [workInstructionNo, loadData]); - const updateDetailField = (field: string, value: unknown) => { - setDetailFormData(prev => ({ ...prev, [field]: value })); - }; - return ( { if (!v) onClose(); }}> @@ -473,6 +1526,7 @@ export function WorkStandardEditModal({ const phaseItems = workItemsByPhase[phase.key] || []; const selectedWiId = selectedWorkItemIdByPhase[phase.key] || null; const selectedWi = phaseItems.find(wi => wi.id === selectedWiId) || null; + const selectedDetails = selectedWi?.details || []; return (
@@ -555,7 +1609,7 @@ export function WorkStandardEditModal({ )}
- {/* 우측: 상세 테이블 */} + {/* 우측: 상세 테이블 — 드래그 정렬 지원 */}
{!selectedWi ? (
@@ -568,7 +1622,7 @@ export function WorkStandardEditModal({
{selectedWi.title} - {selectedWi.details?.length || 0}개 + {selectedDetails.length}개
- {/* 상세 추가/수정 다이얼로그 (유형별 동적 필드) */} - { if (!v) setDetailModalOpen(false); }}> - e.stopPropagation()}> - - - 상세 항목 {detailModalMode === "add" ? "추가" : "수정"} - - - 상세 항목의 유형을 선택하고 내용을 입력하세요 - - - -
- {/* 유형 선택 */} -
- - -
- - {/* 체크리스트 */} - {detailFormData.detail_type === "checklist" && ( -
- - updateDetailField("content", e.target.value)} - placeholder="예: 전원 상태 확인" - className="mt-1 h-8 text-xs" - /> -
- )} - - {/* 검사항목 */} - {detailFormData.detail_type === "inspection" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="예: 외경 치수" - className="mt-1 h-8 text-xs" - /> -
-
-
- - updateDetailField("inspection_method", e.target.value)} - placeholder="예: 마이크로미터" - className="mt-1 h-8 text-xs" - /> -
-
- - -
-
-
-
-
- updateDetailField("base_value", e.target.value)} - placeholder="기준값" - className="h-8 text-xs" - /> -
- ± -
- updateDetailField("tolerance", e.target.value)} - placeholder="오차범위" - className="h-8 text-xs" - /> -
-
-
- - )} - - {/* 작업절차 */} - {detailFormData.detail_type === "procedure" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="예: 자재 투입" - className="mt-1 h-8 text-xs" - /> -
-
- - updateDetailField("duration_minutes", e.target.value ? Number(e.target.value) : undefined)} - placeholder="예: 5" - className="mt-1 h-8 text-xs" - /> -
- - )} - - {/* 직접입력 */} - {detailFormData.detail_type === "input" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="예: 작업자 의견" - className="mt-1 h-8 text-xs" - /> -
-
- - -
- - )} - - {/* 문서참조 */} - {detailFormData.detail_type === "lookup" && ( -
- - 해당 품목에 등록된 문서를 자동으로 불러옵니다. - -
- )} - - {/* 설비점검 */} - {detailFormData.detail_type === "equip_inspection" && ( -
- - updateDetailField("content", e.target.value)} - placeholder="예: 설비 가동 전 안전 점검" - className="mt-1 h-8 text-xs" - /> -
- )} - - {/* 설비조건 */} - {detailFormData.detail_type === "equip_condition" && ( - <> -
- - updateDetailField("content", e.target.value)} - placeholder="조건명 (예: 온도, 압력, RPM)" - className="mt-1 h-8 text-xs" - /> -
-
-
-
- -
-
-
-
- updateDetailField("condition_base_value", e.target.value)} - placeholder="기준값" - className="h-8 text-xs" - /> -
- ± -
- updateDetailField("condition_tolerance", e.target.value)} - placeholder="오차범위" - className="h-8 text-xs" - /> -
-
-
- - )} - - {/* 실적등록 */} - {detailFormData.detail_type === "production_result" && ( -
-

작업수량 / 불량수량 / 양품수량

-

실적 입력 항목이 자동으로 생성됩니다.

-
- )} - - {/* 자재투입 */} - {detailFormData.detail_type === "material_input" && ( -
-

BOM 구성 자재 (자동 연동)

-

품목에 등록된 BOM 구성 자재가 자동으로 적용됩니다.

-
- )} - - {/* 필수 여부 (모든 유형 공통) */} - {detailFormData.detail_type && ( -
- - -
- )} - - {/* 비고 (모든 유형 공통) */} - {detailFormData.detail_type && ( -
- - updateDetailField("remark", e.target.value)} - placeholder="비고 입력" - className="mt-1 h-8 text-xs" - /> -
- )} -
- - - - - -
-
+ {/* 상세 추가/수정 다이얼로그 — 원본 DetailFormModal 로직을 이식 */} + setDetailModalOpen(false)} + onSubmit={handleDetailSubmit} + editData={editingDetail} + mode={detailModalMode} + selectedItemCode={itemCode} + selectedProcessCode={currentProcess?.process_code} + /> ); diff --git a/frontend/app/(main)/COMPANY_9/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_9/quality/item-inspection/page.tsx index 0e976d47..6bb55a70 100644 --- a/frontend/app/(main)/COMPANY_9/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_9/quality/item-inspection/page.tsx @@ -13,6 +13,7 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen import { Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, Copy, + ChevronUp, ChevronDown, ChevronsUpDown, } from "lucide-react"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { cn } from "@/lib/utils"; @@ -472,6 +473,34 @@ export default function ItemInspectionInfoPage() { return Object.values(map); }, [data]); + // 좌측 품목 목록 정렬 (컬럼 헤더 클릭 → asc → desc → 해제 순환) + const [sortConfig, setSortConfig] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); + const handleSort = (key: string) => { + setSortConfig(prev => { + if (!prev || prev.key !== key) return { key, direction: "asc" }; + if (prev.direction === "asc") return { key, direction: "desc" }; + return null; + }); + }; + const sortedGroupedData = useMemo(() => { + if (!sortConfig) return groupedData; + const { key, direction } = sortConfig; + const mul = direction === "asc" ? 1 : -1; + const getVal = (g: any): string => { + if (key === "inspection_type") { + const code = g.types[0] || ""; + return inspTypeCatOptions.find(o => o.code === code)?.label || code; + } + if (key === "is_active") { + return (g.is_active === "사용" || g.is_active === "true") ? "사용" : "미사용"; + } + return String(g[key] ?? ""); + }; + return [...groupedData].sort((a, b) => { + return getVal(a).localeCompare(getVal(b), "ko", { numeric: true }) * mul; + }); + }, [groupedData, sortConfig, inspTypeCatOptions]); + // 선택된 품목의 그룹 데이터 const selectedGroup = useMemo(() => { if (!selectedItemCode) return null; @@ -989,14 +1018,28 @@ export default function ItemInspectionInfoPage() { {ts.visibleColumns.map((col) => ( - - {col.label} + handleSort(col.key)} + > +
+ {col.label} + {sortConfig?.key === col.key ? ( + sortConfig.direction === "asc" + ? + : + ) : ( + + )} +
))}
- {groupedData.map((group) => ( + {sortedGroupedData.map((group) => ( ([]); + + // 좌측: 부서 + const [depts, setDepts] = useState([]); + const [deptLoading, setDeptLoading] = useState(false); + const [deptCount, setDeptCount] = useState(0); + const [selectedDeptId, setSelectedDeptId] = useState(null); + + // 우측: 사원 + const [members, setMembers] = useState([]); + const [memberLoading, setMemberLoading] = useState(false); + + // 부서 모달 + const [deptModalOpen, setDeptModalOpen] = useState(false); + const [deptEditMode, setDeptEditMode] = useState(false); + const [deptForm, setDeptForm] = useState>({}); + const [saving, setSaving] = useState(false); + + // 채번 시스템 (UI 노출 없이 저장 시점에만 allocate) + const [numberingRuleId, setNumberingRuleId] = useState(null); + + // 사원 모달 + const [userModalOpen, setUserModalOpen] = useState(false); + const [userEditMode, setUserEditMode] = useState(false); + const [userForm, setUserForm] = useState>({}); + const [formErrors, setFormErrors] = useState>({}); + const [idChecked, setIdChecked] = useState(false); + const [idChecking, setIdChecking] = useState(false); + + // 사원 탭 (재직중/퇴사) + const [memberTab, setMemberTab] = useState<"active" | "resigned">("active"); + + // 엑셀 + const [excelUploadOpen, setExcelUploadOpen] = useState(false); + + // 트리 확장/축소 상태 + const [expandedDepts, setExpandedDepts] = useState>(new Set()); + const didInitExpandRef = useRef(false); + + // 부서 조회 — autoFilter + 로그인 회사 기준 + const fetchDepts = useCallback(async () => { + if (!companyCode) return; + setDeptLoading(true); + try { + const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); + const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, { + page: 1, size: 0, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + }); + const raw = res.data?.data?.data || res.data?.data?.rows || []; + const data = raw.map((d: any) => ({ ...d, id: d.id || d.dept_code })); + setDepts(data); + setDeptCount(res.data?.data?.total || data.length); + } catch (err) { + toast.error("부서 목록을 불러오는데 실패했습니다."); + } finally { + setDeptLoading(false); + } + }, [searchFilters, companyCode]); + + useEffect(() => { fetchDepts(); }, [fetchDepts]); + + // 트리 초기 로드 시 모든 상위부서 펼침 (최초 1회) + useEffect(() => { + if (!didInitExpandRef.current && depts.length > 0) { + const parents = new Set(); + depts.forEach((d) => { if (d.parent_dept_code) parents.add(d.parent_dept_code); }); + setExpandedDepts(parents); + didInitExpandRef.current = true; + } + }, [depts]); + + const toggleExpand = (deptCode: string) => { + setExpandedDepts((prev) => { + const next = new Set(prev); + if (next.has(deptCode)) next.delete(deptCode); + else next.add(deptCode); + return next; + }); + }; + + // 선택된 부서 + const selectedDept = depts.find((d) => d.id === selectedDeptId); + const selectedDeptCode = selectedDept?.dept_code || null; + + // 우측: 사원 조회 + const fetchMembers = useCallback(async () => { + if (!companyCode) return; + setMemberLoading(true); + try { + const baseFilters: any[] = []; + if (selectedDeptCode) { + baseFilters.push({ columnName: "dept_code", operator: "equals", value: selectedDeptCode }); + } + const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, { + page: 1, size: 0, + dataFilter: baseFilters.length > 0 ? { enabled: true, filters: baseFilters } : undefined, + autoFilter: true, + }); + setMembers(res.data?.data?.data || res.data?.data?.rows || []); + } catch { setMembers([]); } finally { setMemberLoading(false); } + }, [selectedDeptCode, companyCode]); + + useEffect(() => { fetchMembers(); }, [fetchMembers]); + + // 부서 등록 + const openDeptRegister = async () => { + setDeptForm({}); + setDeptEditMode(false); + setNumberingRuleId(null); + setDeptModalOpen(true); + + // 채번 규칙 조회 (UI preview 없이 규칙 존재 여부만 확인) + try { + const ruleRes = await apiClient.get(`/numbering-rules/by-column/dept_info/dept_code`); + const ruleData = ruleRes.data; + if (ruleData?.success && ruleData?.data?.ruleId) { + setNumberingRuleId(ruleData.data.ruleId); + } + } catch { + // 채번 규칙 없으면 무시 (백엔드가 자동 생성) + } + }; + + const openDeptEdit = () => { + if (!selectedDept) return; + setDeptForm({ ...selectedDept }); + setDeptEditMode(true); + setDeptModalOpen(true); + }; + + const handleDeptSave = async () => { + if (!deptForm.dept_name) { toast.error("부서명은 필수입니다."); return; } + if (!companyCode) { toast.error("회사 정보를 확인할 수 없습니다."); return; } + const parentCode = (deptForm.parent_dept_code && deptForm.parent_dept_code !== "none") ? deptForm.parent_dept_code : null; + setSaving(true); + try { + if (deptEditMode && deptForm.dept_code) { + const response = await departmentAPI.updateDepartment(deptForm.dept_code, { + dept_name: deptForm.dept_name, + parent_dept_code: parentCode, + }); + if (!response.success) { toast.error((response as any).error || "수정에 실패했습니다."); return; } + toast.success("수정되었습니다."); + } else { + // 채번 규칙이 있으면 allocate, 없으면 클라이언트에서 D### 포맷 자동 생성 + let allocatedCode: string | undefined; + if (numberingRuleId) { + const allocRes = await allocateNumberingCode(numberingRuleId); + if (allocRes.success && allocRes.data?.generatedCode) { + allocatedCode = allocRes.data.generatedCode; + } else { + toast.error("채번 코드 할당에 실패했습니다."); + return; + } + } else { + // 채번 규칙 미존재 — 기존 부서의 D### 최대 순번 +1로 자동 생성 + const prefix = "D"; + const maxNum = depts + .map((d) => d.dept_code || "") + .filter((c: string) => typeof c === "string" && c.startsWith(prefix)) + .map((c: string) => parseInt(c.substring(prefix.length), 10)) + .filter((n) => !isNaN(n)) + .reduce((max, n) => Math.max(max, n), 0); + allocatedCode = `${prefix}${String(maxNum + 1).padStart(3, "0")}`; + } + + const response = await departmentAPI.createDepartment(companyCode, { + dept_name: deptForm.dept_name, + parent_dept_code: parentCode, + dept_code: allocatedCode, + }); + if (!response.success) { + toast.error((response as any).error || "등록에 실패했습니다."); + return; + } + toast.success("등록되었습니다."); + } + setDeptModalOpen(false); + fetchDepts(); + } catch (err: any) { + toast.error(err.response?.data?.message || "저장에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + // 부서 삭제 + const handleDeptDelete = async () => { + if (!selectedDeptCode) return; + const ok = await confirm("부서를 삭제하시겠습니까?", { + description: "해당 부서에 소속된 사원 정보는 유지됩니다.", + variant: "destructive", confirmText: "삭제", + }); + if (!ok) return; + try { + const response = await departmentAPI.deleteDepartment(selectedDeptCode); + if (!response.success) { toast.error((response as any).error || "삭제에 실패했습니다."); return; } + toast.success(response.message || "삭제되었습니다."); + setSelectedDeptId(null); + fetchDepts(); + } catch { toast.error("삭제에 실패했습니다."); } + }; + + // 사원 추가/수정 + const openUserModal = (editData?: any) => { + if (editData) { + setUserEditMode(true); + setUserForm({ ...editData, user_password: "" }); + setIdChecked(true); + } else { + setUserEditMode(false); + setUserForm({ dept_code: selectedDeptCode || "", user_password: "" }); + setIdChecked(false); + } + setFormErrors({}); + setUserModalOpen(true); + }; + + // 사용자 ID 중복 확인 + const handleCheckUserId = async () => { + const uid = (userForm.user_id || "").trim(); + if (!uid) { toast.error("사용자 ID를 입력해주세요."); return; } + setIdChecking(true); + try { + const res = await apiClient.post("/admin/users/check-duplicate", { userId: uid }); + const isDuplicate = res.data?.data?.isDuplicate; + if (isDuplicate) { + toast.error("이미 사용 중인 사용자 ID입니다."); + setIdChecked(false); + } else { + toast.success("사용 가능한 사용자 ID입니다."); + setIdChecked(true); + } + } catch { + toast.error("중복 확인에 실패했습니다."); + setIdChecked(false); + } finally { + setIdChecking(false); + } + }; + + const handleUserFormChange = (field: string, value: string) => { + const formatted = formatField(field, value); + setUserForm((prev) => ({ ...prev, [field]: formatted })); + const error = validateField(field, formatted); + setFormErrors((prev) => { const n = { ...prev }; if (error) n[field] = error; else delete n[field]; return n; }); + }; + + const handleUserSave = async () => { + if (!userForm.user_id) { toast.error("사용자 ID는 필수입니다."); return; } + if (!userEditMode && !idChecked) { toast.error("사용자 ID 중복 확인을 해주세요."); return; } + if (!userForm.user_name) { toast.error("사용자 이름은 필수입니다."); return; } + if (!userForm.dept_code) { toast.error("부서는 필수입니다."); return; } + const errors = validateForm(userForm, ["cell_phone", "email"]); + setFormErrors(errors); + if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; } + + setSaving(true); + try { + // 비밀번호 미입력 시 기본값 (신규만) + const password = userForm.user_password || (!userEditMode ? "qlalfqjsgh11" : undefined); + + await apiClient.post("/admin/users/with-dept", { + userInfo: { + user_id: userForm.user_id, + user_name: userForm.user_name, + user_name_eng: userForm.user_name_eng || undefined, + user_password: password || undefined, + email: userEditMode ? (userForm.email || null) : (userForm.email || undefined), + tel: userForm.tel || undefined, + cell_phone: userEditMode ? (userForm.cell_phone || null) : (userForm.cell_phone || undefined), + sabun: userEditMode ? (userForm.sabun || null) : (userForm.sabun || undefined), + position_name: userForm.position_name || undefined, + dept_code: userForm.dept_code || undefined, + dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined, + status: userForm.status || "active", + end_date: userForm.end_date || null, + }, + mainDept: userForm.dept_code ? { + dept_code: userForm.dept_code, + dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name, + position_name: userForm.position_name || undefined, + } : undefined, + isUpdate: userEditMode, + }); + toast.success(userEditMode ? "사원 정보가 수정되었습니다." : "사원이 추가되었습니다."); + setUserModalOpen(false); + fetchMembers(); + } catch (err: any) { + toast.error(err.response?.data?.message || "저장에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + // 엑셀 다운로드 + const handleExcelDownload = async () => { + if (depts.length === 0) return; + const data = depts.map((d) => ({ + 부서코드: d.dept_code, 부서명: d.dept_name, 상위부서: d.parent_dept_code, 상태: d.status, + })); + await exportToExcel(data, "부서관리.xlsx", "부서"); + toast.success("다운로드 완료"); + }; + + // 퇴사일 기반 재직/퇴사 분리 + const _now = new Date(); + const today = `${_now.getFullYear()}-${String(_now.getMonth() + 1).padStart(2, "0")}-${String(_now.getDate()).padStart(2, "0")}`; + const activeMembers = members.filter((m) => !m.end_date || m.end_date.substring(0, 10) >= today); + const resignedMembers = members.filter((m) => m.end_date && m.end_date.substring(0, 10) < today); + + // 트리 빌드: 부모-자식 관계 기준. 고아 노드는 루트로 처리. + const allDeptCodes = new Set(depts.map((d) => d.dept_code)); + const buildTreeChildren = (parentCode: string | null) => { + if (parentCode === null) { + return depts.filter((d) => !d.parent_dept_code || !allDeptCodes.has(d.parent_dept_code)); + } + return depts.filter((d) => d.parent_dept_code === parentCode); + }; + + const renderTreeNode = (parentCode: string | null, level: number = 0): React.ReactNode => { + const children = buildTreeChildren(parentCode); + return children.map((dept) => { + const hasChildren = depts.some((d) => d.parent_dept_code === dept.dept_code); + const isExpanded = expandedDepts.has(dept.dept_code); + const isSelected = selectedDeptId === dept.id; + return ( + +
setSelectedDeptId(dept.id)} + onDoubleClick={() => { setSelectedDeptId(dept.id); openDeptEdit(); }} + > + {hasChildren ? ( + + ) : ( +
+ )} + {dept.dept_name || "—"} + {dept.dept_code} + {dept.status && ( + + {dept.status === "active" ? "활성" : dept.status} + + )} +
+ {hasChildren && isExpanded && renderTreeNode(dept.dept_code, level + 1)} + + ); + }); + }; + + return ( +
+ {/* 검색 필터 바 */} + + + +
+ } + /> + + {/* 마스터-디테일 분할 패널 */} +
+ + {/* 좌측: 부서 목록 */} + +
+
+
+ 부서 목록 + + {deptCount}건 + +
+
+ + + +
+
+ + {/* 부서 트리 */} +
+ {deptLoading ? ( +
+ +
+ ) : depts.length === 0 ? ( +
등록된 부서가 없어요
+ ) : ( +
{renderTreeNode(null)}
+ )} +
+ +
+
+ + + + {/* 우측: 사원 목록 */} + +
+ {!selectedDeptId ? ( +
+
+ +
부서를 선택해주세요
+
좌측에서 부서를 선택하면 소속 사원 목록이 표시돼요
+
+
+ ) : ( + <> +
+ {selectedDept?.dept_name || "-"} + + {selectedDept?.dept_code || "-"} + +
+ +
+
+ + {/* 재직/퇴사 탭 */} +
+ + +
+ +
+ {memberLoading ? ( +
+ +
+ ) : memberTab === "active" ? ( + activeMembers.length === 0 ? ( +
재직중인 사원이 없어요
+ ) : ( + + + + No + 사번 + 이름 + 사용자ID + 직급 + 휴대폰 + 이메일 + + + + {activeMembers.map((member, idx) => ( + openUserModal(member)} + > + {idx + 1} + {member.sabun || "—"} + {member.user_name} + {member.user_id} + {member.position_name || "—"} + {member.cell_phone || "—"} + {member.email || "—"} + + ))} + +
+ ) + ) : ( + resignedMembers.length === 0 ? ( +
퇴사한 사원이 없어요
+ ) : ( + + + + No + 사번 + 이름 + 사용자ID + 직급 + 휴대폰 + 이메일 + 퇴사일 + + + + {resignedMembers.map((member, idx) => ( + openUserModal(member)} + > + {idx + 1} + {member.sabun || "—"} + {member.user_name} + {member.user_id} + {member.position_name || "—"} + {member.cell_phone || "—"} + {member.email || "—"} + {member.end_date ? member.end_date.substring(0, 10) : "—"} + + ))} + +
+ ) + )} +
+ + )} +
+
+
+
+ + {/* 부서 등록/수정 모달 */} + + + + {deptEditMode ? "부서 수정" : "부서 등록"} + {deptEditMode ? "부서 정보를 수정합니다." : "새로운 부서를 등록합니다."} + +
+
+ + 부서명 * + + setDeptForm((p) => ({ ...p, dept_name: e.target.value }))} + placeholder="부서명을 입력해 주세요" + className="h-9" + /> +
+
+ 상위부서 + +
+
+ + + + +
+
+ + {/* 사원 추가/수정 모달 */} + + + + {userEditMode ? "사원 정보 수정" : "사원 추가"} + + {userEditMode + ? `${userForm.user_name} (${userForm.user_id}) 사원 정보를 수정합니다.` + : selectedDept ? `${selectedDept.dept_name} 부서에 사원을 추가합니다.` : "사원을 추가합니다."} + + +
+
+ + 사용자 ID * + {!userEditMode && idChecked && ( + ✓ 사용 가능 + )} + +
+ { + setUserForm((p) => ({ ...p, user_id: e.target.value })); + if (!userEditMode) setIdChecked(false); + }} + placeholder="사용자 ID를 입력해 주세요" + className="h-9 flex-1" + disabled={userEditMode} + /> + {!userEditMode && ( + + )} +
+
+
+ + 이름 * + + setUserForm((p) => ({ ...p, user_name: e.target.value }))} + placeholder="이름을 입력해 주세요" + className="h-9" + /> +
+
+ 사번 + setUserForm((p) => ({ ...p, sabun: e.target.value }))} + placeholder="사번" + className="h-9" + autoComplete="off" + /> +
+
+ 비밀번호 + setUserForm((p) => ({ ...p, user_password: e.target.value }))} + placeholder={userEditMode ? "변경 시에만 입력해 주세요" : "미입력 시 기본값이 설정돼요"} + className="h-9" + type="password" + autoComplete="new-password" + /> +
+
+ 직급 + setUserForm((p) => ({ ...p, position_name: e.target.value }))} + placeholder="직급" + className="h-9" + /> +
+
+ + 부서 * + + +
+
+ 휴대폰 + handleUserFormChange("cell_phone", e.target.value)} + placeholder="010-0000-0000" + className={cn("h-9", formErrors.cell_phone && "border-destructive")} + /> + {formErrors.cell_phone &&

{formErrors.cell_phone}

} +
+
+ 이메일 + handleUserFormChange("email", e.target.value)} + placeholder="example@email.com" + className={cn("h-9", formErrors.email && "border-destructive")} + /> + {formErrors.email &&

{formErrors.email}

} +
+
+ 입사일 + setUserForm((p) => ({ ...p, regdate: e.target.value }))} + className="h-9" + /> +
+
+ 퇴사일 + setUserForm((p) => ({ ...p, end_date: e.target.value }))} + className="h-9" + /> +
+
+ + + + +
+
+ + {/* 엑셀 업로드 */} + fetchDepts()} + /> + + {ConfirmDialogComponent} +
+ ); +} diff --git a/frontend/app/(main)/admin/userMng/rolesList/[id]/page.tsx b/frontend/app/(main)/admin/userMng/rolesList/[id]/page.tsx index 9ba74114..62d9b3e0 100644 --- a/frontend/app/(main)/admin/userMng/rolesList/[id]/page.tsx +++ b/frontend/app/(main)/admin/userMng/rolesList/[id]/page.tsx @@ -378,7 +378,7 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin } return ( -
+
{/* 페이지 헤더 */}
diff --git a/frontend/app/(main)/admin/userMng/rolesList/page.tsx b/frontend/app/(main)/admin/userMng/rolesList/page.tsx index 2bdacd2d..4b1dbabb 100644 --- a/frontend/app/(main)/admin/userMng/rolesList/page.tsx +++ b/frontend/app/(main)/admin/userMng/rolesList/page.tsx @@ -219,7 +219,7 @@ export default function RolesPage() { // 관리자가 아니면 접근 제한 if (!isAdmin) { return ( -
+

{t("roles.title")}

@@ -244,7 +244,7 @@ export default function RolesPage() { } return ( -
+
{/* 페이지 헤더 */}
diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index d5cde16b..9f325ed7 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -823,9 +823,9 @@ export const ExcelUploadModal: React.FC = ({ }; // 템플릿 다운로드: 테이블 스키마 기반으로 빈 엑셀 파일 생성 + // 필수 컬럼은 헤더 텍스트를 빨강으로 표시 const handleDownloadTemplate = async () => { try { - const { exportToExcel } = await import("@/lib/utils/excelExport"); const response = await getTableSchema(tableName); if (!response.success || !response.data) { toast.error("테이블 정보를 가져올 수 없습니다."); @@ -837,15 +837,46 @@ export const ExcelUploadModal: React.FC = ({ (col) => !AUTO_COLS.includes(col.name.toLowerCase()) ); - // 필수 컬럼에 * 표시 - const headerRow: Record = {}; - for (const col of columns) { + const ExcelJS = (await import("exceljs")).default; + const workbook = new ExcelJS.Workbook(); + const ws = workbook.addWorksheet("Sheet1"); + + const headers = columns.map((col) => { const label = col.label || col.name; const isRequired = !col.nullable; - headerRow[isRequired ? `${label} *` : label] = ""; - } + return isRequired ? `${label} *` : label; + }); + + columns.forEach((_, i) => { + ws.getColumn(i + 1).width = 18; + }); + + const headerRow = ws.addRow(headers); + headerRow.height = 24; + headerRow.eachCell((cell, colNumber) => { + const col = columns[colNumber - 1]; + const isRequired = !col.nullable; + cell.font = { + bold: true, + size: 10, + color: { argb: isRequired ? "FFDC2626" : "FF1E293B" }, + }; + cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFF1F5F9" } }; + cell.border = { bottom: { style: "medium", color: { argb: "FF94A3B8" } } }; + cell.alignment = { vertical: "middle", horizontal: "center" }; + }); + + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${tableName}_템플릿.xlsx`; + a.click(); + URL.revokeObjectURL(url); - await exportToExcel([headerRow], `${tableName}_템플릿.xlsx`, "Sheet1"); toast.success("템플릿 파일이 다운로드되었습니다."); } catch (error) { console.error("템플릿 다운로드 실패:", error); diff --git a/frontend/components/common/MultiTableExcelUploadModal.tsx b/frontend/components/common/MultiTableExcelUploadModal.tsx index 586ee0d7..9d58eb0e 100644 --- a/frontend/components/common/MultiTableExcelUploadModal.tsx +++ b/frontend/components/common/MultiTableExcelUploadModal.tsx @@ -33,7 +33,7 @@ import { } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; -import { importFromExcel, getExcelSheetNames, exportToExcel } from "@/lib/utils/excelExport"; +import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport"; import { cn } from "@/lib/utils"; import { EditableSpreadsheet } from "./EditableSpreadsheet"; import { @@ -144,32 +144,64 @@ export const MultiTableExcelUploadModal: React.FC { + // 템플릿 다운로드 (필수 헤더는 빨강 폰트) + const handleDownloadTemplate = async () => { if (!selectedMode) return; - const headers: string[] = []; - const sampleRow: Record = {}; - const sampleRow2: Record = {}; + try { + const headers: string[] = []; + const requiredFlags: boolean[] = []; - for (const levelIdx of selectedMode.activeLevels) { - const level = config.levels[levelIdx]; - if (!level) continue; - for (const col of level.columns) { - headers.push(col.excelHeader); - sampleRow[col.excelHeader] = col.required ? "(필수)" : ""; - sampleRow2[col.excelHeader] = ""; + for (const levelIdx of selectedMode.activeLevels) { + const level = config.levels[levelIdx]; + if (!level) continue; + for (const col of level.columns) { + headers.push(col.excelHeader); + requiredFlags.push(!!col.required); + } } + + const ExcelJS = (await import("exceljs")).default; + const workbook = new ExcelJS.Workbook(); + const ws = workbook.addWorksheet("Sheet1"); + + headers.forEach((_, i) => { + ws.getColumn(i + 1).width = 18; + }); + + const headerRow = ws.addRow(headers); + headerRow.height = 24; + headerRow.eachCell((cell, colNumber) => { + const isRequired = requiredFlags[colNumber - 1]; + cell.font = { + bold: true, + size: 10, + color: { argb: isRequired ? "FFDC2626" : "FF1E293B" }, + }; + cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFF1F5F9" } }; + cell.border = { bottom: { style: "medium", color: { argb: "FF94A3B8" } } }; + cell.alignment = { vertical: "middle", horizontal: "center" }; + }); + + // 빈 행 1줄 (사용자가 입력할 수 있도록) + ws.addRow(headers.map(() => "")); + + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${config.name}_${selectedMode.label}_템플릿.xlsx`; + a.click(); + URL.revokeObjectURL(url); + + toast.success("템플릿 파일이 다운로드되었습니다."); + } catch (err) { + console.error("템플릿 다운로드 실패:", err); + toast.error("템플릿 다운로드에 실패했습니다."); } - - // 예시 데이터 생성 (config에 맞춰) - exportToExcel( - [sampleRow, sampleRow2], - `${config.name}_${selectedMode.label}_템플릿.xlsx`, - "Sheet1" - ); - - toast.success("템플릿 파일이 다운로드되었습니다."); }; // 파일 처리 diff --git a/frontend/lib/api/workInstruction.ts b/frontend/lib/api/workInstruction.ts index 59a75443..e7af6d22 100644 --- a/frontend/lib/api/workInstruction.ts +++ b/frontend/lib/api/workInstruction.ts @@ -74,6 +74,9 @@ export interface WIWorkItemDetail { is_required?: string; sort_order?: number; remark?: string; + + // 검사항목(inspection) 전용 + process_inspection_apply?: string; // "apply" | "none" inspection_code?: string; inspection_method?: string; unit?: string; @@ -81,13 +84,48 @@ export interface WIWorkItemDetail { upper_limit?: string; base_value?: string; tolerance?: string; + auto_collect?: string; // "Y" | "N" + plc_data?: string; + + // 작업절차(procedure) 전용 duration_minutes?: number; + + // 직접입력(input) 전용 input_type?: string; + + // 문서참조(lookup) 전용 lookup_target?: string; display_fields?: string; + + // 설비점검(equip_inspection) 전용 + equip_inspection_apply?: string; // "apply" | "none" + + // 설비조건(equip_condition) 전용 + condition_name?: string; + condition_unit?: string; condition_base_value?: string; condition_tolerance?: string; - condition_unit?: string; + condition_auto_collect?: string; + condition_plc_data?: string; + + // 실적등록(production_result) 전용 + work_qty_auto_collect?: string; + work_qty_plc_data?: string; + defect_qty_auto_collect?: string; + defect_qty_plc_data?: string; + good_qty_auto_collect?: string; + good_qty_plc_data?: string; + + // 자재투입(material_input) 전용 - BOM 자동연동 + material_code?: string; + material_name?: string; + quantity?: string; + material_unit?: string; + selected_bom_items?: string[] | string; + bom_item_id?: string; + bom_item_name?: string; + bom_qty?: string; + bom_unit?: string; } export interface WIWorkItem { diff --git a/frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx b/frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx index cd89fff4..d4bd74a1 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx @@ -275,7 +275,17 @@ export function BomExcelUploadModal({ const handleDownloadTemplate = useCallback(async () => { setDownloading(true); try { - const XLSX = await import("xlsx"); + // BOM 컬럼 정의: required 플래그로 필수 여부 표시 (헤더 빨강 폰트) + const BOM_COLUMNS: { header: string; width: number; required: boolean }[] = [ + { header: "레벨", width: 6, required: true }, + { header: "품번", width: 18, required: true }, + { header: "품명", width: 20, required: true }, + { header: "소요량", width: 10, required: true }, + { header: "단위", width: 8, required: true }, + { header: "공정구분", width: 12, required: false }, + { header: "비고", width: 20, required: false }, + ]; + let data: Record[] = []; if (isVersionMode && bomId) { @@ -311,16 +321,45 @@ export function BomExcelUploadModal({ } } - const ws = XLSX.utils.json_to_sheet(data); - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, "BOM"); - ws["!cols"] = [ - { wch: 6 }, { wch: 18 }, { wch: 20 }, { wch: 10 }, - { wch: 8 }, { wch: 12 }, { wch: 20 }, - ]; + const ExcelJS = (await import("exceljs")).default; + const workbook = new ExcelJS.Workbook(); + const ws = workbook.addWorksheet("BOM"); + + BOM_COLUMNS.forEach((col, i) => { + ws.getColumn(i + 1).width = col.width; + }); + + // 헤더: 필수 컬럼은 빨강 폰트 + const headerRow = ws.addRow(BOM_COLUMNS.map((c) => c.header)); + headerRow.height = 24; + headerRow.eachCell((cell, colNumber) => { + const col = BOM_COLUMNS[colNumber - 1]; + cell.font = { + bold: true, + size: 10, + color: { argb: col.required ? "FFDC2626" : "FF1E293B" }, + }; + cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFF1F5F9" } }; + cell.border = { bottom: { style: "medium", color: { argb: "FF94A3B8" } } }; + cell.alignment = { vertical: "middle", horizontal: "center" }; + }); + + // 데이터 행 추가 + for (const row of data) { + ws.addRow(BOM_COLUMNS.map((c) => row[c.header] ?? "")); + } + + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = bomName ? `BOM_${bomName}.xlsx` : "BOM_template.xlsx"; + a.click(); + URL.revokeObjectURL(url); - const filename = bomName ? `BOM_${bomName}.xlsx` : "BOM_template.xlsx"; - XLSX.writeFile(wb, filename); toast.success("템플릿 다운로드 완료"); } catch (err: any) { toast.error(`다운로드 실패: ${err.message}`);