'use client'; /** * 자재투입 모달 (로트 선택 기반) * * 로트를 체크박스로 선택하면 필요수량만큼 FIFO 순서로 자동 배분합니다. * 같은 품목의 여러 로트를 조합하여 필요수량을 충족시킬 수 있습니다. * * 기능: * - 기투입 LOT 표시 및 수정 (replace 모드) * - 선택완료 배지 * - 필요수량 배정 완료 시에만 투입 가능 * - FIFO 자동입력 버튼 */ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Loader2, Check, Zap } from 'lucide-react'; import { ContentSkeleton } from '@/components/ui/skeleton'; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { toast } from 'sonner'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { getMaterialsForWorkOrder, registerMaterialInput, getMaterialsForItem, registerMaterialInputForItem, type MaterialForInput, type MaterialForItemInput } from './actions'; import type { WorkOrder } from '../ProductionDashboard/types'; import type { MaterialInput } from './types'; import { formatNumber } from '@/lib/utils/amount'; interface MaterialInputModalProps { open: boolean; onOpenChange: (open: boolean) => void; order: WorkOrder | null; workOrderItemId?: number; // 개소(작업지시품목) ID (첫 번째 item, 호환용) workOrderItemIds?: number[]; // 개소 내 모든 작업지시품목 IDs (절곡 등 복수 item) workOrderItemName?: string; // 개소명 (모달 헤더 표시용) onComplete?: () => void; isCompletionFlow?: boolean; onSaveMaterials?: (orderId: string, materials: MaterialInput[]) => void; savedMaterials?: MaterialInput[]; } interface MaterialGroup { itemId: number; groupKey: string; // 그룹 식별 키 (itemId 또는 itemId_woItemId) materialName: string; materialCode: string; requiredQty: number; effectiveRequiredQty: number; // 남은 필요수량 (이미 투입분 차감) alreadyInputted: number; // 이미 투입된 수량 unit: string; lots: MaterialForInput[]; // dynamic_bom 추가 정보 workOrderItemId?: number; lotPrefix?: string; partType?: string; category?: string; } const fmtQty = (v: number) => formatNumber(parseFloat(String(v))); export function MaterialInputModal({ open, onOpenChange, order, workOrderItemId, workOrderItemIds, workOrderItemName, onComplete, isCompletionFlow = false, onSaveMaterials, }: 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) => ({ 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, })); // 로트 키 생성 (그룹별 독립 — 같은 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(() => { const groups = new Map(); for (const m of materials) { const itemInput = m as unknown as MaterialForItemInput; const groupKey = itemInput.bomGroupKey ?? (m.workOrderItemId ? `${m.itemId}_${m.workOrderItemId}` : String(m.itemId)); const existing = groups.get(groupKey) || []; existing.push(m); groups.set(groupKey, existing); } // 작업일지와 동일한 카테고리 순서 const categoryOrder: Record = { guideRail: 0, bottomBar: 1, shutterBox: 2, smokeBarrier: 3, }; return Array.from(groups.entries()).map(([groupKey, lots]) => { const first = lots[0]; const itemInput = first as unknown as MaterialForItemInput; const alreadyInputted = itemInput.alreadyInputted ?? 0; const effectiveRequiredQty = Math.max(0, itemInput.remainingRequiredQty ?? first.requiredQty); return { itemId: first.itemId, groupKey, materialName: first.materialName, materialCode: first.materialCode, requiredQty: first.requiredQty, effectiveRequiredQty, alreadyInputted, unit: first.unit, lots: lots.sort((a, b) => a.fifoRank - b.fifoRank), workOrderItemId: first.workOrderItemId, lotPrefix: first.lotPrefix, partType: first.partType, category: first.category, }; }).sort((a, b) => { const catA = categoryOrder[a.category ?? ''] ?? 99; const catB = categoryOrder[b.category ?? ''] ?? 99; return catA - catB; }); }, [materials]); // 그룹별 목표 수량 (기투입 있으면 전체 필요수량, 없으면 남은 필요수량) 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) { const targetQty = getGroupTargetQty(group); let remaining = targetQty; // 1차: manual allocations 적용 for (const lot of group.lots) { 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, 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, group.groupKey)) || 0), 0 ); return allocated >= targetQty; }); }, [materialGroups, allocations, getLotKey, getGroupTargetQty]); // 배정된 항목 존재 여부 const hasAnyAllocation = useMemo(() => { return Array.from(allocations.values()).some((v) => v > 0); }, [allocations]); // 로트 선택/해제 const toggleLot = useCallback((lotKey: string) => { setSelectedLotKeys((prev) => { 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); } return next; }); }, []); // 수량 수동 변경 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; } // 개소 대표 아이템 1개만 조회 (dynamic_bom은 개소 내 모든 아이템에 동일하게 저장됨) const itemId = workOrderItemId ?? (workOrderItemIds && workOrderItemIds.length > 0 ? workOrderItemIds[0] : null); if (itemId) { const result = await getMaterialsForItem(order.id, itemId); if (result.success) { const tagged = result.data.map((m) => ({ ...m, workOrderItemId: m.workOrderItemId || itemId, })); setMaterials(tagged); materialsLoadedRef.current = true; } else { toast.error(result.error || '자재 목록 조회에 실패했습니다.'); } } else { // 전체 작업지시 기준 조회 const result = await getMaterialsForWorkOrder(order.id); if (result.success) { setMaterials(result.data); materialsLoadedRef.current = true; } else { toast.error(result.error || '자재 목록 조회에 실패했습니다.'); } } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[MaterialInputModal] loadMaterials error:', error); toast.error('자재 목록 로드 중 오류가 발생했습니다.'); } finally { setIsLoading(false); } }, [order, workOrderItemId, workOrderItemIds]); // 모달이 열릴 때 데이터 로드 + 선택 초기화 useEffect(() => { 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; // 배분된 로트를 그룹별 개별 엔트리로 추출 (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, bom_group_key: group.groupKey, }); } } } if (inputs.length === 0) { toast.error('투입할 로트를 선택해주세요.'); return; } setIsSubmitting(true); try { // 대표 아이템 기준 자재 투입 등록 let result: { success: boolean; error?: string }; const targetItemId = workOrderItemId ?? (workOrderItemIds && workOrderItemIds.length > 0 ? workOrderItemIds[0] : null); if (targetItemId) { // 기투입 LOT 있으면 replace 모드 (기존 투입 삭제 후 재등록) result = await registerMaterialInputForItem(order.id, targetItemId, inputs, hasPreInputted); } else { result = await registerMaterialInput(order.id, inputs); } if (result.success) { 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[] = []; 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(lot.stockLotId), lotNo: lot.lotNo || '', materialName: lot.materialName, quantity: lot.lotAvailableQty, unit: lot.unit, inputQuantity: totalQty, }); } } } onSaveMaterials(order.id, savedList); } resetAndClose(); if (isCompletionFlow && onComplete) { onComplete(); } } else { toast.error(result.error || '자재 투입 등록에 실패했습니다.'); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[MaterialInputModal] handleSubmit error:', error); toast.error('자재 투입 등록 중 오류가 발생했습니다.'); } finally { setIsSubmitting(false); } }; const resetAndClose = () => { setSelectedLotKeys(new Set()); setManualAllocations(new Map()); onOpenChange(false); }; if (!order) return null; return ( {/* 헤더 */} 자재 투입{workOrderItemName ? ` - ${workOrderItemName}` : ''}

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

{!isLoading && materials.length > 0 && ( )}
{/* 자재 목록 */} {isLoading ? ( ) : materials.length === 0 ? (
이 공정에 배정된 자재가 없습니다.
) : (
{materialGroups.map((group, groupIdx) => { // 같은 카테고리 내 순번 계산 (①②③...) const categoryIndex = group.category ? materialGroups.slice(0, groupIdx).filter(g => g.category === group.category).length : -1; const circledNumbers = ['①','②','③','④','⑤','⑥','⑦','⑧','⑨','⑩']; 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, group.groupKey)) || 0), 0 ); const isGroupComplete = targetQty <= 0 && group.alreadyInputted <= 0; const isFulfilled = isGroupComplete || groupAllocated >= targetQty; return (
{/* 품목 그룹 헤더 */}
{group.category && ( {group.category === 'guideRail' ? '가이드레일' : group.category === 'bottomBar' ? '하단마감재' : group.category === 'shutterBox' ? '셔터박스' : group.category === 'smokeBarrier' ? '연기차단재' : group.category} )} {group.partType && ( {circledNum}{group.partType} )} {group.materialName} {group.materialCode && ( {group.materialCode} )}
{group.alreadyInputted > 0 ? ( <> 필요:{' '} {fmtQty(group.requiredQty)} {' '} {group.unit} (기투입: {fmtQty(group.alreadyInputted)}) ) : ( <> 필요:{' '} {fmtQty(group.requiredQty)} {' '} {group.unit} )} 0 ? 'bg-amber-100 text-amber-700' : 'bg-gray-100 text-gray-500' }`} > {isFulfilled ? ( <> 배정 완료 ) : ( `${fmtQty(groupAllocated)} / ${fmtQty(targetQty)}` )}
{/* 로트 테이블 */}
선택 로트번호 가용수량 단위 배정수량 {group.lots.map((lot, idx) => { const lotKey = getLotKey(lot, group.groupKey); const hasStock = lot.stockLotId !== null; const isSelected = selectedLotKeys.has(lotKey); const allocated = allocations.get(lotKey) || 0; 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' : '', isPreInputted && isSelected ? 'bg-blue-50/70' : '' )} > {hasStock ? ( ) : null}
{lot.lotNo || ( 재고 없음 )} {isPreInputted && ( 기투입 )}
{hasStock ? ( isPreInputted ? ( {fmtQty(lot.lotAvailableQty)} (+{fmtQty(lotInputted)}) ) : ( fmtQty(lot.lotAvailableQty) ) ) : ( 0 )} {lot.unit} {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)} ) : ( - )}
); })}
); })}
)} {/* 버튼 영역 */}
); }