fix: [자재투입] 입고 로트번호 표시 및 로트별 수량 투입으로 변경

- MaterialForInput 타입: stockLotId, lotNo, lotAvailableQty 추가
- 로트번호 컬럼에 실제 입고 로트번호(lot_no) 표시
- 수량 컬럼을 가용수량(lotAvailableQty)으로 변경
- 가용수량 초과 검증 추가
- registerMaterialInput: stock_lot_id+qty 로트별 투입 방식으로 변경
This commit is contained in:
2026-02-07 05:06:34 +09:00
parent a523bb482e
commit 1fca5ed477
2 changed files with 110 additions and 67 deletions

View File

@@ -1,10 +1,10 @@
'use client';
/**
* 자재투입 모달 (기획서 기반)
* 자재투입 모달 (로트 기반)
*
* 기획서 변경: BOM 체크박스 → 투입수량 입력 테이블
* 컬럼: 로트번호 | 품목명 | 수량 | 단위 | 투입 수량 (input, 숫자만)
* 입고관리에서 생성된 실제 로트번호 기준으로 자재를 표시합니다.
* 컬럼: 로트번호 | 품목명 | 가용수량 | 단위 | 투입 수량 (input, 숫자만)
* 하단: 취소 / 투입
*/
@@ -56,13 +56,17 @@ export function MaterialInputModal({
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// 목업 자재 데이터 (기획서 기반 10행)
const MOCK_MATERIALS: MaterialForInput[] = Array.from({ length: 10 }, (_, i) => ({
id: 100 + i,
materialCode: '123123',
materialName: '품목명',
unit: 'm',
currentStock: 500,
// 목업 자재 데이터 (개발용)
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,
}));
@@ -77,7 +81,7 @@ export function MaterialInputModal({
setMaterials(MOCK_MATERIALS);
const initialQuantities: Record<string, string> = {};
MOCK_MATERIALS.forEach((m) => {
initialQuantities[String(m.id)] = '';
initialQuantities[String(m.stockLotId)] = '';
});
setInputQuantities(initialQuantities);
setIsLoading(false);
@@ -87,10 +91,9 @@ export function MaterialInputModal({
const result = await getMaterialsForWorkOrder(order.id);
if (result.success) {
setMaterials(result.data);
// 초기 투입 수량 비우기
const initialQuantities: Record<string, string> = {};
result.data.forEach((m) => {
initialQuantities[String(m.id)] = '';
initialQuantities[String(m.stockLotId ?? `item-${m.itemId}`)] = '';
});
setInputQuantities(initialQuantities);
} else {
@@ -113,22 +116,26 @@ export function MaterialInputModal({
}, [open, order, loadMaterials]);
// 투입 수량 변경 핸들러 (숫자만 허용)
const handleQuantityChange = (materialId: string, value: string) => {
// 숫자만 허용
const handleQuantityChange = (key: string, value: string) => {
const numericValue = value.replace(/[^0-9]/g, '');
setInputQuantities((prev) => ({
...prev,
[materialId]: numericValue,
[key]: numericValue,
}));
};
// 로트 키 생성
const getLotKey = (material: MaterialForInput) =>
String(material.stockLotId ?? `item-${material.itemId}`);
// 투입 등록
const handleSubmit = async () => {
if (!order) return;
// 투입 수량이 입력된 항목 필터
// 투입 수량이 입력된 항목 필터 (재고 있는 로트만)
const materialsWithQuantity = materials.filter((m) => {
const qty = inputQuantities[String(m.id)];
if (!m.stockLotId) return false;
const qty = inputQuantities[getLotKey(m)];
return qty && parseInt(qty) > 0;
});
@@ -137,23 +144,35 @@ export function MaterialInputModal({
return;
}
// 가용수량 초과 검증
for (const m of materialsWithQuantity) {
const inputQty = parseInt(inputQuantities[getLotKey(m)] || '0');
if (inputQty > m.lotAvailableQty) {
toast.error(`${m.lotNo}: 가용수량(${m.lotAvailableQty})을 초과할 수 없습니다.`);
return;
}
}
setIsSubmitting(true);
try {
const materialIds = materialsWithQuantity.map((m) => m.id);
const result = await registerMaterialInput(order.id, materialIds);
const inputs = materialsWithQuantity.map((m) => ({
stock_lot_id: m.stockLotId!,
qty: parseInt(inputQuantities[getLotKey(m)] || '0'),
}));
const result = await registerMaterialInput(order.id, inputs);
if (result.success) {
toast.success('자재 투입이 등록되었습니다.');
// onSaveMaterials 콜백 호출
if (onSaveMaterials) {
const savedList: MaterialInput[] = materialsWithQuantity.map((m) => ({
id: String(m.id),
lotNo: '', // API에서 가져올 필드
id: String(m.stockLotId),
lotNo: m.lotNo || '',
materialName: m.materialName,
quantity: m.currentStock,
quantity: m.lotAvailableQty,
unit: m.unit,
inputQuantity: parseInt(inputQuantities[String(m.id)] || '0'),
inputQuantity: parseInt(inputQuantities[getLotKey(m)] || '0'),
}));
onSaveMaterials(order.id, savedList);
}
@@ -205,7 +224,7 @@ export function MaterialInputModal({
<TableRow className="bg-gray-50">
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"> </TableHead>
</TableRow>
@@ -226,40 +245,52 @@ export function MaterialInputModal({
<TableRow className="bg-gray-50">
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"></TableHead>
<TableHead className="text-center font-medium"> </TableHead>
</TableRow>
</TableHeader>
<TableBody>
{materials.map((material, index) => (
<TableRow key={`mat-${material.id}-${index}`}>
<TableCell className="text-center text-sm">
{material.materialCode}
</TableCell>
<TableCell className="text-center text-sm">
{material.materialName}
</TableCell>
<TableCell className="text-center text-sm">
{material.currentStock.toLocaleString()}
</TableCell>
<TableCell className="text-center text-sm">
{material.unit}
</TableCell>
<TableCell className="text-center">
<Input
type="text"
inputMode="numeric"
placeholder="0"
value={inputQuantities[String(material.id)] || ''}
onChange={(e) =>
handleQuantityChange(String(material.id), e.target.value)
}
className="w-20 mx-auto text-center h-8 text-sm"
/>
</TableCell>
</TableRow>
))}
{materials.map((material, index) => {
const lotKey = getLotKey(material);
const hasStock = material.stockLotId !== null;
return (
<TableRow key={`mat-${lotKey}-${index}`}>
<TableCell className="text-center text-sm">
{material.lotNo || (
<span className="text-gray-400"> </span>
)}
</TableCell>
<TableCell className="text-center text-sm">
{material.materialName}
</TableCell>
<TableCell className="text-center text-sm">
{hasStock ? material.lotAvailableQty.toLocaleString() : (
<span className="text-red-500">0</span>
)}
</TableCell>
<TableCell className="text-center text-sm">
{material.unit}
</TableCell>
<TableCell className="text-center">
{hasStock ? (
<Input
type="text"
inputMode="numeric"
placeholder="0"
value={inputQuantities[lotKey] || ''}
onChange={(e) =>
handleQuantityChange(lotKey, e.target.value)
}
className="w-20 mx-auto text-center h-8 text-sm"
/>
) : (
<span className="text-gray-400 text-sm">-</span>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
@@ -294,4 +325,4 @@ export function MaterialInputModal({
</DialogContent>
</Dialog>
);
}
}

View File

@@ -277,13 +277,17 @@ export async function completeWorkOrder(
}
}
// ===== 자재 목록 조회 (BOM 기준) =====
// ===== 자재 목록 조회 (로트 기준) =====
export interface MaterialForInput {
id: number;
stockLotId: number | null; // StockLot ID (null이면 재고 없음)
itemId: number;
lotNo: string | null; // 실제 입고 로트번호
materialCode: string;
materialName: string;
specification: string;
unit: string;
currentStock: number;
requiredQty: number; // 필요 수량
lotAvailableQty: number; // 로트별 가용 수량
fifoRank: number;
}
@@ -330,20 +334,28 @@ export async function getMaterialsForWorkOrder(
};
}
// API 응답을 MaterialForInput 형식으로 변환
// API 응답을 MaterialForInput 형식으로 변환 (로트 단위)
const materials: MaterialForInput[] = (result.data || []).map((item: {
id: number;
stock_lot_id: number | null;
item_id: number;
lot_no: string | null;
material_code: string;
material_name: string;
specification: string;
unit: string;
current_stock: number;
required_qty: number;
lot_available_qty: number;
fifo_rank: number;
}) => ({
id: item.id,
stockLotId: item.stock_lot_id,
itemId: item.item_id,
lotNo: item.lot_no,
materialCode: item.material_code,
materialName: item.material_name,
specification: item.specification ?? '',
unit: item.unit,
currentStock: item.current_stock,
requiredQty: item.required_qty,
lotAvailableQty: item.lot_available_qty,
fifoRank: item.fifo_rank,
}));
@@ -362,17 +374,17 @@ export async function getMaterialsForWorkOrder(
}
}
// ===== 자재 투입 등록 =====
// ===== 자재 투입 등록 (로트별 수량) =====
export async function registerMaterialInput(
workOrderId: string,
materialIds: number[]
inputs: { stock_lot_id: number; qty: number }[]
): Promise<{ success: boolean; error?: string }> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/material-inputs`,
{
method: 'POST',
body: JSON.stringify({ material_ids: materialIds }),
body: JSON.stringify({ inputs }),
}
);