From 1fca5ed477ff33b95fc58d1e1f5c3a649be2b735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 7 Feb 2026 05:06:34 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20[=EC=9E=90=EC=9E=AC=ED=88=AC=EC=9E=85]?= =?UTF-8?q?=20=EC=9E=85=EA=B3=A0=20=EB=A1=9C=ED=8A=B8=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EB=B0=8F=20=EB=A1=9C=ED=8A=B8=EB=B3=84=20?= =?UTF-8?q?=EC=88=98=EB=9F=89=20=ED=88=AC=EC=9E=85=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MaterialForInput 타입: stockLotId, lotNo, lotAvailableQty 추가 - 로트번호 컬럼에 실제 입고 로트번호(lot_no) 표시 - 수량 컬럼을 가용수량(lotAvailableQty)으로 변경 - 가용수량 초과 검증 추가 - registerMaterialInput: stock_lot_id+qty 로트별 투입 방식으로 변경 --- .../WorkerScreen/MaterialInputModal.tsx | 143 +++++++++++------- .../production/WorkerScreen/actions.ts | 34 +++-- 2 files changed, 110 insertions(+), 67 deletions(-) diff --git a/src/components/production/WorkerScreen/MaterialInputModal.tsx b/src/components/production/WorkerScreen/MaterialInputModal.tsx index d27e8eb3..bb1dab84 100644 --- a/src/components/production/WorkerScreen/MaterialInputModal.tsx +++ b/src/components/production/WorkerScreen/MaterialInputModal.tsx @@ -1,10 +1,10 @@ 'use client'; /** - * 자재투입 모달 (기획서 기반) + * 자재투입 모달 (로트 기반) * - * 기획서 변경: BOM 체크박스 → 투입수량 입력 테이블 - * 컬럼: 로트번호 | 품목명 | 수량 | 단위 | 투입 수량 (input, 숫자만) + * 입고관리에서 생성된 실제 로트번호 기준으로 자재를 표시합니다. + * 컬럼: 로트번호 | 품목명 | 가용수량 | 단위 | 투입 수량 (input, 숫자만) * 하단: 취소 / 투입 */ @@ -56,13 +56,17 @@ export function MaterialInputModal({ const [isLoading, setIsLoading] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - // 목업 자재 데이터 (기획서 기반 10행) - const MOCK_MATERIALS: MaterialForInput[] = Array.from({ length: 10 }, (_, i) => ({ - id: 100 + i, - materialCode: '123123', - materialName: '품목명', - unit: 'm', - currentStock: 500, + // 목업 자재 데이터 (개발용) + const MOCK_MATERIALS: MaterialForInput[] = Array.from({ length: 5 }, (_, i) => ({ + stockLotId: 100 + i, + itemId: 200 + i, + lotNo: `LOT-2026-${String(i + 1).padStart(3, '0')}`, + materialCode: `MAT-${String(i + 1).padStart(3, '0')}`, + materialName: `자재 ${i + 1}`, + specification: '', + unit: 'EA', + requiredQty: 100, + lotAvailableQty: 500 - i * 50, fifoRank: i + 1, })); @@ -77,7 +81,7 @@ export function MaterialInputModal({ setMaterials(MOCK_MATERIALS); const initialQuantities: Record = {}; MOCK_MATERIALS.forEach((m) => { - initialQuantities[String(m.id)] = ''; + initialQuantities[String(m.stockLotId)] = ''; }); setInputQuantities(initialQuantities); setIsLoading(false); @@ -87,10 +91,9 @@ export function MaterialInputModal({ const result = await getMaterialsForWorkOrder(order.id); if (result.success) { setMaterials(result.data); - // 초기 투입 수량 비우기 const initialQuantities: Record = {}; result.data.forEach((m) => { - initialQuantities[String(m.id)] = ''; + initialQuantities[String(m.stockLotId ?? `item-${m.itemId}`)] = ''; }); setInputQuantities(initialQuantities); } else { @@ -113,22 +116,26 @@ export function MaterialInputModal({ }, [open, order, loadMaterials]); // 투입 수량 변경 핸들러 (숫자만 허용) - const handleQuantityChange = (materialId: string, value: string) => { - // 숫자만 허용 + const handleQuantityChange = (key: string, value: string) => { const numericValue = value.replace(/[^0-9]/g, ''); setInputQuantities((prev) => ({ ...prev, - [materialId]: numericValue, + [key]: numericValue, })); }; + // 로트 키 생성 + const getLotKey = (material: MaterialForInput) => + String(material.stockLotId ?? `item-${material.itemId}`); + // 투입 등록 const handleSubmit = async () => { if (!order) return; - // 투입 수량이 입력된 항목 필터 + // 투입 수량이 입력된 항목 필터 (재고 있는 로트만) const materialsWithQuantity = materials.filter((m) => { - const qty = inputQuantities[String(m.id)]; + if (!m.stockLotId) return false; + const qty = inputQuantities[getLotKey(m)]; return qty && parseInt(qty) > 0; }); @@ -137,23 +144,35 @@ export function MaterialInputModal({ return; } + // 가용수량 초과 검증 + for (const m of materialsWithQuantity) { + const inputQty = parseInt(inputQuantities[getLotKey(m)] || '0'); + if (inputQty > m.lotAvailableQty) { + toast.error(`${m.lotNo}: 가용수량(${m.lotAvailableQty})을 초과할 수 없습니다.`); + return; + } + } + setIsSubmitting(true); try { - const materialIds = materialsWithQuantity.map((m) => m.id); - const result = await registerMaterialInput(order.id, materialIds); + const inputs = materialsWithQuantity.map((m) => ({ + stock_lot_id: m.stockLotId!, + qty: parseInt(inputQuantities[getLotKey(m)] || '0'), + })); + + const result = await registerMaterialInput(order.id, inputs); if (result.success) { toast.success('자재 투입이 등록되었습니다.'); - // onSaveMaterials 콜백 호출 if (onSaveMaterials) { const savedList: MaterialInput[] = materialsWithQuantity.map((m) => ({ - id: String(m.id), - lotNo: '', // API에서 가져올 필드 + id: String(m.stockLotId), + lotNo: m.lotNo || '', materialName: m.materialName, - quantity: m.currentStock, + quantity: m.lotAvailableQty, unit: m.unit, - inputQuantity: parseInt(inputQuantities[String(m.id)] || '0'), + inputQuantity: parseInt(inputQuantities[getLotKey(m)] || '0'), })); onSaveMaterials(order.id, savedList); } @@ -205,7 +224,7 @@ export function MaterialInputModal({ 로트번호 품목명 - 수량 + 가용수량 단위 투입 수량 @@ -226,40 +245,52 @@ export function MaterialInputModal({ 로트번호 품목명 - 수량 + 가용수량 단위 투입 수량 - {materials.map((material, index) => ( - - - {material.materialCode} - - - {material.materialName} - - - {material.currentStock.toLocaleString()} - - - {material.unit} - - - - handleQuantityChange(String(material.id), e.target.value) - } - className="w-20 mx-auto text-center h-8 text-sm" - /> - - - ))} + {materials.map((material, index) => { + const lotKey = getLotKey(material); + const hasStock = material.stockLotId !== null; + return ( + + + {material.lotNo || ( + 재고 없음 + )} + + + {material.materialName} + + + {hasStock ? material.lotAvailableQty.toLocaleString() : ( + 0 + )} + + + {material.unit} + + + {hasStock ? ( + + handleQuantityChange(lotKey, e.target.value) + } + className="w-20 mx-auto text-center h-8 text-sm" + /> + ) : ( + - + )} + + + ); + })} @@ -294,4 +325,4 @@ export function MaterialInputModal({ ); -} +} \ No newline at end of file diff --git a/src/components/production/WorkerScreen/actions.ts b/src/components/production/WorkerScreen/actions.ts index d815b0ef..55395ad0 100644 --- a/src/components/production/WorkerScreen/actions.ts +++ b/src/components/production/WorkerScreen/actions.ts @@ -277,13 +277,17 @@ export async function completeWorkOrder( } } -// ===== 자재 목록 조회 (BOM 기준) ===== +// ===== 자재 목록 조회 (로트 기준) ===== export interface MaterialForInput { - id: number; + stockLotId: number | null; // StockLot ID (null이면 재고 없음) + itemId: number; + lotNo: string | null; // 실제 입고 로트번호 materialCode: string; materialName: string; + specification: string; unit: string; - currentStock: number; + requiredQty: number; // 필요 수량 + lotAvailableQty: number; // 로트별 가용 수량 fifoRank: number; } @@ -330,20 +334,28 @@ export async function getMaterialsForWorkOrder( }; } - // API 응답을 MaterialForInput 형식으로 변환 + // API 응답을 MaterialForInput 형식으로 변환 (로트 단위) const materials: MaterialForInput[] = (result.data || []).map((item: { - id: number; + stock_lot_id: number | null; + item_id: number; + lot_no: string | null; material_code: string; material_name: string; + specification: string; unit: string; - current_stock: number; + required_qty: number; + lot_available_qty: number; fifo_rank: number; }) => ({ - id: item.id, + stockLotId: item.stock_lot_id, + itemId: item.item_id, + lotNo: item.lot_no, materialCode: item.material_code, materialName: item.material_name, + specification: item.specification ?? '', unit: item.unit, - currentStock: item.current_stock, + requiredQty: item.required_qty, + lotAvailableQty: item.lot_available_qty, fifoRank: item.fifo_rank, })); @@ -362,17 +374,17 @@ export async function getMaterialsForWorkOrder( } } -// ===== 자재 투입 등록 ===== +// ===== 자재 투입 등록 (로트별 수량) ===== export async function registerMaterialInput( workOrderId: string, - materialIds: number[] + inputs: { stock_lot_id: number; qty: number }[] ): Promise<{ success: boolean; error?: string }> { try { const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/material-inputs`, { method: 'POST', - body: JSON.stringify({ material_ids: materialIds }), + body: JSON.stringify({ inputs }), } );