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:
2026-02-22 02:13:13 +09:00
parent e5b706249a
commit a19263334e
9 changed files with 109 additions and 27 deletions

View File

@@ -29,9 +29,10 @@ import { ProductionSummarySection } from './bending/ProductionSummarySection';
interface BendingWorkLogContentProps {
data: WorkOrder;
lotNoMap?: Record<string, string>; // 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}
/>
<BottomBarSection
bendingInfo={bendingInfo!}
mapping={mapping}
lotNoMap={lotNoMap}
/>
<ShutterBoxSection
bendingInfo={bendingInfo!}
lotNoMap={lotNoMap}
/>
<SmokeBarrierSection
bendingInfo={bendingInfo!}
lotNoMap={lotNoMap}
/>
</>
) : (

View File

@@ -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<string, string>;
}
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
<td className="border border-gray-400 px-1 py-0.5 text-center">{row.material}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.length)}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">-</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">
{lotNoMap?.[`BD-${row.lotPrefix}-${lengthToCode(row.length)}`] || '-'}
</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
</tr>
))}

View File

@@ -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<string, string>; // 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<string, string>;
}) {
if (rows.length === 0) return null;
@@ -60,7 +62,9 @@ function PartTable({ title, rows, imageUrl, lotNo, baseSize }: {
{row.partName === '하부BASE' ? (baseSize || '-') : fmt(row.length)}
</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">-</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">
{lotNoMap?.[`BD-${row.lotPrefix}-${lengthToCode(row.length)}`] || '-'}
</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
</tr>
))}
@@ -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}
/>
)}
</div>

View File

@@ -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<string, string>;
}
function ShutterBoxSubSection({ box, index }: { box: ShutterBoxData; index: number }) {
function ShutterBoxSubSection({ box, index, lotNoMap }: { box: ShutterBoxData; index: number; lotNoMap?: Record<string, string> }) {
const rows = buildShutterBoxRows(box);
if (rows.length === 0) return null;
@@ -70,7 +71,15 @@ function ShutterBoxSubSection({ box, index }: { box: ShutterBoxData; index: numb
<td className="border border-gray-400 px-1 py-0.5 text-center">{row.material}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{row.dimension}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">-</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">
{(() => {
const dimNum = parseInt(row.dimension);
if (!isNaN(dimNum) && !row.dimension.includes('*')) {
return lotNoMap?.[`BD-${row.lotPrefix}-${lengthToCode(dimNum)}`] || '-';
}
return '-';
})()}
</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
</tr>
))}
@@ -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) {
</div>
{boxes.map((box, idx) => (
<ShutterBoxSubSection key={idx} box={box} index={idx} />
<ShutterBoxSubSection key={idx} box={box} index={idx} lotNoMap={lotNoMap} />
))}
</div>
);

View File

@@ -13,9 +13,10 @@ import { buildSmokeBarrierRows, getBendingImageUrl, fmt, fmtWeight } from './uti
interface SmokeBarrierSectionProps {
bendingInfo: BendingInfoExtended;
lotNoMap?: Record<string, string>;
}
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) {
<td className="border border-gray-400 px-1 py-0.5 text-center">{row.material}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.length)}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">-</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">
{lotNoMap?.[`BD-${row.lotCode}`] || '-'}
</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
</tr>
))}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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} />;
}

View File

@@ -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`,