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