Files
sam-react-prod/src/components/production/WorkerScreen/MaterialInputModal.tsx
김보곤 169ba3c4d2 fix: [자재투입] 재고 검색 필드 매핑 수정 + 재공품 원자재 필터
- searchStockByCode: API 응답 필드 매핑 수정 (Item 모델 code/name → itemCode/itemName)
- 재공품(WIP) 자재 투입 시 원자재(RM)만 검색되도록 item_type 필터 추가
- handleStockSearch query null 안전 처리
- 재고생산 품목코드 동적 반영 (expectedItemCode 상태 추가)
- 재고생산 목록 검색에 품목코드 포함
2026-03-22 14:01:57 +09:00

1007 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
/**
* 자재투입 모달 (로트 선택 기반)
*
* 로트를 체크박스로 선택하면 필요수량만큼 FIFO 순서로 자동 배분합니다.
* 같은 품목의 여러 로트를 조합하여 필요수량을 충족시킬 수 있습니다.
*
* 기능:
* - 기투입 LOT 표시 및 수정 (replace 모드)
* - 선택완료 배지
* - 필요수량 배정 완료 시에만 투입 가능
* - FIFO 자동입력 버튼
*/
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Loader2, Check, Zap, Info, Search, X } 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, searchStockByCode, forceCreateReceiving, searchItems, type MaterialForInput, type MaterialForItemInput, type StockSearchResult, type ItemSearchResult } 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<MaterialForInput[]>([]);
const [selectedLotKeys, setSelectedLotKeys] = useState<Set<string>>(new Set());
const [manualAllocations, setManualAllocations] = useState<Map<string, number>>(new Map());
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const materialsLoadedRef = useRef(false);
// 재공품 여부 판별 — 재공품이면 검색 시 원자재(RM)만 조회
const isWipOrder = useMemo(() => {
if (!order) return false;
return order.projectName === '재고생산' || order.salesOrderNo?.startsWith('STK');
}, [order]);
// 재고 검색 상태
const [searchOpenGroup, setSearchOpenGroup] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<StockSearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
// 품목 검색 결과 (재고 없는 품목 포함)
const [itemSearchResults, setItemSearchResults] = useState<ItemSearchResult[]>([]);
const handleStockSearch = useCallback(async (query: string | undefined) => {
if (!query?.trim()) { setSearchResults([]); setItemSearchResults([]); return; }
setIsSearching(true);
// 재공품: 원자재(RM)만 검색, 일반: 전체 검색
const typeFilter = isWipOrder ? 'RM' : undefined;
const [stockResult, itemResult] = await Promise.all([
searchStockByCode(query.trim(), typeFilter),
searchItems(query.trim(), typeFilter),
]);
if (stockResult.success) setSearchResults(stockResult.data);
if (itemResult.success) {
// 재고 검색 결과에 이미 있는 품목은 제외
const stockItemIds = new Set(stockResult.data?.map(s => s.itemId) || []);
setItemSearchResults(itemResult.data.filter(i => !stockItemIds.has(i.itemId)));
}
setIsSearching(false);
}, [isWipOrder]);
const [isForceCreating, setIsForceCreating] = 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,
}));
// 로트 키 생성 (그룹별 독립 — 같은 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<string, MaterialForInput[]>();
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<string, number> = {
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<string, number>();
const physicalUsed = new Map<number, number>(); // 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<string>();
const newAllocations = new Map<string, number>();
const physicalUsed = new Map<number, number>(); // 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]);
// [개발전용] 입고 강제생성 핸들러
const handleForceReceiving = useCallback(async (itemId: number, itemCode: string) => {
setIsForceCreating(true);
const result = await forceCreateReceiving(itemId, 100);
if (result.success && result.data) {
const d = result.data as Record<string, unknown>;
const rm = d.rm_item_code || d.rm_item_name;
const pt = d.pt_item_code || d.item_code;
const label = rm ? `원자재 ${rm} → 재공품 ${pt}` : String(pt);
toast.success(`${label} 입고 완료 (LOT: ${d.lot_no}, ${d.qty}EA)`);
handleStockSearch(itemCode);
loadMaterials();
} else {
toast.error(result.error || '입고 생성 실패');
}
setIsForceCreating(false);
}, [handleStockSearch, loadMaterials]);
// 모달이 열릴 때 데이터 로드 + 선택 초기화
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<string>();
const preAllocations = new Map<string, number>();
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<number, number>();
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<number>();
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!max-w-3xl p-0 gap-0 max-h-[85vh] flex flex-col">
{/* 헤더 */}
<DialogHeader className="p-6 pb-4 shrink-0">
<DialogTitle className="text-xl font-semibold">
{workOrderItemName ? ` - ${workOrderItemName}` : ''}
</DialogTitle>
<div className="flex items-center justify-between mt-1">
<div className="flex items-center gap-2">
<p className="text-sm text-gray-500">
.
</p>
{!isLoading && materialGroups.length > 0 && (() => {
const fulfilled = materialGroups.filter((g) => {
const target = getGroupTargetQty(g);
if (target <= 0 && g.alreadyInputted <= 0) return true;
const allocated = g.lots.reduce((sum, lot) => sum + (allocations.get(getLotKey(lot, g.groupKey)) || 0), 0);
return allocated >= target;
}).length;
const total = materialGroups.length;
return (
<span className={cn(
"text-xs font-semibold px-2.5 py-1 rounded-full",
fulfilled === total ? "bg-emerald-100 text-emerald-700" : "bg-amber-100 text-amber-700"
)}>
{fulfilled === total ? <Check className="h-3 w-3 inline mr-0.5" /> : null}
{fulfilled} / {total}
</span>
);
})()}
</div>
{!isLoading && materials.length > 0 && (
<button
onClick={handleAutoFill}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-semibold bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors shrink-0"
>
<Zap className="h-3 w-3" />
</button>
)}
</div>
</DialogHeader>
<div className="px-6 pb-6 space-y-4 flex-1 min-h-0 flex flex-col">
{/* 자재 목록 */}
{isLoading ? (
<ContentSkeleton type="table" rows={4} />
) : materials.length === 0 ? (
<div className="border rounded-lg overflow-hidden">
<div className="py-8 text-center text-sm text-gray-500">
.
</div>
{/* 자재 없을 때도 재고 검색 + 강제입고 가능 */}
<div className="px-4 py-3 bg-blue-50 border-t space-y-2">
<div className="flex items-center gap-2">
<Search className="h-3.5 w-3.5 text-blue-500 shrink-0" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleStockSearch(searchQuery)}
placeholder={isWipOrder ? "원자재 코드 또는 자재명으로 검색" : "품목코드 또는 자재명으로 재고 검색"}
className="flex-1 text-xs px-2 py-1.5 border rounded bg-white"
/>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => handleStockSearch(searchQuery)}>
{isSearching ? <Loader2 className="h-3 w-3 animate-spin" /> : '검색'}
</Button>
</div>
{searchResults.length > 0 ? (
<div className="space-y-1">
<p className="text-[11px] text-blue-600 font-medium">{searchResults.length} :</p>
{searchResults.map((s, si) => (
<div key={`${s.itemId}-${si}`} className={cn(
"text-[11px] px-2 py-1.5 rounded border",
s.availableQty > 0 ? "bg-white border-emerald-200" : "bg-gray-50 border-gray-200"
)}>
<div className="flex justify-between items-center">
<span className="font-medium">{s.itemCode}</span>
<span className={s.availableQty > 0 ? "text-emerald-600 font-semibold" : "text-gray-400"}>
{formatNumber(s.availableQty)} EA
</span>
</div>
<div className="text-gray-500">{s.itemName}</div>
{s.lots.length > 0 && s.lots.slice(0, 3).map((l, li) => (
<div key={li} className="text-[10px] text-gray-400 ml-2">
LOT {l.lotNo} | {formatNumber(l.availableQty)} EA | FIFO #{l.fifoOrder}
</div>
))}
</div>
))}
</div>
) : isSearching ? (
<p className="text-[11px] text-gray-400 flex items-center gap-1"><Loader2 className="h-3 w-3 animate-spin" /> ...</p>
) : searchQuery && searchResults.length === 0 && itemSearchResults.length === 0 ? (
<p className="text-[11px] text-red-500"> </p>
) : null}
{/* 재고 없는 품목 목록 (items 테이블에만 존재) */}
{itemSearchResults.length > 0 && (
<div className="space-y-1">
<p className="text-[11px] text-orange-600 font-medium"> {itemSearchResults.length}:</p>
{itemSearchResults.map((item, idx) => (
<div key={`item-${item.itemId}-${idx}`} className="text-[11px] px-2 py-1.5 rounded border bg-orange-50 border-orange-200 flex items-center justify-between">
<div>
<span className="font-medium">{item.itemCode}</span>
<span className="text-gray-500 ml-1">{item.itemName}</span>
<span className="text-[10px] text-gray-400 ml-1">({item.itemType})</span>
</div>
<Button
size="sm"
variant="outline"
className="h-6 text-[10px] px-2 border-orange-300 text-orange-600 hover:bg-orange-100"
disabled={isForceCreating}
onClick={() => handleForceReceiving(item.itemId, item.itemCode)}
>
{isForceCreating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Zap className="h-3 w-3 mr-0.5" />}
</Button>
</div>
))}
</div>
)}
</div>
</div>
) : (
<div className="space-y-4 flex-1 overflow-y-auto min-h-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 (
<div key={group.groupKey} className="border rounded-lg overflow-hidden">
{/* 품목 그룹 헤더 */}
<div className="flex flex-wrap items-center justify-between gap-1.5 px-4 py-2.5 bg-gray-50 border-b">
<div className="flex items-center gap-2 flex-wrap min-w-0">
{group.category && (
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
group.category === 'guideRail' ? 'bg-blue-100 text-blue-700' :
group.category === 'bottomBar' ? 'bg-green-100 text-green-700' :
group.category === 'shutterBox' ? 'bg-orange-100 text-orange-700' :
group.category === 'smokeBarrier' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600'
}`}>
{group.category === 'guideRail' ? '가이드레일' :
group.category === 'bottomBar' ? '하단마감재' :
group.category === 'shutterBox' ? '셔터박스' :
group.category === 'smokeBarrier' ? '연기차단재' :
group.category}
</span>
)}
{group.partType && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-200 text-gray-600 font-medium">
{circledNum}{group.partType}
</span>
)}
<span className="text-sm font-semibold text-gray-900">
{group.materialName}
</span>
{group.materialCode && (
<span className="text-xs text-gray-400">
{group.materialCode}
</span>
)}
{/* 매칭 정보 말풍선 */}
<span className="relative group/info inline-flex">
<Info className={cn(
"h-3.5 w-3.5 cursor-help",
group.lots.some(l => l.stockLotId !== null) ? "text-blue-400" : "text-red-400"
)} />
<span className="absolute left-0 top-5 z-50 hidden group-hover/info:block w-64 p-2.5 rounded-lg shadow-lg border bg-white text-xs text-gray-700 leading-relaxed">
<span className="font-semibold text-gray-900 block mb-1"> </span>
<span className="block text-[11px] text-gray-500 mb-1.5">
: {group.materialCode || '-'}<br/>
: {group.lots[0]?.specification || '-'}<br/>
매칭기준: 품목코드 + LOT (FIFO)
</span>
{(() => {
const availableLots = group.lots.filter(l => l.stockLotId !== null);
const noStockLots = group.lots.filter(l => l.stockLotId === null);
if (availableLots.length > 0) {
return (
<span className="block">
<span className="text-emerald-600 font-medium">
{availableLots.length}
</span>
{availableLots.map((l, i) => (
<span key={i} className="block text-[11px] text-gray-500 ml-2">
LOT {l.lotNo} | {formatNumber(l.lotAvailableQty)}{group.unit} | FIFO #{l.fifoRank}
</span>
))}
</span>
);
}
return (
<span className="block text-red-600 font-medium">
LOT
<span className="block text-[11px] text-red-500 font-normal mt-0.5">
[{group.materialCode}] {group.lots[0]?.specification || ''}
</span>
</span>
);
})()}
</span>
</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-gray-500">
{group.alreadyInputted > 0 ? (
<>
:{' '}
<span className="font-semibold text-gray-900">
{fmtQty(group.requiredQty)}
</span>{' '}
{group.unit}
<span className="ml-1 text-blue-500">
(: {fmtQty(group.alreadyInputted)})
</span>
</>
) : (
<>
:{' '}
<span className="font-semibold text-gray-900">
{fmtQty(group.requiredQty)}
</span>{' '}
{group.unit}
</>
)}
</span>
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full flex items-center gap-1 ${
isFulfilled
? 'bg-emerald-100 text-emerald-700'
: groupAllocated > 0
? 'bg-amber-100 text-amber-700'
: 'bg-gray-100 text-gray-500'
}`}
>
{isFulfilled ? (
<>
<Check className="h-3 w-3" />
</>
) : (
`${fmtQty(groupAllocated)} / ${fmtQty(targetQty)}`
)}
</span>
</div>
</div>
{/* 로트 테이블 */}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-center w-24"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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 (
<TableRow
key={`${lotKey}-${idx}`}
className={cn(
isSelected && allocated > 0 ? 'bg-blue-50/50' : '',
isPreInputted && isSelected ? 'bg-blue-50/70' : ''
)}
>
<TableCell className="text-center">
{hasStock ? (
<button
onClick={() => toggleLot(lotKey)}
disabled={!canSelect}
className={cn(
'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
? 'bg-gray-100 text-gray-600 hover:bg-gray-200'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
)}
>
{isSelected ? '선택완료' : '선택'}
</button>
) : null}
</TableCell>
<TableCell className="text-center text-sm">
<div className="flex items-center justify-center gap-1">
{lot.lotNo || (
<span className="text-gray-400">
</span>
)}
{isPreInputted && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-600 font-medium">
</span>
)}
</div>
</TableCell>
<TableCell className="text-center text-sm">
{hasStock ? (
isPreInputted ? (
<span>
{fmtQty(lot.lotAvailableQty)}
<span className="text-blue-500 text-xs ml-1">(+{fmtQty(lotInputted)})</span>
</span>
) : (
fmtQty(lot.lotAvailableQty)
)
) : (
<span className="text-red-500">0</span>
)}
</TableCell>
<TableCell className="text-center text-sm">
{lot.unit}
</TableCell>
<TableCell className="text-center text-sm font-medium">
{isSelected && hasStock ? (
<input
type="number"
value={allocated || ''}
onChange={(e) => {
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 ? (
<span className="text-blue-600">
{fmtQty(allocated)}
</span>
) : (
<span className="text-gray-300">-</span>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{/* 재고 검색 패널 */}
{!group.lots.some(l => l.stockLotId !== null) && searchOpenGroup !== group.groupKey && (
<button
onClick={() => {
setSearchOpenGroup(group.groupKey);
setSearchQuery(group.materialCode || group.materialName);
setSearchResults([]);
handleStockSearch(group.materialCode || group.materialName);
}}
className="w-full px-4 py-2 text-xs text-red-600 bg-red-50 border-t flex items-center gap-1.5 hover:bg-red-100 transition-colors"
>
<Search className="h-3.5 w-3.5" />
</button>
)}
{searchOpenGroup === group.groupKey && (
<div className="px-4 py-3 bg-blue-50 border-t space-y-2">
<div className="flex items-center gap-2">
<Search className="h-3.5 w-3.5 text-blue-500 shrink-0" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleStockSearch(searchQuery)}
placeholder={isWipOrder ? "원자재 코드 또는 자재명 검색" : "품목코드 또는 자재명 검색"}
className="flex-1 text-xs px-2 py-1.5 border rounded bg-white"
/>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => handleStockSearch(searchQuery)}>
{isSearching ? <Loader2 className="h-3 w-3 animate-spin" /> : '검색'}
</Button>
<button onClick={() => setSearchOpenGroup(null)} className="text-gray-400 hover:text-gray-600">
<X className="h-4 w-4" />
</button>
</div>
{searchResults.length > 0 ? (
<div className="space-y-1">
<p className="text-[11px] text-blue-600 font-medium">{searchResults.length} :</p>
{searchResults.map((s, si) => (
<div key={`${s.itemId}-${si}`} className={cn(
"text-[11px] px-2 py-1.5 rounded border",
s.availableQty > 0 ? "bg-white border-emerald-200" : "bg-gray-50 border-gray-200"
)}>
<div className="flex justify-between items-center">
<span className="font-medium">{s.itemCode}</span>
<span className={s.availableQty > 0 ? "text-emerald-600 font-semibold" : "text-gray-400"}>
{formatNumber(s.availableQty)} EA
</span>
</div>
<div className="text-gray-500">{s.itemName}</div>
{s.lots.length > 0 && (
<div className="mt-1 space-y-0.5">
{s.lots.slice(0, 3).map((l, li) => (
<div key={li} className="text-[10px] text-gray-400 ml-2">
LOT {l.lotNo} | {formatNumber(l.availableQty)} EA | FIFO #{l.fifoOrder}
</div>
))}
</div>
)}
</div>
))}
</div>
) : isSearching ? (
<p className="text-[11px] text-gray-400 flex items-center gap-1"><Loader2 className="h-3 w-3 animate-spin" /> ...</p>
) : searchQuery && (
<p className="text-[11px] text-red-500"> </p>
)}
{/* [개발전용] 입고 강제생성 버튼 */}
{group.lots[0]?.itemId && (
<Button
size="sm"
variant="outline"
className="w-full h-8 text-xs mt-1 border-orange-300 text-orange-600 hover:bg-orange-50"
disabled={isForceCreating}
onClick={() => handleForceReceiving(group.lots[0].itemId, group.materialCode || group.materialName)}
>
{isForceCreating ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Zap className="h-3 w-3 mr-1" />}
[DEV] ({group.materialCode || '품목'} × 100EA)
</Button>
)}
</div>
)}
</div>
);
})}
</div>
)}
{/* 버튼 영역 */}
<div className="flex gap-3 shrink-0">
<Button
variant="outline"
onClick={resetAndClose}
disabled={isSubmitting}
className="flex-1 py-6 text-base font-medium"
>
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || !allGroupsFulfilled || !hasAnyAllocation}
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
'투입'
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}