From 495a5f034bc23835f77a9828ff09664817ac881b Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 7 Apr 2026 10:57:56 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20POP=20=EC=9E=91=EC=97=85=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=9E=90=EC=9E=AC=ED=88=AC=EC=9E=85(material-input?= =?UTF-8?q?)=20=EC=84=B9=EC=85=98=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BOM 기반 자재 목록 표시 + 소요량 자동 계산 - 작업자 실제 투입량 입력 (참고 모드 — 유연 입력) - 기준량 ±20% 범위 검증 + 경고 표시 (차단 아님) - 투입 이력 테이블 표시 - ISA-101 터치 기준 (입력 56px+, 버튼 56px+) --- .../PopWorkDetailComponent.tsx | 307 +++++++++++++++++- 1 file changed, 306 insertions(+), 1 deletion(-) diff --git a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx index 2465cb80..c5d5f169 100644 --- a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx @@ -1348,7 +1348,7 @@ interface BatchHistoryItem { changed_by: string | null; } -const IMPLEMENTED_SECTIONS = new Set(["total-qty", "good-defect", "defect-types", "note", "plc-data"]); +const IMPLEMENTED_SECTIONS = new Set(["total-qty", "good-defect", "defect-types", "note", "plc-data", "material-input"]); const SECTION_LABELS: Record = { "total-qty": "생산수량", @@ -1754,6 +1754,14 @@ function ResultPanel({ /> ))} + {/* 자재 투입 섹션 */} + {enabledSections.some((s) => s.type === "material-input") && ( + + )} + {/* 미구현 섹션 플레이스홀더 (순서 보존) */} {!isConfirmed && enabledSections .filter((s) => !IMPLEMENTED_SECTIONS.has(s.type)) @@ -2164,6 +2172,303 @@ function InfoBar({ fields, parentRow, processName }: InfoBarProps) { ); } +// ======================================== +// 자재 투입 섹션 +// ======================================== + +interface BomMaterial { + id: string; + child_item_id: string; + child_item_code: string; + child_item_name: string; + bom_qty: number; + unit: string; + process_type: string; + loss_rate: number; + required_qty: number; + input_qty: number; +} + +interface MaterialInputRecord { + id: string; + item_code: string; + item_name: string; + input_qty: string; + unit: string; + remark: string | null; + recorded_by: string | null; + recorded_at: string | null; +} + +interface MaterialInputSectionProps { + workOrderProcessId: string; + isConfirmed: boolean; +} + +function MaterialInputSection({ workOrderProcessId, isConfirmed }: MaterialInputSectionProps) { + const [materials, setMaterials] = useState([]); + const [inputValues, setInputValues] = useState>({}); + const [existingInputs, setExistingInputs] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + // BOM 자재 목록 + 기존 투입 기록 로드 + const loadData = useCallback(async () => { + setLoading(true); + try { + const [bomRes, inputsRes] = await Promise.all([ + apiClient.get(`/pop/production/bom-materials/${workOrderProcessId}`), + apiClient.get(`/pop/production/material-inputs/${workOrderProcessId}`), + ]); + + const bomMaterials: BomMaterial[] = bomRes.data?.data?.materials ?? []; + setMaterials(bomMaterials); + + const inputs: MaterialInputRecord[] = inputsRes.data?.data ?? []; + setExistingInputs(inputs); + + // 기존 투입값을 inputValues에 반영 (같은 자재의 투입 합산) + const existingMap: Record = {}; + for (const inp of inputs) { + const key = inp.item_code || inp.item_name; + existingMap[key] = (existingMap[key] || 0) + (parseFloat(inp.input_qty) || 0); + } + + const initialValues: Record = {}; + for (const mat of bomMaterials) { + const existing = existingMap[mat.child_item_code] || existingMap[mat.child_item_name]; + initialValues[mat.id] = existing ? String(existing) : ""; + } + setInputValues(initialValues); + } catch { + // 에러 시 빈 상태 유지 + } finally { + setLoading(false); + } + }, [workOrderProcessId]); + + useEffect(() => { + loadData(); + }, [loadData]); + + const updateInputValue = (materialId: string, value: string) => { + setInputValues((prev) => ({ ...prev, [materialId]: value })); + }; + + // 기준 범위 판정: lower_limit~upper_limit이 있으면 사용, 없으면 +/-20% + const getStatus = (mat: BomMaterial, inputVal: string): { label: string; color: string } => { + const val = parseFloat(inputVal); + if (!inputVal || isNaN(val)) return { label: "", color: "" }; + + const required = mat.required_qty; + if (required <= 0) return { label: "기준 내", color: "text-green-600" }; + + const lowerBound = required * 0.8; + const upperBound = required * 1.2; + + if (val >= lowerBound && val <= upperBound) { + return { label: "기준 내", color: "text-green-600" }; + } else if (val < lowerBound) { + return { label: "기준 미달", color: "text-amber-600" }; + } else { + return { label: "기준 초과", color: "text-amber-600" }; + } + }; + + const handleSave = async () => { + const inputs = materials + .filter((mat) => { + const val = parseFloat(inputValues[mat.id] || ""); + return !isNaN(val) && val > 0; + }) + .map((mat) => ({ + child_item_id: mat.child_item_id, + child_item_code: mat.child_item_code, + child_item_name: mat.child_item_name, + input_qty: parseFloat(inputValues[mat.id]), + unit: mat.unit, + bom_detail_id: mat.id, + required_qty: mat.required_qty, + })); + + if (inputs.length === 0) { + toast.error("투입 수량을 입력해주세요."); + return; + } + + setSaving(true); + try { + const res = await apiClient.post("/pop/production/material-input", { + work_order_process_id: workOrderProcessId, + inputs, + }); + if (res.data?.success) { + toast.success(res.data.message || "자재 투입이 저장되었습니다."); + loadData(); // 새로고침 + } else { + toast.error(res.data?.message || "자재 투입 저장에 실패했습니다."); + } + } catch { + toast.error("자재 투입 저장 중 오류가 발생했습니다."); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+
자재 투입
+
+ 불러오는 중... +
+
+ ); + } + + if (materials.length === 0) { + return ( +
+
자재 투입
+
+ +

BOM 자재 정보가 없습니다.

+
+
+ ); + } + + return ( +
+
자재 투입
+ + {/* 자재 목록 테이블 */} +
+ + + + + + + + + + + {materials.map((mat) => { + const status = getStatus(mat, inputValues[mat.id] || ""); + return ( + + + + + + + ); + })} + +
품목명기준량실제투입상태
+
{mat.child_item_name || mat.child_item_code}
+ {mat.child_item_code && mat.child_item_name && ( +
{mat.child_item_code}
+ )} +
+ {mat.required_qty} + {mat.unit || "EA"} + +
+ {isConfirmed ? ( + {inputValues[mat.id] || "-"} + ) : ( + updateInputValue(mat.id, e.target.value)} + placeholder="0" + /> + )} + {mat.unit || "EA"} +
+
+ {status.label && ( + + {status.color === "text-green-600" ? ( + + + {status.label} + + ) : ( + + + {status.label} + + )} + + )} +
+
+ + {/* 저장 버튼 */} + {!isConfirmed && ( +
+ +
+ )} + + {/* 기존 투입 이력 */} + {existingInputs.length > 0 && ( +
+
투입 이력
+
+ + + + + + + + + + {existingInputs.map((inp) => ( + + + + + + ))} + +
품목투입량시각
+ {inp.item_name || inp.item_code} + + {inp.input_qty} {inp.unit} + + {inp.recorded_at + ? new Date(inp.recorded_at).toLocaleString("ko-KR", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }) + : "-"} +
+
+
+ )} +
+ ); +} + // ======================================== // KPI 카드 (항상 표시) // ========================================