From a19263334e7e2154339c5b7fff4ec51af8c82377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sun, 22 Feb 2026 02:13:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EC=A0=88=EA=B3=A1=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=ED=88=AC=EC=9E=85=20LOT=20=EB=A7=A4=ED=95=91=20?= =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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() 래퍼 함수 추가 --- .../documents/BendingWorkLogContent.tsx | 7 ++- .../documents/bending/BottomBarSection.tsx | 9 ++-- .../documents/bending/GuideRailSection.tsx | 14 +++-- .../documents/bending/ShutterBoxSection.tsx | 19 +++++-- .../documents/bending/SmokeBarrierSection.tsx | 7 ++- .../WorkOrders/documents/bending/utils.ts | 5 ++ .../WorkerScreen/MaterialInputModal.tsx | 53 +++++++++++++++---- .../production/WorkerScreen/WorkLogModal.tsx | 11 +++- .../production/WorkerScreen/actions.ts | 11 +++- 9 files changed, 109 insertions(+), 27 deletions(-) diff --git a/src/components/production/WorkOrders/documents/BendingWorkLogContent.tsx b/src/components/production/WorkOrders/documents/BendingWorkLogContent.tsx index d1a45c1a..785414f1 100644 --- a/src/components/production/WorkOrders/documents/BendingWorkLogContent.tsx +++ b/src/components/production/WorkOrders/documents/BendingWorkLogContent.tsx @@ -29,9 +29,10 @@ import { ProductionSummarySection } from './bending/ProductionSummarySection'; interface BendingWorkLogContentProps { data: WorkOrder; + lotNoMap?: Record; // BD-{prefix}-{lengthCode} → LOT NO } -export function BendingWorkLogContent({ data: order }: BendingWorkLogContentProps) { +export function BendingWorkLogContent({ data: order, lotNoMap }: BendingWorkLogContentProps) { const today = new Date().toLocaleDateString('ko-KR', { year: 'numeric', month: '2-digit', @@ -164,16 +165,20 @@ export function BendingWorkLogContent({ data: order }: BendingWorkLogContentProp bendingInfo={bendingInfo!} mapping={mapping} lotNo={order.lotNo} + lotNoMap={lotNoMap} /> ) : ( diff --git a/src/components/production/WorkOrders/documents/bending/BottomBarSection.tsx b/src/components/production/WorkOrders/documents/bending/BottomBarSection.tsx index 2ce79584..4d0019db 100644 --- a/src/components/production/WorkOrders/documents/bending/BottomBarSection.tsx +++ b/src/components/production/WorkOrders/documents/bending/BottomBarSection.tsx @@ -8,14 +8,15 @@ */ import type { BendingInfoExtended, MaterialMapping } from './types'; -import { buildBottomBarRows, getBendingImageUrl, fmt, fmtWeight } from './utils'; +import { buildBottomBarRows, getBendingImageUrl, fmt, fmtWeight, lengthToCode } from './utils'; interface BottomBarSectionProps { bendingInfo: BendingInfoExtended; mapping: MaterialMapping; + lotNoMap?: Record; } -export function BottomBarSection({ bendingInfo, mapping }: BottomBarSectionProps) { +export function BottomBarSection({ bendingInfo, mapping, lotNoMap }: BottomBarSectionProps) { const rows = buildBottomBarRows(bendingInfo.bottomBar, mapping); if (rows.length === 0) return null; @@ -55,7 +56,9 @@ export function BottomBarSection({ bendingInfo, mapping }: BottomBarSectionProps {row.material} {fmt(row.length)} {fmt(row.quantity)} - - + + {lotNoMap?.[`BD-${row.lotPrefix}-${lengthToCode(row.length)}`] || '-'} + {fmtWeight(row.weight)} ))} diff --git a/src/components/production/WorkOrders/documents/bending/GuideRailSection.tsx b/src/components/production/WorkOrders/documents/bending/GuideRailSection.tsx index 4fa924ad..bfc2a7da 100644 --- a/src/components/production/WorkOrders/documents/bending/GuideRailSection.tsx +++ b/src/components/production/WorkOrders/documents/bending/GuideRailSection.tsx @@ -8,20 +8,22 @@ */ import type { BendingInfoExtended, MaterialMapping, GuideRailPartRow } from './types'; -import { buildWallGuideRailRows, buildSideGuideRailRows, getBendingImageUrl, fmt, fmtWeight } from './utils'; +import { buildWallGuideRailRows, buildSideGuideRailRows, getBendingImageUrl, fmt, fmtWeight, lengthToCode } from './utils'; interface GuideRailSectionProps { bendingInfo: BendingInfoExtended; mapping: MaterialMapping; lotNo: string; + lotNoMap?: Record; // BD-{prefix}-{lengthCode} → LOT NO } -function PartTable({ title, rows, imageUrl, lotNo, baseSize }: { +function PartTable({ title, rows, imageUrl, lotNo, baseSize, lotNoMap }: { title: string; rows: GuideRailPartRow[]; imageUrl: string; lotNo: string; baseSize?: string; + lotNoMap?: Record; }) { if (rows.length === 0) return null; @@ -60,7 +62,9 @@ function PartTable({ title, rows, imageUrl, lotNo, baseSize }: { {row.partName === '하부BASE' ? (baseSize || '-') : fmt(row.length)} {fmt(row.quantity)} - - + + {lotNoMap?.[`BD-${row.lotPrefix}-${lengthToCode(row.length)}`] || '-'} + {fmtWeight(row.weight)} ))} @@ -72,7 +76,7 @@ function PartTable({ title, rows, imageUrl, lotNo, baseSize }: { ); } -export function GuideRailSection({ bendingInfo, mapping, lotNo }: GuideRailSectionProps) { +export function GuideRailSection({ bendingInfo, mapping, lotNo, lotNoMap }: GuideRailSectionProps) { const { wall, side } = bendingInfo.guideRail; const productCode = bendingInfo.productCode; @@ -99,6 +103,7 @@ export function GuideRailSection({ bendingInfo, mapping, lotNo }: GuideRailSecti imageUrl={getBendingImageUrl('guiderail', productCode, 'wall')} lotNo={lotNo} baseSize={wall?.baseDimension || wall?.baseSize} + lotNoMap={lotNoMap} /> )} @@ -109,6 +114,7 @@ export function GuideRailSection({ bendingInfo, mapping, lotNo }: GuideRailSecti imageUrl={getBendingImageUrl('guiderail', productCode, 'side')} lotNo={lotNo} baseSize={side?.baseDimension || '135*130'} + lotNoMap={lotNoMap} /> )} diff --git a/src/components/production/WorkOrders/documents/bending/ShutterBoxSection.tsx b/src/components/production/WorkOrders/documents/bending/ShutterBoxSection.tsx index 2db3eff4..b1261d6f 100644 --- a/src/components/production/WorkOrders/documents/bending/ShutterBoxSection.tsx +++ b/src/components/production/WorkOrders/documents/bending/ShutterBoxSection.tsx @@ -8,13 +8,14 @@ */ import type { BendingInfoExtended, ShutterBoxData } from './types'; -import { buildShutterBoxRows, getBendingImageUrl, fmt, fmtWeight } from './utils'; +import { buildShutterBoxRows, getBendingImageUrl, fmt, fmtWeight, lengthToCode } from './utils'; interface ShutterBoxSectionProps { bendingInfo: BendingInfoExtended; + lotNoMap?: Record; } -function ShutterBoxSubSection({ box, index }: { box: ShutterBoxData; index: number }) { +function ShutterBoxSubSection({ box, index, lotNoMap }: { box: ShutterBoxData; index: number; lotNoMap?: Record }) { const rows = buildShutterBoxRows(box); if (rows.length === 0) return null; @@ -70,7 +71,15 @@ function ShutterBoxSubSection({ box, index }: { box: ShutterBoxData; index: numb {row.material} {row.dimension} {fmt(row.quantity)} - - + + {(() => { + const dimNum = parseInt(row.dimension); + if (!isNaN(dimNum) && !row.dimension.includes('*')) { + return lotNoMap?.[`BD-${row.lotPrefix}-${lengthToCode(dimNum)}`] || '-'; + } + return '-'; + })()} + {fmtWeight(row.weight)} ))} @@ -82,7 +91,7 @@ function ShutterBoxSubSection({ box, index }: { box: ShutterBoxData; index: numb ); } -export function ShutterBoxSection({ bendingInfo }: ShutterBoxSectionProps) { +export function ShutterBoxSection({ bendingInfo, lotNoMap }: ShutterBoxSectionProps) { const boxes = bendingInfo.shutterBox; if (!boxes || boxes.length === 0) return null; @@ -93,7 +102,7 @@ export function ShutterBoxSection({ bendingInfo }: ShutterBoxSectionProps) { {boxes.map((box, idx) => ( - + ))} ); diff --git a/src/components/production/WorkOrders/documents/bending/SmokeBarrierSection.tsx b/src/components/production/WorkOrders/documents/bending/SmokeBarrierSection.tsx index 49518a54..c985edf9 100644 --- a/src/components/production/WorkOrders/documents/bending/SmokeBarrierSection.tsx +++ b/src/components/production/WorkOrders/documents/bending/SmokeBarrierSection.tsx @@ -13,9 +13,10 @@ import { buildSmokeBarrierRows, getBendingImageUrl, fmt, fmtWeight } from './uti interface SmokeBarrierSectionProps { bendingInfo: BendingInfoExtended; + lotNoMap?: Record; } -export function SmokeBarrierSection({ bendingInfo }: SmokeBarrierSectionProps) { +export function SmokeBarrierSection({ bendingInfo, lotNoMap }: SmokeBarrierSectionProps) { const rows = buildSmokeBarrierRows(bendingInfo.smokeBarrier); if (rows.length === 0) return null; @@ -55,7 +56,9 @@ export function SmokeBarrierSection({ bendingInfo }: SmokeBarrierSectionProps) { {row.material} {fmt(row.length)} {fmt(row.quantity)} - - + + {lotNoMap?.[`BD-${row.lotCode}`] || '-'} + {fmtWeight(row.weight)} ))} diff --git a/src/components/production/WorkOrders/documents/bending/utils.ts b/src/components/production/WorkOrders/documents/bending/utils.ts index 85152e8d..1b297adb 100644 --- a/src/components/production/WorkOrders/documents/bending/utils.ts +++ b/src/components/production/WorkOrders/documents/bending/utils.ts @@ -565,3 +565,8 @@ export function fmt(v?: number): string { export function fmtWeight(v: number): string { return v > 0 ? v.toFixed(2) : '-'; } + +/** 길이(mm) → 길이코드 변환 (PrefixResolver.lengthToCode 프론트 버전) */ +export function lengthToCode(lengthMm: number): string { + return getSLengthCode(lengthMm, '') || String(lengthMm); +} diff --git a/src/components/production/WorkerScreen/MaterialInputModal.tsx b/src/components/production/WorkerScreen/MaterialInputModal.tsx index 7757e592..7da630c3 100644 --- a/src/components/production/WorkerScreen/MaterialInputModal.tsx +++ b/src/components/production/WorkerScreen/MaterialInputModal.tsx @@ -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(); + // dynamic_bom 항목은 (itemId, workOrderItemId) 쌍으로 그룹핑 + const groups = new Map(); 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 ( -
+
{/* 품목 그룹 헤더 */}
+ {group.category && ( + + {group.category === 'guideRail' ? '가이드레일' : + group.category === 'bottomBar' ? '하단마감재' : + group.category === 'shutterBox' ? '셔터박스' : + group.category === 'smokeBarrier' ? '연기차단재' : + group.category} + + )} {group.materialName} diff --git a/src/components/production/WorkerScreen/WorkLogModal.tsx b/src/components/production/WorkerScreen/WorkLogModal.tsx index 8c8c17f1..278de2ac 100644 --- a/src/components/production/WorkerScreen/WorkLogModal.tsx +++ b/src/components/production/WorkerScreen/WorkLogModal.tsx @@ -209,8 +209,15 @@ export function WorkLogModal({ return ; case 'slat': return ; - case 'bending': - return ; + case 'bending': { + const lotNoMap: Record = {}; + for (const lot of materialLots) { + if (lot.item_code.startsWith('BD-')) { + lotNoMap[lot.item_code] = lot.lot_no; + } + } + return ; + } default: return ; } diff --git a/src/components/production/WorkerScreen/actions.ts b/src/components/production/WorkerScreen/actions.ts index 5d48fce8..6c229917 100644 --- a/src/components/production/WorkerScreen/actions.ts +++ b/src/components/production/WorkerScreen/actions.ts @@ -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({ 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`,