From 0166601be8ec428437f873a82348d04052272048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Wed, 4 Mar 2026 10:36:50 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20[production]=20=EC=9E=90=EC=9E=AC?= =?UTF-8?q?=ED=88=AC=EC=9E=85=20=EB=AA=A8=EB=8B=AC=20=E2=80=94=20=EB=8F=99?= =?UTF-8?q?=EC=9D=BC=20=EC=9E=90=EC=9E=AC=20=EB=8B=A4=EC=A4=91=20BOM=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9=20LOT=20=EB=8F=85=EB=A6=BD=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getLotKey에 groupKey 포함하여 그룹별 LOT 선택/배정 독립 처리 - physicalUsed 맵으로 물리LOT 교차그룹 가용량 추적 - handleAutoFill FIFO 자동입력 (교차그룹 가용량 고려) - handleSubmit 그룹별 개별 엔트리 전송 (bom_group_key 포함, replace 모드) - 기투입 LOT 자동 선택 및 배지 표시, 수량 수동 편집 input - allGroupsFulfilled 조건으로 투입 버튼 활성화 제어 - actions.ts: lotInputtedQty 필드 + bom_group_key/replace 파라미터 추가 --- .../WorkerScreen/MaterialInputModal.tsx | 325 +++++++++++++----- .../production/WorkerScreen/actions.ts | 9 +- 2 files changed, 252 insertions(+), 82 deletions(-) diff --git a/src/components/production/WorkerScreen/MaterialInputModal.tsx b/src/components/production/WorkerScreen/MaterialInputModal.tsx index 1d026227..9240750b 100644 --- a/src/components/production/WorkerScreen/MaterialInputModal.tsx +++ b/src/components/production/WorkerScreen/MaterialInputModal.tsx @@ -5,10 +5,16 @@ * * 로트를 체크박스로 선택하면 필요수량만큼 FIFO 순서로 자동 배분합니다. * 같은 품목의 여러 로트를 조합하여 필요수량을 충족시킬 수 있습니다. + * + * 기능: + * - 기투입 LOT 표시 및 수정 (replace 모드) + * - 선택완료 배지 + * - 필요수량 배정 완료 시에만 투입 가능 + * - FIFO 자동입력 버튼 */ -import { useState, useEffect, useCallback, useMemo } from 'react'; -import { Loader2, Check } from 'lucide-react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { Loader2, Check, Zap } from 'lucide-react'; import { ContentSkeleton } from '@/components/ui/skeleton'; import { Dialog, @@ -78,8 +84,10 @@ export function MaterialInputModal({ }: MaterialInputModalProps) { const [materials, setMaterials] = useState([]); const [selectedLotKeys, setSelectedLotKeys] = useState>(new Set()); + const [manualAllocations, setManualAllocations] = useState>(new Map()); const [isLoading, setIsLoading] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); + const materialsLoadedRef = useRef(false); // 목업 자재 데이터 (개발용) const MOCK_MATERIALS: MaterialForInput[] = Array.from({ length: 5 }, (_, i) => ({ @@ -95,9 +103,17 @@ export function MaterialInputModal({ fifoRank: i + 1, })); - // 로트 키 생성 - const getLotKey = (material: MaterialForInput) => - String(material.stockLotId ?? `item-${material.itemId}`); + // 로트 키 생성 (그룹별 독립 — 같은 LOT가 여러 그룹에 있어도 구분) + const getLotKey = useCallback((material: MaterialForInput, groupKey: string) => + `${String(material.stockLotId ?? `item-${material.itemId}`)}__${groupKey}`, []); + + // 기투입 LOT 존재 여부 + const hasPreInputted = useMemo(() => { + return materials.some(m => { + const itemInput = m as unknown as MaterialForItemInput; + return (itemInput.lotInputtedQty ?? 0) > 0; + }); + }, [materials]); // 품목별 그룹핑 (BOM 엔트리별 고유키 사용 — 같은 item_id라도 category+partType 다르면 별도 그룹) const materialGroups: MaterialGroup[] = useMemo(() => { @@ -142,34 +158,64 @@ export function MaterialInputModal({ }); }, [materials]); - // 선택된 로트에 FIFO 순서로 자동 배분 계산 + // 그룹별 목표 수량 (기투입 있으면 전체 필요수량, 없으면 남은 필요수량) + const getGroupTargetQty = useCallback((group: MaterialGroup) => { + return group.alreadyInputted > 0 ? group.requiredQty : group.effectiveRequiredQty; + }, []); + + // 배정 수량 계산 (manual 우선 → 나머지 FIFO 자동배분, 물리LOT 교차그룹 추적) const allocations = useMemo(() => { const result = new Map(); + const physicalUsed = new Map(); // stockLotId → 그룹 간 누적 사용량 + for (const group of materialGroups) { - let remaining = group.effectiveRequiredQty; + const targetQty = getGroupTargetQty(group); + let remaining = targetQty; + + // 1차: manual allocations 적용 for (const lot of group.lots) { - const lotKey = getLotKey(lot); - if (selectedLotKeys.has(lotKey) && lot.stockLotId && remaining > 0) { - const alloc = Math.min(lot.lotAvailableQty, remaining); + const lotKey = getLotKey(lot, group.groupKey); + if (selectedLotKeys.has(lotKey) && lot.stockLotId && manualAllocations.has(lotKey)) { + const val = manualAllocations.get(lotKey)!; + result.set(lotKey, val); + remaining -= val; + physicalUsed.set(lot.stockLotId, (physicalUsed.get(lot.stockLotId) || 0) + val); + } + } + + // 2차: non-manual 선택 로트 FIFO 자동배분 (물리LOT 가용량 고려) + for (const lot of group.lots) { + const lotKey = getLotKey(lot, group.groupKey); + if (selectedLotKeys.has(lotKey) && lot.stockLotId && !manualAllocations.has(lotKey)) { + const itemInput = lot as unknown as MaterialForItemInput; + const maxAvail = lot.lotAvailableQty + (itemInput.lotInputtedQty ?? 0); + const used = physicalUsed.get(lot.stockLotId) || 0; + const effectiveAvail = Math.max(0, maxAvail - used); + const alloc = remaining > 0 ? Math.min(effectiveAvail, remaining) : 0; result.set(lotKey, alloc); remaining -= alloc; + if (alloc > 0) { + physicalUsed.set(lot.stockLotId, used + alloc); + } } } } return result; - }, [materialGroups, selectedLotKeys]); + }, [materialGroups, selectedLotKeys, manualAllocations, getLotKey, getGroupTargetQty]); // 전체 배정 완료 여부 const allGroupsFulfilled = useMemo(() => { if (materialGroups.length === 0) return false; return materialGroups.every((group) => { + const targetQty = getGroupTargetQty(group); + if (targetQty <= 0) return true; const allocated = group.lots.reduce( - (sum, lot) => sum + (allocations.get(getLotKey(lot)) || 0), + (sum, lot) => sum + (allocations.get(getLotKey(lot, group.groupKey)) || 0), 0 ); - return group.effectiveRequiredQty <= 0 || allocated >= group.effectiveRequiredQty; + return allocated >= targetQty; }); - }, [materialGroups, allocations]); + }, [materialGroups, allocations, getLotKey, getGroupTargetQty]); // 배정된 항목 존재 여부 const hasAnyAllocation = useMemo(() => { @@ -182,6 +228,11 @@ export function MaterialInputModal({ const next = new Set(prev); if (next.has(lotKey)) { next.delete(lotKey); + setManualAllocations(prev => { + const n = new Map(prev); + n.delete(lotKey); + return n; + }); } else { next.add(lotKey); } @@ -189,16 +240,60 @@ export function MaterialInputModal({ }); }, []); + // 수량 수동 변경 + const handleAllocationChange = useCallback((lotKey: string, value: number, maxAvailable: number) => { + const clamped = Math.max(0, Math.min(value, maxAvailable)); + setManualAllocations(prev => { + const next = new Map(prev); + next.set(lotKey, clamped); + return next; + }); + }, []); + + // FIFO 자동입력 (물리LOT 교차그룹 가용량 추적) + const handleAutoFill = useCallback(() => { + const newSelected = new Set(); + const newAllocations = new Map(); + const physicalUsed = new Map(); // stockLotId → 그룹 간 누적 사용량 + + for (const group of materialGroups) { + const targetQty = getGroupTargetQty(group); + if (targetQty <= 0) continue; + + let remaining = targetQty; + for (const lot of group.lots) { + if (!lot.stockLotId || remaining <= 0) continue; + const lotKey = getLotKey(lot, group.groupKey); + const itemInput = lot as unknown as MaterialForItemInput; + const maxAvail = lot.lotAvailableQty + (itemInput.lotInputtedQty ?? 0); + const used = physicalUsed.get(lot.stockLotId) || 0; + const effectiveAvail = Math.max(0, maxAvail - used); + const alloc = Math.min(effectiveAvail, remaining); + if (alloc > 0) { + newSelected.add(lotKey); + newAllocations.set(lotKey, alloc); + remaining -= alloc; + physicalUsed.set(lot.stockLotId, used + alloc); + } + } + } + + setSelectedLotKeys(newSelected); + setManualAllocations(newAllocations); + }, [materialGroups, getLotKey, getGroupTargetQty]); + // API로 자재 목록 로드 const loadMaterials = useCallback(async () => { if (!order) return; setIsLoading(true); + materialsLoadedRef.current = false; try { // 목업 아이템인 경우 목업 자재 데이터 사용 if (order.id.startsWith('mock-')) { setMaterials(MOCK_MATERIALS); setIsLoading(false); + materialsLoadedRef.current = true; return; } @@ -214,6 +309,7 @@ export function MaterialInputModal({ workOrderItemId: m.workOrderItemId || itemId, })); setMaterials(tagged); + materialsLoadedRef.current = true; } else { toast.error(result.error || '자재 목록 조회에 실패했습니다.'); } @@ -222,6 +318,7 @@ export function MaterialInputModal({ const result = await getMaterialsForWorkOrder(order.id); if (result.success) { setMaterials(result.data); + materialsLoadedRef.current = true; } else { toast.error(result.error || '자재 목록 조회에 실패했습니다.'); } @@ -240,27 +337,53 @@ export function MaterialInputModal({ if (open && order) { loadMaterials(); setSelectedLotKeys(new Set()); + setManualAllocations(new Map()); } }, [open, order, loadMaterials]); + // 자재 로드 후 기투입 LOT 자동 선택 (그룹별 독립 처리) + useEffect(() => { + if (!materialsLoadedRef.current || materials.length === 0 || materialGroups.length === 0) return; + + const preSelected = new Set(); + const preAllocations = new Map(); + + for (const group of materialGroups) { + for (const m of group.lots) { + const itemInput = m as unknown as MaterialForItemInput; + const lotInputted = itemInput.lotInputtedQty ?? 0; + if (lotInputted > 0 && m.stockLotId) { + const lotKey = getLotKey(m, group.groupKey); + preSelected.add(lotKey); + preAllocations.set(lotKey, lotInputted); + } + } + } + + if (preSelected.size > 0) { + setSelectedLotKeys(prev => new Set([...prev, ...preSelected])); + setManualAllocations(prev => new Map([...prev, ...preAllocations])); + } + // 한 번만 실행하도록 ref 초기화 + materialsLoadedRef.current = false; + }, [materials, materialGroups, getLotKey]); + // 투입 등록 const handleSubmit = async () => { if (!order) return; - // 배분된 로트만 추출 (dynamic_bom이면 work_order_item_id 포함) - const inputs: { stock_lot_id: number; qty: number; work_order_item_id?: number }[] = []; - for (const [lotKey, allocQty] of allocations) { - if (allocQty > 0) { - const material = materials.find((m) => getLotKey(m) === lotKey); - if (material?.stockLotId) { - const input: { stock_lot_id: number; qty: number; work_order_item_id?: number } = { - stock_lot_id: material.stockLotId, + // 배분된 로트를 그룹별 개별 엔트리로 추출 (bom_group_key 포함) + const inputs: { stock_lot_id: number; qty: number; bom_group_key: string }[] = []; + for (const group of materialGroups) { + for (const lot of group.lots) { + const lotKey = getLotKey(lot, group.groupKey); + const allocQty = allocations.get(lotKey) || 0; + if (allocQty > 0 && lot.stockLotId) { + inputs.push({ + stock_lot_id: lot.stockLotId, qty: allocQty, - }; - if (material.workOrderItemId) { - input.work_order_item_id = material.workOrderItemId; - } - inputs.push(input); + bom_group_key: group.groupKey, + }); } } } @@ -278,8 +401,8 @@ export function MaterialInputModal({ ?? (workOrderItemIds && workOrderItemIds.length > 0 ? workOrderItemIds[0] : null); if (targetItemId) { - const simpleInputs = inputs.map(({ stock_lot_id, qty }) => ({ stock_lot_id, qty })); - result = await registerMaterialInputForItem(order.id, targetItemId, simpleInputs); + // 기투입 LOT 있으면 replace 모드 (기존 투입 삭제 후 재등록) + result = await registerMaterialInputForItem(order.id, targetItemId, inputs, hasPreInputted); } else { result = await registerMaterialInput(order.id, inputs); } @@ -288,18 +411,26 @@ export function MaterialInputModal({ toast.success('자재 투입이 등록되었습니다.'); if (onSaveMaterials) { + // 표시용: 같은 LOT는 합산 (자재투입목록 UI) + const lotTotals = new Map(); + for (const inp of inputs) { + lotTotals.set(inp.stock_lot_id, (lotTotals.get(inp.stock_lot_id) || 0) + inp.qty); + } const savedList: MaterialInput[] = []; - for (const [lotKey, allocQty] of allocations) { - if (allocQty > 0) { - const material = materials.find((m) => getLotKey(m) === lotKey); - if (material) { + const processedLotIds = new Set(); + for (const group of materialGroups) { + for (const lot of group.lots) { + if (!lot.stockLotId || processedLotIds.has(lot.stockLotId)) continue; + const totalQty = lotTotals.get(lot.stockLotId) || 0; + if (totalQty > 0) { + processedLotIds.add(lot.stockLotId); savedList.push({ - id: String(material.stockLotId), - lotNo: material.lotNo || '', - materialName: material.materialName, - quantity: material.lotAvailableQty, - unit: material.unit, - inputQuantity: allocQty, + id: String(lot.stockLotId), + lotNo: lot.lotNo || '', + materialName: lot.materialName, + quantity: lot.lotAvailableQty, + unit: lot.unit, + inputQuantity: totalQty, }); } } @@ -326,6 +457,7 @@ export function MaterialInputModal({ const resetAndClose = () => { setSelectedLotKeys(new Set()); + setManualAllocations(new Map()); onOpenChange(false); }; @@ -339,9 +471,20 @@ export function MaterialInputModal({ 자재 투입{workOrderItemName ? ` - ${workOrderItemName}` : ''} -

