feat(WEB): 절곡 자재투입 LOT 매핑 프론트엔드 연동
- actions.ts: MaterialForInput에 workOrderItemId/lotPrefix/partType/category 필드 추가 - MaterialInputModal: dynamic_bom 세부품목 단위 그룹핑 + category 배지 표시 - 작업일지 4개 섹션 lotNoMap prop 추가 (GuideRail/BottomBar/ShutterBox/SmokeBarrier) - WorkLogModal: materialLots에서 BD-* 필터링 → lotNoMap 빌드 후 전달 - utils.ts: lengthToCode() 래퍼 함수 추가
This commit is contained in:
@@ -47,6 +47,7 @@ interface MaterialInputModalProps {
|
||||
|
||||
interface MaterialGroup {
|
||||
itemId: number;
|
||||
groupKey: string; // 그룹 식별 키 (itemId 또는 itemId_woItemId)
|
||||
materialName: string;
|
||||
materialCode: string;
|
||||
requiredQty: number;
|
||||
@@ -54,6 +55,11 @@ interface MaterialGroup {
|
||||
alreadyInputted: number; // 이미 투입된 수량
|
||||
unit: string;
|
||||
lots: MaterialForInput[];
|
||||
// dynamic_bom 추가 정보
|
||||
workOrderItemId?: number;
|
||||
lotPrefix?: string;
|
||||
partType?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
const fmtQty = (v: number) => formatNumber(parseFloat(String(v)));
|
||||
@@ -93,19 +99,22 @@ export function MaterialInputModal({
|
||||
|
||||
// 품목별 그룹핑
|
||||
const materialGroups: MaterialGroup[] = useMemo(() => {
|
||||
const groups = new Map<number, MaterialForInput[]>();
|
||||
// dynamic_bom 항목은 (itemId, workOrderItemId) 쌍으로 그룹핑
|
||||
const groups = new Map<string, MaterialForInput[]>();
|
||||
for (const m of materials) {
|
||||
const existing = groups.get(m.itemId) || [];
|
||||
const groupKey = m.workOrderItemId ? `${m.itemId}_${m.workOrderItemId}` : String(m.itemId);
|
||||
const existing = groups.get(groupKey) || [];
|
||||
existing.push(m);
|
||||
groups.set(m.itemId, existing);
|
||||
groups.set(groupKey, existing);
|
||||
}
|
||||
return Array.from(groups.entries()).map(([itemId, lots]) => {
|
||||
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,
|
||||
itemId: first.itemId,
|
||||
groupKey,
|
||||
materialName: first.materialName,
|
||||
materialCode: first.materialCode,
|
||||
requiredQty: first.requiredQty,
|
||||
@@ -113,6 +122,10 @@ export function MaterialInputModal({
|
||||
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]);
|
||||
@@ -208,13 +221,20 @@ export function MaterialInputModal({
|
||||
const handleSubmit = async () => {
|
||||
if (!order) return;
|
||||
|
||||
// 배분된 로트만 추출
|
||||
const inputs: { stock_lot_id: number; qty: number }[] = [];
|
||||
// 배분된 로트만 추출 (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) {
|
||||
inputs.push({ stock_lot_id: material.stockLotId, qty: allocQty });
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,10 +330,25 @@ export function MaterialInputModal({
|
||||
const isFulfilled = isAlreadyComplete || groupAllocated >= group.effectiveRequiredQty;
|
||||
|
||||
return (
|
||||
<div key={group.itemId} className="border rounded-lg overflow-hidden">
|
||||
<div key={group.groupKey} className="border rounded-lg overflow-hidden">
|
||||
{/* 품목 그룹 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-gray-50 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
{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>
|
||||
)}
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{group.materialName}
|
||||
</span>
|
||||
|
||||
@@ -209,8 +209,15 @@ export function WorkLogModal({
|
||||
return <ScreenWorkLogContent data={order} materialLots={materialLots} />;
|
||||
case 'slat':
|
||||
return <SlatWorkLogContent data={order} materialLots={materialLots} />;
|
||||
case 'bending':
|
||||
return <BendingWorkLogContent data={order} />;
|
||||
case 'bending': {
|
||||
const lotNoMap: Record<string, string> = {};
|
||||
for (const lot of materialLots) {
|
||||
if (lot.item_code.startsWith('BD-')) {
|
||||
lotNoMap[lot.item_code] = lot.lot_no;
|
||||
}
|
||||
}
|
||||
return <BendingWorkLogContent data={order} lotNoMap={lotNoMap} />;
|
||||
}
|
||||
default:
|
||||
return <WorkLogContent data={order} />;
|
||||
}
|
||||
|
||||
@@ -254,6 +254,11 @@ export interface MaterialForInput {
|
||||
requiredQty: number; // 필요 수량
|
||||
lotAvailableQty: number; // 로트별 가용 수량
|
||||
fifoRank: number;
|
||||
// dynamic_bom 추가 필드 (절곡 세부품목용)
|
||||
workOrderItemId?: number; // 개소(작업지시품목) ID
|
||||
lotPrefix?: string; // LOT prefix (RS, RM 등)
|
||||
partType?: string; // 파트 타입 (finish, body 등)
|
||||
category?: string; // 카테고리 (guideRail, bottomBar 등)
|
||||
}
|
||||
|
||||
export async function getMaterialsForWorkOrder(
|
||||
@@ -267,6 +272,8 @@ export async function getMaterialsForWorkOrder(
|
||||
stock_lot_id: number | null; item_id: number; lot_no: string | null;
|
||||
material_code: string; material_name: string; specification: string;
|
||||
unit: string; required_qty: number; lot_available_qty: number; fifo_rank: number;
|
||||
// dynamic_bom 추가 필드
|
||||
work_order_item_id?: number; lot_prefix?: string; part_type?: string; category?: string;
|
||||
}
|
||||
const result = await executeServerAction<MaterialApiItem[]>({
|
||||
url: `${API_URL}/api/v1/work-orders/${workOrderId}/materials`,
|
||||
@@ -280,6 +287,8 @@ export async function getMaterialsForWorkOrder(
|
||||
materialCode: item.material_code, materialName: item.material_name,
|
||||
specification: item.specification ?? '', unit: item.unit,
|
||||
requiredQty: item.required_qty, lotAvailableQty: item.lot_available_qty, fifoRank: item.fifo_rank,
|
||||
workOrderItemId: item.work_order_item_id, lotPrefix: item.lot_prefix,
|
||||
partType: item.part_type, category: item.category,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -287,7 +296,7 @@ export async function getMaterialsForWorkOrder(
|
||||
// ===== 자재 투입 등록 (로트별 수량) =====
|
||||
export async function registerMaterialInput(
|
||||
workOrderId: string,
|
||||
inputs: { stock_lot_id: number; qty: number }[]
|
||||
inputs: { stock_lot_id: number; qty: number; work_order_item_id?: number }[]
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/work-orders/${workOrderId}/material-inputs`,
|
||||
|
||||
Reference in New Issue
Block a user