'use client'; /** * 자재투입 모달 (로트 선택 기반) * * 로트를 체크박스로 선택하면 필요수량만큼 FIFO 순서로 자동 배분합니다. * 같은 품목의 여러 로트를 조합하여 필요수량을 충족시킬 수 있습니다. */ import { useState, useEffect, useCallback, useMemo } from 'react'; import { Loader2, Check } 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 [isLoading, setIsLoading] = useState(false); const [isSubmitting, setIsSubmitting] = useState(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, })); // 로트 키 생성 const getLotKey = (material: MaterialForInput) => String(material.stockLotId ?? `item-${material.itemId}`); // 품목별 그룹핑 const materialGroups: MaterialGroup[] = useMemo(() => { // dynamic_bom 항목은 (itemId, workOrderItemId) 쌍으로 그룹핑 const groups = new Map(); for (const m of materials) { const groupKey = m.workOrderItemId ? `${m.itemId}_${m.workOrderItemId}` : String(m.itemId); const existing = groups.get(groupKey) || []; existing.push(m); groups.set(groupKey, existing); } 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, }; }); }, [materials]); // 선택된 로트에 FIFO 순서로 자동 배분 계산 const allocations = useMemo(() => { const result = new Map(); for (const group of materialGroups) { let remaining = group.effectiveRequiredQty; 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); result.set(lotKey, alloc); remaining -= alloc; } } } return result; }, [materialGroups, selectedLotKeys]); // 전체 배정 완료 여부 const allGroupsFulfilled = useMemo(() => { if (materialGroups.length === 0) return false; return materialGroups.every((group) => { const allocated = group.lots.reduce( (sum, lot) => sum + (allocations.get(getLotKey(lot)) || 0), 0 ); return group.effectiveRequiredQty <= 0 || allocated >= group.effectiveRequiredQty; }); }, [materialGroups, allocations]); // 배정된 항목 존재 여부 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); } else { next.add(lotKey); } return next; }); }, []); // API로 자재 목록 로드 const loadMaterials = useCallback(async () => { if (!order) return; setIsLoading(true); try { // 목업 아이템인 경우 목업 자재 데이터 사용 if (order.id.startsWith('mock-')) { setMaterials(MOCK_MATERIALS); setIsLoading(false); 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); } else { toast.error(result.error || '자재 목록 조회에 실패했습니다.'); } } else { // 전체 작업지시 기준 조회 const result = await getMaterialsForWorkOrder(order.id); if (result.success) { setMaterials(result.data); } 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()); } }, [open, order, loadMaterials]); // 투입 등록 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, qty: allocQty, }; if (material.workOrderItemId) { input.work_order_item_id = material.workOrderItemId; } inputs.push(input); } } } 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) { const simpleInputs = inputs.map(({ stock_lot_id, qty }) => ({ stock_lot_id, qty })); result = await registerMaterialInputForItem(order.id, targetItemId, simpleInputs); } else { result = await registerMaterialInput(order.id, inputs); } if (result.success) { toast.success('자재 투입이 등록되었습니다.'); if (onSaveMaterials) { const savedList: MaterialInput[] = []; for (const [lotKey, allocQty] of allocations) { if (allocQty > 0) { const material = materials.find((m) => getLotKey(m) === lotKey); if (material) { savedList.push({ id: String(material.stockLotId), lotNo: material.lotNo || '', materialName: material.materialName, quantity: material.lotAvailableQty, unit: material.unit, inputQuantity: allocQty, }); } } } 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()); onOpenChange(false); }; if (!order) return null; return ( {/* 헤더 */} 자재 투입{workOrderItemName ? ` - ${workOrderItemName}` : ''}

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

{/* 자재 목록 */} {isLoading ? ( ) : materials.length === 0 ? (
이 공정에 배정된 자재가 없습니다.
) : (
{materialGroups.map((group) => { const groupAllocated = group.lots.reduce( (sum, lot) => sum + (allocations.get(getLotKey(lot)) || 0), 0 ); const isAlreadyComplete = group.effectiveRequiredQty <= 0; const isFulfilled = isAlreadyComplete || groupAllocated >= group.effectiveRequiredQty; return (
{/* 품목 그룹 헤더 */}
{group.category && ( {group.category === 'guideRail' ? '가이드레일' : group.category === 'bottomBar' ? '하단마감재' : group.category === 'shutterBox' ? '셔터박스' : group.category === 'smokeBarrier' ? '연기차단재' : group.category} )} {group.materialName} {group.materialCode && ( {group.materialCode} )}
{group.alreadyInputted > 0 ? ( <> 필요:{' '} {fmtQty(group.effectiveRequiredQty)} {' '} {group.unit} (기투입: {fmtQty(group.alreadyInputted)}) ) : ( <> 필요:{' '} {fmtQty(group.requiredQty)} {' '} {group.unit} )} 0 ? 'bg-amber-100 text-amber-700' : 'bg-gray-100 text-gray-500' }`} > {isAlreadyComplete ? ( <> 투입 완료 ) : isFulfilled ? ( <> 배정 완료 ) : ( `${fmtQty(groupAllocated)} / ${fmtQty(group.effectiveRequiredQty)}` )}
{/* 로트 테이블 */} 선택 로트번호 가용수량 단위 배정수량 {group.lots.map((lot, idx) => { const lotKey = getLotKey(lot); const hasStock = lot.stockLotId !== null; const isSelected = selectedLotKeys.has(lotKey); const allocated = allocations.get(lotKey) || 0; const canSelect = hasStock && !isAlreadyComplete && (!isFulfilled || isSelected); return ( 0 ? 'bg-blue-50/50' : '' } > {hasStock ? ( ) : null} {lot.lotNo || ( 재고 없음 )} {hasStock ? ( fmtQty(lot.lotAvailableQty) ) : ( 0 )} {lot.unit} {allocated > 0 ? ( {fmtQty(allocated)} ) : ( - )} ); })}
); })}
)} {/* 버튼 영역 */}
); }