- 로트를 선택하면 필요수량만큼 자동 배분됩니다. -

+
+

+ 로트를 선택하면 필요수량만큼 자동 배분됩니다. +

+ {!isLoading && materials.length > 0 && ( + + )} +
@@ -363,12 +506,13 @@ export function MaterialInputModal({ const circledNum = categoryIndex >= 0 && categoryIndex < circledNumbers.length ? circledNumbers[categoryIndex] : ''; + const targetQty = getGroupTargetQty(group); const groupAllocated = group.lots.reduce( - (sum, lot) => sum + (allocations.get(getLotKey(lot)) || 0), + (sum, lot) => sum + (allocations.get(getLotKey(lot, group.groupKey)) || 0), 0 ); - const isAlreadyComplete = group.effectiveRequiredQty <= 0; - const isFulfilled = isAlreadyComplete || groupAllocated >= group.effectiveRequiredQty; + const isGroupComplete = targetQty <= 0 && group.alreadyInputted <= 0; + const isFulfilled = isGroupComplete || groupAllocated >= targetQty; return (
@@ -410,10 +554,10 @@ export function MaterialInputModal({ <> 필요:{' '} - {fmtQty(group.effectiveRequiredQty)} + {fmtQty(group.requiredQty)} {' '} {group.unit} - + (기투입: {fmtQty(group.alreadyInputted)}) @@ -429,27 +573,20 @@ export function MaterialInputModal({ 0 - ? 'bg-amber-100 text-amber-700' - : 'bg-gray-100 text-gray-500' + : groupAllocated > 0 + ? 'bg-amber-100 text-amber-700' + : 'bg-gray-100 text-gray-500' }`} > - {isAlreadyComplete ? ( - <> - - 투입 완료 - - ) : isFulfilled ? ( + {isFulfilled ? ( <> 배정 완료 ) : ( - `${fmtQty(groupAllocated)} / ${fmtQty(group.effectiveRequiredQty)}` + `${fmtQty(groupAllocated)} / ${fmtQty(targetQty)}` )}
@@ -460,7 +597,7 @@ export function MaterialInputModal({ - 선택 + 선택 로트번호 가용수량 단위 @@ -469,20 +606,24 @@ export function MaterialInputModal({ {group.lots.map((lot, idx) => { - const lotKey = getLotKey(lot); + const lotKey = getLotKey(lot, group.groupKey); const hasStock = lot.stockLotId !== null; const isSelected = selectedLotKeys.has(lotKey); const allocated = allocations.get(lotKey) || 0; - const canSelect = hasStock && !isAlreadyComplete && (!isFulfilled || isSelected); + const itemInput = lot as unknown as MaterialForItemInput; + const lotInputted = itemInput.lotInputtedQty ?? 0; + const isPreInputted = lotInputted > 0; + // 가용수량 = 현재 가용 + 기투입분 (replace 시 복원되므로) + const effectiveAvailable = lot.lotAvailableQty + lotInputted; + const canSelect = hasStock && (!isFulfilled || isSelected); return ( 0 - ? 'bg-blue-50/50' - : '' - } + className={cn( + isSelected && allocated > 0 ? 'bg-blue-50/50' : '', + isPreInputted && isSelected ? 'bg-blue-50/70' : '' + )} > {hasStock ? ( @@ -490,7 +631,7 @@ export function MaterialInputModal({ onClick={() => toggleLot(lotKey)} disabled={!canSelect} className={cn( - 'min-w-[56px] px-3 py-1.5 rounded-lg text-xs font-semibold transition-all', + 'min-w-[64px] px-3 py-1.5 rounded-lg text-xs font-semibold transition-all', isSelected ? 'bg-blue-600 text-white shadow-sm' : canSelect @@ -498,20 +639,34 @@ export function MaterialInputModal({ : 'bg-gray-100 text-gray-400 cursor-not-allowed' )} > - {isSelected ? '선택됨' : '선택'} + {isSelected ? '선택완료' : '선택'} ) : null} - {lot.lotNo || ( - - 재고 없음 - - )} +
+ {lot.lotNo || ( + + 재고 없음 + + )} + {isPreInputted && ( + + 기투입 + + )} +
{hasStock ? ( - fmtQty(lot.lotAvailableQty) + isPreInputted ? ( + + {fmtQty(lot.lotAvailableQty)} + (+{fmtQty(lotInputted)}) + + ) : ( + fmtQty(lot.lotAvailableQty) + ) ) : ( 0 )} @@ -520,7 +675,19 @@ export function MaterialInputModal({ {lot.unit} - {allocated > 0 ? ( + {isSelected && hasStock ? ( + { + const val = parseFloat(e.target.value) || 0; + handleAllocationChange(lotKey, val, effectiveAvailable); + }} + className="w-20 text-center text-blue-600 font-semibold border border-blue-200 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-400 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + min={0} + max={effectiveAvailable} + /> + ) : allocated > 0 ? ( {fmtQty(allocated)} @@ -552,7 +719,7 @@ export function MaterialInputModal({