fix: [production] 자재투입 모달 — 동일 자재 다중 BOM 그룹 LOT 독립 관리

- getLotKey에 groupKey 포함하여 그룹별 LOT 선택/배정 독립 처리
- physicalUsed 맵으로 물리LOT 교차그룹 가용량 추적
- handleAutoFill FIFO 자동입력 (교차그룹 가용량 고려)
- handleSubmit 그룹별 개별 엔트리 전송 (bom_group_key 포함, replace 모드)
- 기투입 LOT 자동 선택 및 배지 표시, 수량 수동 편집 input
- allGroupsFulfilled 조건으로 투입 버튼 활성화 제어
- actions.ts: lotInputtedQty 필드 + bom_group_key/replace 파라미터 추가
This commit is contained in:
2026-03-04 10:36:50 +09:00
parent 83a23701a7
commit 0166601be8
2 changed files with 252 additions and 82 deletions

View File

@@ -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<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);
// 목업 자재 데이터 (개발용)
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<string, number>();
const physicalUsed = new Map<number, number>(); // 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<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;
}
@@ -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<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;
// 배분된 로트만 추출 (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<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[] = [];
for (const [lotKey, allocQty] of allocations) {
if (allocQty > 0) {
const material = materials.find((m) => getLotKey(m) === lotKey);
if (material) {
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(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({
<DialogTitle className="text-xl font-semibold">
{workOrderItemName ? ` - ${workOrderItemName}` : ''}
</DialogTitle>
<p className="text-sm text-gray-500 mt-1">
.
</p>
<div className="flex items-center justify-between mt-1">
<p className="text-sm text-gray-500">
.
</p>
{!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">
@@ -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 (
<div key={group.groupKey} className="border rounded-lg overflow-hidden">
@@ -410,10 +554,10 @@ export function MaterialInputModal({
<>
:{' '}
<span className="font-semibold text-gray-900">
{fmtQty(group.effectiveRequiredQty)}
{fmtQty(group.requiredQty)}
</span>{' '}
{group.unit}
<span className="ml-1 text-gray-400">
<span className="ml-1 text-blue-500">
(: {fmtQty(group.alreadyInputted)})
</span>
</>
@@ -429,27 +573,20 @@ export function MaterialInputModal({
</span>
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full flex items-center gap-1 ${
isAlreadyComplete
isFulfilled
? 'bg-emerald-100 text-emerald-700'
: isFulfilled
? 'bg-emerald-100 text-emerald-700'
: groupAllocated > 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 ? (
<>
<Check className="h-3 w-3" />
</>
) : isFulfilled ? (
{isFulfilled ? (
<>
<Check className="h-3 w-3" />
</>
) : (
`${fmtQty(groupAllocated)} / ${fmtQty(group.effectiveRequiredQty)}`
`${fmtQty(groupAllocated)} / ${fmtQty(targetQty)}`
)}
</span>
</div>
@@ -460,7 +597,7 @@ export function MaterialInputModal({
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-center w-20"></TableHead>
<TableHead className="text-center w-24"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
@@ -469,20 +606,24 @@ export function MaterialInputModal({
</TableHeader>
<TableBody>
{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 (
<TableRow
key={`${lotKey}-${idx}`}
className={
isSelected && allocated > 0
? 'bg-blue-50/50'
: ''
}
className={cn(
isSelected && allocated > 0 ? 'bg-blue-50/50' : '',
isPreInputted && isSelected ? 'bg-blue-50/70' : ''
)}
>
<TableCell className="text-center">
{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 ? '선택완료' : '선택'}
</button>
) : null}
</TableCell>
<TableCell className="text-center text-sm">
{lot.lotNo || (
<span className="text-gray-400">
</span>
)}
<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 ? (
fmtQty(lot.lotAvailableQty)
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>
)}
@@ -520,7 +675,19 @@ export function MaterialInputModal({
{lot.unit}
</TableCell>
<TableCell className="text-center text-sm font-medium">
{allocated > 0 ? (
{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>
@@ -552,7 +719,7 @@ export function MaterialInputModal({
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || !hasAnyAllocation}
disabled={isSubmitting || !allGroupsFulfilled || !hasAnyAllocation}
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
>
{isSubmitting ? (
@@ -569,4 +736,4 @@ export function MaterialInputModal({
</DialogContent>
</Dialog>
);
}
}

View File

@@ -316,6 +316,7 @@ export async function registerMaterialInput(
export interface MaterialForItemInput extends MaterialForInput {
alreadyInputted: number; // 이미 투입된 수량
remainingRequiredQty: number; // 남은 필요 수량
lotInputtedQty: number; // 해당 LOT의 기투입 수량
bomGroupKey?: string; // BOM 엔트리별 고유 그룹키 (category+partType 기반)
}
@@ -331,7 +332,7 @@ export async function getMaterialsForItem(
stock_lot_id: number | null; item_id: number; lot_no: string | null;
material_code: string; material_name: string; specification: string;
unit: string; bom_qty: number; required_qty: number;
already_inputted: number; remaining_required_qty: number;
already_inputted: number; remaining_required_qty: number; lot_inputted_qty: number;
lot_available_qty: number; fifo_rank: number;
lot_qty: number; lot_reserved_qty: number;
receipt_date: string | null; supplier: string | null;
@@ -354,6 +355,7 @@ export async function getMaterialsForItem(
fifoRank: item.fifo_rank,
alreadyInputted: item.already_inputted,
remainingRequiredQty: item.remaining_required_qty,
lotInputtedQty: item.lot_inputted_qty ?? 0,
workOrderItemId: item.work_order_item_id, lotPrefix: item.lot_prefix,
partType: item.part_type, category: item.category,
bomGroupKey: item.bom_group_key,
@@ -365,12 +367,13 @@ export async function getMaterialsForItem(
export async function registerMaterialInputForItem(
workOrderId: string,
itemId: number,
inputs: { stock_lot_id: number; qty: number }[]
inputs: { stock_lot_id: number; qty: number; bom_group_key?: string }[],
replace = false
): Promise<{ success: boolean; error?: string }> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/work-orders/${workOrderId}/items/${itemId}/material-inputs`,
method: 'POST',
body: { inputs },
body: { inputs, replace },
errorMessage: '개소별 자재 투입 등록에 실패했습니다.',
});
return { success: result.success, error: result.error };