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}`);