feat(WEB): 절곡 작업일지 완전 재구현 + 슬랫 입고 LOT NO 개소별 표시 수정

- 절곡 작업일지: PHP viewBendingWork_slat.php 기준 4개 카테고리 섹션 구현
  (가이드레일/하단마감재/셔터박스/연기차단재) + SUS/EGI 무게 계산
- 슬랫 작업일지: 입고 LOT NO가 모든 행에 동일하게 표시되던 버그 수정
  → items.materialInputs.stockLot 데이터 활용하여 개소별 LOT 표시
- types.ts: WorkOrderItemApi에 material_inputs 필드 추가,
  WorkOrderItem에 bendingInfo/materialInputLots 필드 추가 및 transform 매핑
This commit is contained in:
2026-02-19 22:29:51 +09:00
parent 77516a4dff
commit 492e4c02aa
10 changed files with 1198 additions and 77 deletions

View File

@@ -3,16 +3,29 @@
/**
* 절곡 작업일지 문서 콘텐츠
*
* 기획서 기준 구성:
* - 헤더: "작업일지 (절곡)" + 문서번호/작성일자 + 결재란(결재|작성/승인/승인/승인)
* - 신청업체(수주일,수주처,담당자,연락처) / 신청내용(현장명,작업일자,제품LOT NO,생산담당자,출고예정일)
* - 제품명 / 재질 / 마감 / 유형 테이블
* - 작업내역: 유형명, 세부품명, 재질, 입고 & 생산 LOT NO, 길이/규격, 수량
* - 생산량 합계 [kg]: SUS / EGI
* PHP viewBendingWork_slat.php와 동일한 구조로 완전 재구현
*
* 구성:
* - 헤더: "작업일지 (절곡)" + 문서번호/작성일자 + 결재란
* - 신청업체/신청내용 테이블
* - 제품 정보 테이블 (제품명/재질/마감/유형)
* - 4개 카테고리 섹션:
* 1. 가이드레일 (벽면형/측면형)
* 2. 하단마감재
* 3. 셔터박스 (방향별)
* 4. 연기차단재 (W50/W80)
* - 생산량 합계 [kg] SUS/EGI
*/
import type { WorkOrder } from '../types';
import { SectionHeader, ConstructionApprovalTable } from '@/components/document-system';
import type { BendingInfoExtended } from './bending/types';
import { ConstructionApprovalTable } from '@/components/document-system';
import { getMaterialMapping, calculateProductionSummary } from './bending/utils';
import { GuideRailSection } from './bending/GuideRailSection';
import { BottomBarSection } from './bending/BottomBarSection';
import { ShutterBoxSection } from './bending/ShutterBoxSection';
import { SmokeBarrierSection } from './bending/SmokeBarrierSection';
import { ProductionSummarySection } from './bending/ProductionSummarySection';
interface BendingWorkLogContentProps {
data: WorkOrder;
@@ -43,22 +56,32 @@ export function BendingWorkLogContent({ data: order }: BendingWorkLogContentProp
}).replace(/\. /g, '-').replace('.', '')
: '-';
// 빈 행 수 (기획서에 여러 빈 행 표시)
const EMPTY_ROWS = 4;
// 첫 번째 아이템의 bendingInfo에서 절곡 데이터 추출
const firstBendingInfo = items.find(item => item.bendingInfo)?.bendingInfo as BendingInfoExtended | undefined;
// bendingInfo가 없으면 빈 상태 표시
const hasBendingData = !!firstBendingInfo?.productCode;
// 재질 매핑
const mapping = hasBendingData
? getMaterialMapping(firstBendingInfo!.productCode, firstBendingInfo!.finishMaterial)
: null;
// 생산량 합계
const summary = hasBendingData && mapping
? calculateProductionSummary(firstBendingInfo!, mapping)
: { susTotal: 0, egiTotal: 0, grandTotal: 0 };
return (
<div className="p-6 bg-white">
{/* ===== 헤더 영역 ===== */}
<div className="flex justify-between items-start mb-6">
{/* 좌측: 제목 + 문서번호 */}
<div>
<h1 className="text-2xl font-bold"> ()</h1>
<p className="text-xs text-gray-500 mt-1 whitespace-nowrap">
: {documentNo} | : {fullDate}
</p>
</div>
{/* 우측: 결재란 */}
<ConstructionApprovalTable
approvers={{ writer: { name: primaryAssignee } }}
className="flex-shrink-0"
@@ -118,73 +141,58 @@ export function BendingWorkLogContent({ data: order }: BendingWorkLogContentProp
</thead>
<tbody>
<tr>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">
{hasBendingData ? firstBendingInfo!.productCode : '-'}
</td>
<td className="border border-gray-400 p-2">
{mapping?.bodyMaterial || '-'}
</td>
<td className="border border-gray-400 p-2">
{hasBendingData ? firstBendingInfo!.finishMaterial : '-'}
</td>
<td className="border border-gray-400 p-2">
{hasBendingData ? firstBendingInfo!.common.type : '-'}
</td>
</tr>
</tbody>
</table>
{/* ===== 작업내역 ===== */}
<SectionHeader variant="dark"></SectionHeader>
<table className="w-full border-collapse text-xs mb-6">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-2"></th>
<th className="border border-gray-400 p-2"></th>
<th className="border border-gray-400 p-2"></th>
<th className="border border-gray-400 p-2"> &amp; LOT NO</th>
<th className="border border-gray-400 p-2">/</th>
<th className="border border-gray-400 p-2 w-16"></th>
</tr>
</thead>
<tbody>
{items.length > 0 ? (
items.map((item, idx) => (
<tr key={item.id}>
<td className="border border-gray-400 p-2">{item.productName}</td>
<td className="border border-gray-400 p-2">-</td>
<td className="border border-gray-400 p-2 text-center">-</td>
<td className="border border-gray-400 p-2 text-center">{order.lotNo}</td>
<td className="border border-gray-400 p-2 text-center">{item.specification || '-'}</td>
<td className="border border-gray-400 p-2 text-center">{item.quantity}</td>
</tr>
))
) : (
Array.from({ length: EMPTY_ROWS }).map((_, idx) => (
<tr key={idx}>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
</tr>
))
)}
</tbody>
</table>
{/* ===== 4개 카테고리 섹션 ===== */}
{hasBendingData && mapping ? (
<>
<GuideRailSection
bendingInfo={firstBendingInfo!}
mapping={mapping}
lotNo={order.lotNo}
/>
<BottomBarSection
bendingInfo={firstBendingInfo!}
mapping={mapping}
/>
<ShutterBoxSection
bendingInfo={firstBendingInfo!}
/>
<SmokeBarrierSection
bendingInfo={firstBendingInfo!}
/>
</>
) : (
<div className="text-center text-gray-400 text-sm py-8 border border-gray-300 mb-6">
. (bending_info )
</div>
)}
{/* ===== 생산량 합계 [kg] ===== */}
<table className="w-full border-collapse text-xs mb-6">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-2"> [kg]</th>
<th className="border border-gray-400 p-2">SUS</th>
<th className="border border-gray-400 p-2">EGI</th>
</tr>
</thead>
{/* ===== 생산량 합계 ===== */}
<ProductionSummarySection summary={summary} />
{/* ===== 비고 ===== */}
<table className="w-full border-collapse text-xs">
<tbody>
<tr>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
</tr>
<tr>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 p-2">&nbsp;</td>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-40 align-top"></td>
<td className="border border-gray-400 px-3 py-3 min-h-[60px]">
{order.note || ''}
</td>
</tr>
</tbody>
</table>

View File

@@ -65,10 +65,6 @@ export function SlatWorkLogContent({ data: order, materialLots = [] }: SlatWorkL
}).replace(/\. /g, '-').replace('.', '')
: '-';
// 투입 LOT 번호 (중복 제거)
const lotNoList = materialLots.map(lot => lot.lot_no).filter(Boolean);
const lotNoDisplay = lotNoList.length > 0 ? lotNoList.join(', ') : '';
// 슬랫 계산: 매수(세로) = floor(height / 72) + 1
const calcSlatCount = (height?: number) => height ? Math.floor(height / 72) + 1 : 0;
@@ -198,7 +194,7 @@ export function SlatWorkLogContent({ data: order, materialLots = [] }: SlatWorkL
return (
<tr key={item.id}>
<td className="border border-gray-400 px-2 py-1 text-center">{idx + 1}</td>
<td className="border border-gray-400 px-2 py-1 text-center">{lotNoDisplay}</td>
<td className="border border-gray-400 px-2 py-1 text-center">{item.materialInputLots?.join(', ') || ''}</td>
<td className="border border-gray-400 px-2 py-1 text-center">{glassQty > 0 ? fmt(glassQty) : '-'}</td>
<td className="border border-gray-400 px-2 py-1">{item.productName}</td>
<td className="border border-gray-400 px-2 py-1 text-center whitespace-nowrap">{fmt(item.width)}</td>

View File

@@ -0,0 +1,70 @@
'use client';
/**
* 하단마감재 섹션
*
* ①하단마감재: 3000mm, 4000mm 수량
* ④별도마감재: SUS마감 시에만 표시
*/
import type { BendingInfoExtended, MaterialMapping } from './types';
import { buildBottomBarRows, getBendingImageUrl, fmt, fmtWeight } from './utils';
interface BottomBarSectionProps {
bendingInfo: BendingInfoExtended;
mapping: MaterialMapping;
}
export function BottomBarSection({ bendingInfo, mapping }: BottomBarSectionProps) {
const rows = buildBottomBarRows(bendingInfo.bottomBar, mapping);
if (rows.length === 0) return null;
return (
<div className="mb-6">
<div className="bg-gray-800 text-white text-xs font-bold px-3 py-1.5 mb-2">
2.
</div>
<div className="flex gap-3">
{/* 좌측: 이미지 */}
<div className="flex-shrink-0 w-48">
<img
src={getBendingImageUrl('bottombar', bendingInfo.productCode)}
alt="하단마감재"
className="w-full border border-gray-300"
/>
</div>
{/* 우측: 테이블 */}
<div className="flex-1">
<table className="w-full border-collapse text-[10px]">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 px-1 py-0.5"></th>
<th className="border border-gray-400 px-1 py-0.5"></th>
<th className="border border-gray-400 px-1 py-0.5"></th>
<th className="border border-gray-400 px-1 py-0.5"></th>
<th className="border border-gray-400 px-1 py-0.5">LOT NO</th>
<th className="border border-gray-400 px-1 py-0.5">(kg)</th>
</tr>
</thead>
<tbody>
{rows.map((row, idx) => (
<tr key={idx}>
<td className="border border-gray-400 px-1 py-0.5">{row.partName}</td>
<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">
{row.lotPrefix}-
</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
/**
* 가이드레일 섹션
*
* 1.1 벽면형 [130*75]: 이미지 + 세부품명/재질/길이/수량/LOT NO/무게 테이블
* 1.2 측면형 [130*125]: 동일 구조
*/
import type { BendingInfoExtended, MaterialMapping, GuideRailPartRow } from './types';
import { buildWallGuideRailRows, buildSideGuideRailRows, getBendingImageUrl, fmt, fmtWeight } from './utils';
interface GuideRailSectionProps {
bendingInfo: BendingInfoExtended;
mapping: MaterialMapping;
lotNo: string;
}
function PartTable({ title, rows, imageUrl, lotNo, baseSize }: {
title: string;
rows: GuideRailPartRow[];
imageUrl: string;
lotNo: string;
baseSize?: string;
}) {
if (rows.length === 0) return null;
return (
<div className="mb-4">
<div className="text-xs font-bold mb-1">{title}</div>
<div className="flex gap-3">
{/* 좌측: 이미지 + LOT */}
<div className="flex-shrink-0 w-48">
<img src={imageUrl} alt={title} className="w-full border border-gray-300" />
<div className="text-[10px] text-gray-500 mt-1">
&amp; LOT NO:<br />
<span className="text-gray-400">_______________</span>
</div>
</div>
{/* 우측: 테이블 */}
<div className="flex-1">
<table className="w-full border-collapse text-[10px]">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 px-1 py-0.5"></th>
<th className="border border-gray-400 px-1 py-0.5"></th>
<th className="border border-gray-400 px-1 py-0.5"></th>
<th className="border border-gray-400 px-1 py-0.5"></th>
<th className="border border-gray-400 px-1 py-0.5">LOT NO</th>
<th className="border border-gray-400 px-1 py-0.5">(kg)</th>
</tr>
</thead>
<tbody>
{rows.map((row, idx) => (
<tr key={idx}>
<td className="border border-gray-400 px-1 py-0.5">{row.partName}</td>
<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.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">
{row.lotPrefix}-
</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
export function GuideRailSection({ bendingInfo, mapping, lotNo }: GuideRailSectionProps) {
const { wall, side } = bendingInfo.guideRail;
const productCode = bendingInfo.productCode;
const wallRows = wall
? buildWallGuideRailRows(wall.lengthData, wall.baseSize, mapping)
: [];
const sideRows = side
? buildSideGuideRailRows(side.lengthData, mapping)
: [];
if (wallRows.length === 0 && sideRows.length === 0) return null;
return (
<div className="mb-6">
<div className="bg-gray-800 text-white text-xs font-bold px-3 py-1.5 mb-2">
1.
</div>
{wallRows.length > 0 && (
<PartTable
title="1.1 벽면형 [130*75]"
rows={wallRows}
imageUrl={getBendingImageUrl('guiderail', productCode, 'wall')}
lotNo={lotNo}
baseSize={wall?.baseSize}
/>
)}
{sideRows.length > 0 && (
<PartTable
title="1.2 측면형 [130*125]"
rows={sideRows}
imageUrl={getBendingImageUrl('guiderail', productCode, 'side')}
lotNo={lotNo}
baseSize="135*130"
/>
)}
</div>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
/**
* 생산량 합계 섹션
*
* SUS(7.93g/cm3) + EGI(7.85g/cm3) 무게 합계
*/
import type { ProductionSummary } from './types';
interface ProductionSummarySectionProps {
summary: ProductionSummary;
}
export function ProductionSummarySection({ summary }: ProductionSummarySectionProps) {
return (
<div className="mb-6">
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 px-3 py-2"> [kg]</th>
<th className="border border-gray-400 px-3 py-2">SUS</th>
<th className="border border-gray-400 px-3 py-2">EGI</th>
<th className="border border-gray-400 px-3 py-2"></th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-400 px-3 py-2 text-center font-medium"></td>
<td className="border border-gray-400 px-3 py-2 text-center">
{summary.susTotal > 0 ? `${summary.susTotal.toFixed(2)} kg` : '-'}
</td>
<td className="border border-gray-400 px-3 py-2 text-center">
{summary.egiTotal > 0 ? `${summary.egiTotal.toFixed(2)} kg` : '-'}
</td>
<td className="border border-gray-400 px-3 py-2 text-center font-bold">
{summary.grandTotal > 0 ? `${summary.grandTotal.toFixed(2)} kg` : '-'}
</td>
</tr>
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,102 @@
'use client';
/**
* 셔터박스 섹션
*
* 방향별(양면/밑면/후면) 구성요소 + SVG 다이어그램
* PHP에서는 GD로 동적 이미지 생성 → React에서는 source 이미지 + 치수 텍스트 오버레이
*/
import type { BendingInfoExtended, ShutterBoxData } from './types';
import { buildShutterBoxRows, getBendingImageUrl, fmt, fmtWeight } from './utils';
interface ShutterBoxSectionProps {
bendingInfo: BendingInfoExtended;
}
function ShutterBoxSubSection({ box, index }: { box: ShutterBoxData; index: number }) {
const rows = buildShutterBoxRows(box);
if (rows.length === 0) return null;
const directionMap: Record<string, 'both' | 'bottom' | 'rear'> = {
'양면': 'both',
'밑면': 'bottom',
'후면': 'rear',
};
const imageType = directionMap[box.direction] || 'both';
return (
<div className="mb-4">
<div className="text-xs font-bold mb-1">
3.{index + 1} [{box.size}] {box.direction}
</div>
<div className="flex gap-3">
{/* 좌측: 이미지 (source 이미지에 치수 오버레이) */}
<div className="flex-shrink-0 w-48">
<div className="relative">
<img
src={getBendingImageUrl('box', '', imageType)}
alt={`셔터박스 ${box.direction}`}
className="w-full border border-gray-300"
/>
{/* 치수 텍스트 오버레이 */}
<div className="absolute bottom-1 left-1 text-[8px] bg-white/80 px-1 rounded">
{box.size} ({box.direction})
</div>
</div>
<div className="text-[10px] text-gray-500 mt-1">
: {box.railWidth}mm
</div>
</div>
{/* 우측: 테이블 */}
<div className="flex-1">
<table className="w-full border-collapse text-[10px]">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 px-1 py-0.5"></th>
<th className="border border-gray-400 px-1 py-0.5"></th>
<th className="border border-gray-400 px-1 py-0.5">/</th>
<th className="border border-gray-400 px-1 py-0.5"></th>
<th className="border border-gray-400 px-1 py-0.5">LOT NO</th>
<th className="border border-gray-400 px-1 py-0.5">(kg)</th>
</tr>
</thead>
<tbody>
{rows.map((row, idx) => (
<tr key={idx}>
<td className="border border-gray-400 px-1 py-0.5">{row.partName}</td>
<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">
{row.lotPrefix}-
</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
export function ShutterBoxSection({ bendingInfo }: ShutterBoxSectionProps) {
const boxes = bendingInfo.shutterBox;
if (!boxes || boxes.length === 0) return null;
return (
<div className="mb-6">
<div className="bg-gray-800 text-white text-xs font-bold px-3 py-1.5 mb-2">
3.
</div>
{boxes.map((box, idx) => (
<ShutterBoxSubSection key={idx} box={box} index={idx} />
))}
</div>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
/**
* 연기차단재 섹션
*
* 레일용 [W50]: 길이별 수량 (2438, 3000, 3500, 4000, 4300mm)
* 케이스용 [W80]: 3000mm 고정
* 재질: 모두 EGI 0.8T, 폭 26mm
*/
import type { BendingInfoExtended } from './types';
import { buildSmokeBarrierRows, getBendingImageUrl, fmt, fmtWeight } from './utils';
interface SmokeBarrierSectionProps {
bendingInfo: BendingInfoExtended;
}
export function SmokeBarrierSection({ bendingInfo }: SmokeBarrierSectionProps) {
const rows = buildSmokeBarrierRows(bendingInfo.smokeBarrier);
if (rows.length === 0) return null;
return (
<div className="mb-6">
<div className="bg-gray-800 text-white text-xs font-bold px-3 py-1.5 mb-2">
4.
</div>
<div className="flex gap-3">
{/* 좌측: 이미지 */}
<div className="flex-shrink-0 w-48">
<img
src={getBendingImageUrl('smokebarrier', '')}
alt="연기차단재"
className="w-full border border-gray-300"
/>
</div>
{/* 우측: 테이블 */}
<div className="flex-1">
<table className="w-full border-collapse text-[10px]">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 px-1 py-0.5"></th>
<th className="border border-gray-400 px-1 py-0.5"></th>
<th className="border border-gray-400 px-1 py-0.5"></th>
<th className="border border-gray-400 px-1 py-0.5"></th>
<th className="border border-gray-400 px-1 py-0.5">LOT NO</th>
<th className="border border-gray-400 px-1 py-0.5">(kg)</th>
</tr>
</thead>
<tbody>
{rows.map((row, idx) => (
<tr key={idx}>
<td className="border border-gray-400 px-1 py-0.5">{row.partName}</td>
<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">{row.lotCode}</td>
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,130 @@
/**
* 절곡 작업일지 전용 타입 정의
*
* PHP viewBendingWork_slat.php 기반
* 가이드레일 / 하단마감재 / 셔터박스 / 연기차단재 4개 카테고리
*/
// 길이별 수량 데이터
export interface LengthQuantity {
length: number; // mm (2438, 3000, 3500, 4000, 4300 등)
quantity: number;
}
// 가이드레일 타입별 데이터
export interface GuideRailTypeData {
lengthData: LengthQuantity[];
baseSize: string; // "135*80" 또는 "135*130"
}
// 셔터박스 데이터
export interface ShutterBoxData {
size: string; // "500*380" 등
direction: string; // "양면" | "밑면" | "후면"
railWidth: number;
frontBottom: number;
coverQty: number; // 상부덮개 수량
finCoverQty: number; // 마구리 수량
lengthData: LengthQuantity[];
}
// 확장된 bending_info 구조 (options JSON에 저장)
export interface BendingInfoExtended {
// 기존 필드 (유지)
drawingUrl?: string;
common: {
kind: string; // "혼합형 120X70"
type: string; // "혼합형(130*75)(130*125)" | "벽면형(130*75)" | "측면형(130*125)"
lengthQuantities: LengthQuantity[];
};
detailParts: Array<{
partName: string; // "엘바", "하장바"
material: string; // "EGI 1.6T"
barcyInfo: string; // "16 I 75"
}>;
// 신규 필드
productCode: string; // "KTE01", "KQTS01", "KSE01", "KSS01", "KWE01"
finishMaterial: string; // "EGI마감", "SUS마감"
guideRail: {
wall: GuideRailTypeData | null;
side: GuideRailTypeData | null;
};
bottomBar: {
material: string; // "EGI 1.55T" 또는 "SUS 1.5T"
extraFinish: string; // "SUS 1.2T" 또는 "없음"
length3000Qty: number;
length4000Qty: number;
};
shutterBox: ShutterBoxData[];
smokeBarrier: {
w50: LengthQuantity[]; // 레일용 W50
w80Qty: number; // 케이스용 W80 수량
};
}
// 재질 매핑 결과
export interface MaterialMapping {
guideRailFinish: string; // ①②마감재
bodyMaterial: string; // ③본체, ④C형, ⑤D형
guideRailExtraFinish: string; // ⑥별도마감 (SUS마감 시만)
bottomBarFinish: string; // 하단마감재
bottomBarExtraFinish: string; // 별도마감재
}
// 무게 계산 결과
export interface WeightResult {
weight: number; // kg
type: 'SUS' | 'EGI';
}
// 가이드레일 파트 행
export interface GuideRailPartRow {
partName: string; // ①②마감재, ③본체, ④C형, ⑤D형, ⑥별도마감, 하부BASE
lotPrefix: string; // XX, RT, RC, RD, RS (벽면) / SS, ST, SC, SD (측면)
material: string; // SUS 1.2T, EGI 1.55T
length: number; // mm
quantity: number;
weight: number; // kg
}
// 하단마감재 파트 행
export interface BottomBarPartRow {
partName: string; // ①하단마감재, ④별도마감재
lotPrefix: string; // TE(EGI)/TS(SUS)
material: string;
length: number; // 3000 또는 4000
quantity: number;
weight: number;
}
// 셔터박스 구성요소 행
export interface ShutterBoxPartRow {
partName: string; // ①전면부, ②린텔부 등
lotPrefix: string;
material: string; // EGI 1.55T
dimension: string; // 길이 또는 치수 표시
quantity: number;
weight: number;
}
// 연기차단재 파트 행
export interface SmokeBarrierPartRow {
partName: string; // 레일용 [W50], 케이스용 [W80]
material: string; // EGI 0.8T
length: number;
quantity: number;
weight: number;
lotCode: string; // GI-53, GI-54, GI-83, GI-84
}
// 생산량 합계
export interface ProductionSummary {
susTotal: number;
egiTotal: number;
grandTotal: number;
}

View File

@@ -0,0 +1,567 @@
/**
* 절곡 작업일지 유틸리티 함수
*
* PHP viewBendingWork_slat.php의 calWeight, 재질매핑, 길이버킷팅 등을 TypeScript로 구현
*/
import type {
WeightResult,
MaterialMapping,
LengthQuantity,
GuideRailPartRow,
BottomBarPartRow,
ShutterBoxPartRow,
SmokeBarrierPartRow,
BendingInfoExtended,
ShutterBoxData,
ProductionSummary,
} from './types';
// ============================================================
// 상수
// ============================================================
const SUS_DENSITY = 7.93; // g/cm3
const EGI_DENSITY = 7.85; // g/cm3
// 가이드레일
const WALL_PART_WIDTH = 412; // mm - 벽면형 파트 폭
const SIDE_PART_WIDTH = 462; // mm - 측면형 파트 폭
const WALL_BASE_HEIGHT_MIXED = 80; // 혼합형 벽면 하부BASE 높이
const WALL_BASE_HEIGHT_ONLY = 130; // 벽면형 단독 하부BASE 높이
const SIDE_BASE_HEIGHT = 130; // 측면형 하부BASE 높이
const BASE_WIDTH = 135; // 하부BASE 폭
// 하단마감재
const BOTTOM_BAR_WIDTH = 184; // mm
const EXTRA_FINISH_WIDTH = 238; // mm
// 연기차단재
const SMOKE_BARRIER_WIDTH = 26; // mm
// 셔터박스
const BOX_FINISH_MATERIAL = 'EGI 1.55T';
const BOX_COVER_LENGTH = 1219; // 상부덮개 고정 길이
// 길이 버킷
const GUIDE_RAIL_LENGTH_BUCKETS = [2438, 3000, 3500, 4000, 4300];
const SHUTTER_BOX_LENGTH_BUCKETS = [1219, 2438, 3000, 3500, 4000, 4150];
const SMOKE_W50_LENGTH_BUCKETS = [2438, 3000, 3500, 4000, 4300];
// ============================================================
// 핵심 함수: calWeight (PHP Lines 27-55)
// ============================================================
/**
* 무게 계산
* volume_cm3 = (thickness * width * height) / 1000
* weight_kg = (volume_cm3 * density) / 1000
*/
export function calcWeight(material: string, width: number, height: number): WeightResult {
const thicknessMatch = material.match(/(\d+(\.\d+)?)/);
const thickness = thicknessMatch ? parseFloat(thicknessMatch[1]) : 0;
const isSUS = material.toUpperCase().includes('SUS');
const density = isSUS ? SUS_DENSITY : EGI_DENSITY;
const volume_cm3 = (thickness * width * height) / 1000;
const weight_kg = (volume_cm3 * density) / 1000;
return {
weight: Math.round(weight_kg * 100) / 100,
type: isSUS ? 'SUS' : 'EGI',
};
}
// ============================================================
// 제품코드별 재질 매핑 (PHP Lines 330-366)
// ============================================================
export function getMaterialMapping(productCode: string, finishMaterial: string): MaterialMapping {
// Group 1: KQTS01 - SUS 마감
if (productCode === 'KQTS01') {
return {
guideRailFinish: 'SUS 1.2T',
bodyMaterial: 'EGI 1.55T',
guideRailExtraFinish: '',
bottomBarFinish: 'SUS 1.5T',
bottomBarExtraFinish: '없음',
};
}
// Group 2: KTE01 - 마감유형에 따라 분기
if (productCode === 'KTE01') {
const isSUS = finishMaterial === 'SUS마감';
return {
guideRailFinish: 'EGI 1.55T',
bodyMaterial: 'EGI 1.55T',
guideRailExtraFinish: isSUS ? 'SUS 1.2T' : '',
bottomBarFinish: 'EGI 1.55T',
bottomBarExtraFinish: isSUS ? 'SUS 1.2T' : '없음',
};
}
// 기타 제품코드 (KSE01, KSS01, KSS02, KWE01 등) - EGI 기본
return {
guideRailFinish: 'EGI 1.55T',
bodyMaterial: 'EGI 1.55T',
guideRailExtraFinish: '',
bottomBarFinish: 'EGI 1.55T',
bottomBarExtraFinish: '없음',
};
}
// ============================================================
// 이미지 URL 빌더
// ============================================================
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://api.sam.kr';
export function getBendingImageUrl(
category: 'guiderail' | 'bottombar' | 'smokebarrier' | 'box',
productCode: string,
type?: 'wall' | 'side' | 'both' | 'bottom' | 'rear',
): string {
switch (category) {
case 'guiderail': {
const size = ['KQTS01', 'KTE01'].includes(productCode)
? (type === 'wall' ? '130x75' : '130x125')
: (type === 'wall' ? '120x70' : '120x120');
return `${API_BASE}/images/bending/guiderail/guiderail_${productCode}_${type}_${size}.jpg`;
}
case 'bottombar':
return `${API_BASE}/images/bending/bottombar/bottombar_${productCode}.jpg`;
case 'smokebarrier':
return `${API_BASE}/images/bending/part/smokeban.jpg`;
case 'box':
return `${API_BASE}/images/bending/box/box_${type || 'both'}.jpg`;
default:
return '';
}
}
// ============================================================
// getSLengthCode (PHP Lines 56-100)
// ============================================================
export function getSLengthCode(length: number, category: string): string | null {
if (category === '연기차단재50') {
return length === 3000 ? '53' : length === 4000 ? '54' : null;
}
if (category === '연기차단재80') {
return length === 3000 ? '83' : length === 4000 ? '84' : null;
}
const map: Record<number, string> = {
1219: '12', 2438: '24', 3000: '30', 3500: '35',
4000: '40', 4150: '41', 4200: '42', 4300: '43',
};
return map[length] || null;
}
// ============================================================
// 가이드레일 파트 행 생성
// ============================================================
/**
* 벽면형 가이드레일 파트 행 생성
* 파트: ①②마감재, ③본체, ④C형, ⑤D형, (⑥별도마감), 하부BASE
*/
export function buildWallGuideRailRows(
lengthData: LengthQuantity[],
baseSize: string,
mapping: MaterialMapping,
): GuideRailPartRow[] {
const rows: GuideRailPartRow[] = [];
const baseHeight = baseSize === '135*80' ? WALL_BASE_HEIGHT_MIXED : WALL_BASE_HEIGHT_ONLY;
for (const ld of lengthData) {
if (ld.quantity <= 0) continue;
// ①②마감재
const finishW = calcWeight(mapping.guideRailFinish, WALL_PART_WIDTH, ld.length);
rows.push({
partName: '①②마감재', lotPrefix: 'XX', material: mapping.guideRailFinish,
length: ld.length, quantity: ld.quantity, weight: Math.round(finishW.weight * ld.quantity * 100) / 100,
});
// ③본체
const bodyW = calcWeight(mapping.bodyMaterial, WALL_PART_WIDTH, ld.length);
rows.push({
partName: '③본체', lotPrefix: 'RT', material: mapping.bodyMaterial,
length: ld.length, quantity: ld.quantity, weight: Math.round(bodyW.weight * ld.quantity * 100) / 100,
});
// ④C형
rows.push({
partName: '④C형', lotPrefix: 'RC', material: mapping.bodyMaterial,
length: ld.length, quantity: ld.quantity, weight: Math.round(bodyW.weight * ld.quantity * 100) / 100,
});
// ⑤D형
rows.push({
partName: '⑤D형', lotPrefix: 'RD', material: mapping.bodyMaterial,
length: ld.length, quantity: ld.quantity, weight: Math.round(bodyW.weight * ld.quantity * 100) / 100,
});
// ⑥별도마감 (SUS마감 시만)
if (mapping.guideRailExtraFinish) {
const extraW = calcWeight(mapping.guideRailExtraFinish, WALL_PART_WIDTH, ld.length);
rows.push({
partName: '⑥별도마감', lotPrefix: 'RS', material: mapping.guideRailExtraFinish,
length: ld.length, quantity: ld.quantity, weight: Math.round(extraW.weight * ld.quantity * 100) / 100,
});
}
}
// 하부BASE (길이 데이터와 무관하게 1행)
const totalQty = lengthData.reduce((sum, ld) => sum + ld.quantity, 0);
if (totalQty > 0) {
const baseW = calcWeight('EGI 1.55T', BASE_WIDTH, baseHeight);
rows.push({
partName: '하부BASE', lotPrefix: 'XX', material: 'EGI 1.55T',
length: 0, quantity: totalQty, weight: Math.round(baseW.weight * totalQty * 100) / 100,
});
}
return rows;
}
/**
* 측면형 가이드레일 파트 행 생성
*/
export function buildSideGuideRailRows(
lengthData: LengthQuantity[],
mapping: MaterialMapping,
): GuideRailPartRow[] {
const rows: GuideRailPartRow[] = [];
for (const ld of lengthData) {
if (ld.quantity <= 0) continue;
const finishW = calcWeight(mapping.guideRailFinish, SIDE_PART_WIDTH, ld.length);
rows.push({
partName: '①②마감재', lotPrefix: 'SS', material: mapping.guideRailFinish,
length: ld.length, quantity: ld.quantity, weight: Math.round(finishW.weight * ld.quantity * 100) / 100,
});
const bodyW = calcWeight(mapping.bodyMaterial, SIDE_PART_WIDTH, ld.length);
rows.push({
partName: '③본체', lotPrefix: 'ST', material: mapping.bodyMaterial,
length: ld.length, quantity: ld.quantity, weight: Math.round(bodyW.weight * ld.quantity * 100) / 100,
});
rows.push({
partName: '④C형', lotPrefix: 'SC', material: mapping.bodyMaterial,
length: ld.length, quantity: ld.quantity, weight: Math.round(bodyW.weight * ld.quantity * 100) / 100,
});
rows.push({
partName: '⑤D형', lotPrefix: 'SD', material: mapping.bodyMaterial,
length: ld.length, quantity: ld.quantity, weight: Math.round(bodyW.weight * ld.quantity * 100) / 100,
});
}
// 하부BASE
const totalQty = lengthData.reduce((sum, ld) => sum + ld.quantity, 0);
if (totalQty > 0) {
const baseW = calcWeight('EGI 1.55T', BASE_WIDTH, SIDE_BASE_HEIGHT);
rows.push({
partName: '하부BASE', lotPrefix: 'XX', material: 'EGI 1.55T',
length: 0, quantity: totalQty, weight: Math.round(baseW.weight * totalQty * 100) / 100,
});
}
return rows;
}
// ============================================================
// 하단마감재 파트 행 생성
// ============================================================
export function buildBottomBarRows(
bottomBar: BendingInfoExtended['bottomBar'],
mapping: MaterialMapping,
): BottomBarPartRow[] {
const rows: BottomBarPartRow[] = [];
const lotPrefix = mapping.bottomBarFinish.includes('SUS') ? 'TS' : 'TE';
// ①하단마감재 - 3000mm
if (bottomBar.length3000Qty > 0) {
const w = calcWeight(mapping.bottomBarFinish, BOTTOM_BAR_WIDTH, 3000);
rows.push({
partName: '①하단마감재', lotPrefix, material: mapping.bottomBarFinish,
length: 3000, quantity: bottomBar.length3000Qty,
weight: Math.round(w.weight * bottomBar.length3000Qty * 100) / 100,
});
}
// ①하단마감재 - 4000mm
if (bottomBar.length4000Qty > 0) {
const w = calcWeight(mapping.bottomBarFinish, BOTTOM_BAR_WIDTH, 4000);
rows.push({
partName: '①하단마감재', lotPrefix, material: mapping.bottomBarFinish,
length: 4000, quantity: bottomBar.length4000Qty,
weight: Math.round(w.weight * bottomBar.length4000Qty * 100) / 100,
});
}
// ④별도마감재 (extraFinish !== '없음' 일 때만)
if (mapping.bottomBarExtraFinish !== '없음' && mapping.bottomBarExtraFinish) {
const extraLotPrefix = mapping.bottomBarExtraFinish.includes('SUS') ? 'TS' : 'TE';
if (bottomBar.length3000Qty > 0) {
const w = calcWeight(mapping.bottomBarExtraFinish, EXTRA_FINISH_WIDTH, 3000);
rows.push({
partName: '④별도마감재', lotPrefix: extraLotPrefix, material: mapping.bottomBarExtraFinish,
length: 3000, quantity: bottomBar.length3000Qty,
weight: Math.round(w.weight * bottomBar.length3000Qty * 100) / 100,
});
}
if (bottomBar.length4000Qty > 0) {
const w = calcWeight(mapping.bottomBarExtraFinish, EXTRA_FINISH_WIDTH, 4000);
rows.push({
partName: '④별도마감재', lotPrefix: extraLotPrefix, material: mapping.bottomBarExtraFinish,
length: 4000, quantity: bottomBar.length4000Qty,
weight: Math.round(w.weight * bottomBar.length4000Qty * 100) / 100,
});
}
}
return rows;
}
// ============================================================
// 셔터박스 구성요소 행 생성 (방향별)
// ============================================================
function parseBoxSize(size: string): { width: number; height: number } {
const parts = size.split('*').map(Number);
return { width: parts[0] || 500, height: parts[1] || 380 };
}
export function buildShutterBoxRows(box: ShutterBoxData): ShutterBoxPartRow[] {
const rows: ShutterBoxPartRow[] = [];
const { width: boxWidth, height: boxHeight } = parseBoxSize(box.size);
const isStandard = box.size === '500*380';
for (const ld of box.lengthData) {
if (ld.quantity <= 0) continue;
if (isStandard) {
// 표준 500*380 구성
const parts = [
{ name: '①전면부', prefix: 'CF', dim: boxHeight + 122 },
{ name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 },
{ name: '③⑤점검구', prefix: 'CP', dim: boxWidth - 200 },
{ name: '④후면코너부', prefix: 'CB', dim: 170 },
];
for (const p of parts) {
const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length);
rows.push({
partName: p.name, lotPrefix: p.prefix, material: BOX_FINISH_MATERIAL,
dimension: `${ld.length}`, quantity: ld.quantity,
weight: Math.round(w.weight * ld.quantity * 100) / 100,
});
}
} else if (box.direction === '양면') {
const parts = [
{ name: '①전면부', prefix: 'XX', dim: boxHeight + 122 },
{ name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 },
{ name: '③점검구', prefix: 'XX', dim: boxWidth - 200 },
{ name: '④후면코너부', prefix: 'CB', dim: 170 },
{ name: '⑤점검구', prefix: 'XX', dim: boxHeight - 100 },
];
for (const p of parts) {
const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length);
rows.push({
partName: p.name, lotPrefix: p.prefix, material: BOX_FINISH_MATERIAL,
dimension: `${ld.length}`, quantity: ld.quantity,
weight: Math.round(w.weight * ld.quantity * 100) / 100,
});
}
} else if (box.direction === '밑면') {
const parts = [
{ name: '①전면부', prefix: 'XX', dim: boxHeight + 122 },
{ name: '②린텔부', prefix: 'CL', dim: boxWidth - 330 },
{ name: '③점검구', prefix: 'XX', dim: boxWidth - 200 },
{ name: '④후면부', prefix: 'CB', dim: boxHeight + 85 * 2 },
];
for (const p of parts) {
const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length);
rows.push({
partName: p.name, lotPrefix: p.prefix, material: BOX_FINISH_MATERIAL,
dimension: `${ld.length}`, quantity: ld.quantity,
weight: Math.round(w.weight * ld.quantity * 100) / 100,
});
}
} else if (box.direction === '후면') {
const parts = [
{ name: '①전면부', prefix: 'XX', dim: boxHeight + 122 },
{ name: '②린텔부', prefix: 'CL', dim: boxWidth + 85 * 2 },
{ name: '③점검구', prefix: 'XX', dim: boxHeight - 200 },
{ name: '④후면코너부', prefix: 'CB', dim: boxHeight + 85 * 2 },
];
for (const p of parts) {
const w = calcWeight(BOX_FINISH_MATERIAL, p.dim, ld.length);
rows.push({
partName: p.name, lotPrefix: p.prefix, material: BOX_FINISH_MATERIAL,
dimension: `${ld.length}`, quantity: ld.quantity,
weight: Math.round(w.weight * ld.quantity * 100) / 100,
});
}
}
}
// 상부덮개 (비표준일 때)
if (!isStandard && box.coverQty > 0) {
const coverWidth = boxWidth - 111;
const w = calcWeight(BOX_FINISH_MATERIAL, coverWidth, BOX_COVER_LENGTH);
rows.push({
partName: '⑥상부덮개', lotPrefix: 'XX', material: BOX_FINISH_MATERIAL,
dimension: `1219 * ${coverWidth}`, quantity: box.coverQty,
weight: Math.round(w.weight * box.coverQty * 100) / 100,
});
}
// 마구리 (비표준일 때)
if (!isStandard && box.finCoverQty > 0) {
const finWidth = boxWidth + 5;
const finHeight = boxHeight + 5;
const w = calcWeight(BOX_FINISH_MATERIAL, finWidth, finHeight);
rows.push({
partName: '⑦측면부(마구리)', lotPrefix: 'XX', material: BOX_FINISH_MATERIAL,
dimension: `${finWidth} * ${finHeight}`, quantity: box.finCoverQty,
weight: Math.round(w.weight * box.finCoverQty * 100) / 100,
});
}
return rows;
}
// ============================================================
// 연기차단재 파트 행 생성
// ============================================================
export function buildSmokeBarrierRows(
smokeBarrier: BendingInfoExtended['smokeBarrier'],
): SmokeBarrierPartRow[] {
const rows: SmokeBarrierPartRow[] = [];
// 레일용 W50
for (const ld of smokeBarrier.w50) {
if (ld.quantity <= 0) continue;
const w = calcWeight('EGI 0.8T', SMOKE_BARRIER_WIDTH, ld.length);
const code = getSLengthCode(ld.length, '연기차단재50');
rows.push({
partName: '레일용 [W50]', material: 'EGI 0.8T',
length: ld.length, quantity: ld.quantity,
weight: Math.round(w.weight * ld.quantity * 100) / 100,
lotCode: code ? `GI-${code}` : 'GI',
});
}
// 케이스용 W80
if (smokeBarrier.w80Qty > 0) {
const w = calcWeight('EGI 0.8T', SMOKE_BARRIER_WIDTH, 3000);
const code = getSLengthCode(3000, '연기차단재80');
rows.push({
partName: '케이스용 [W80]', material: 'EGI 0.8T',
length: 3000, quantity: smokeBarrier.w80Qty,
weight: Math.round(w.weight * smokeBarrier.w80Qty * 100) / 100,
lotCode: code ? `GI-${code}` : 'GI',
});
}
return rows;
}
// ============================================================
// 생산량 합계 계산
// ============================================================
export function calculateProductionSummary(
bendingInfo: BendingInfoExtended,
mapping: MaterialMapping,
): ProductionSummary {
let susTotal = 0;
let egiTotal = 0;
function addWeight(material: string, width: number, height: number, qty: number) {
if (qty <= 0) return;
const { weight, type } = calcWeight(material, width, height);
const total = weight * qty;
if (type === 'SUS') susTotal += total;
else egiTotal += total;
}
// 가이드레일 - 벽면형
if (bendingInfo.guideRail.wall) {
const baseHeight = bendingInfo.guideRail.wall.baseSize === '135*80'
? WALL_BASE_HEIGHT_MIXED : WALL_BASE_HEIGHT_ONLY;
for (const ld of bendingInfo.guideRail.wall.lengthData) {
if (ld.quantity <= 0) continue;
addWeight(mapping.guideRailFinish, WALL_PART_WIDTH, ld.length, ld.quantity);
addWeight(mapping.bodyMaterial, WALL_PART_WIDTH, ld.length, ld.quantity * 3); // 본체+C형+D형
if (mapping.guideRailExtraFinish) {
addWeight(mapping.guideRailExtraFinish, WALL_PART_WIDTH, ld.length, ld.quantity);
}
}
const totalWallQty = bendingInfo.guideRail.wall.lengthData.reduce((s, l) => s + l.quantity, 0);
addWeight('EGI 1.55T', BASE_WIDTH, baseHeight, totalWallQty);
}
// 가이드레일 - 측면형
if (bendingInfo.guideRail.side) {
for (const ld of bendingInfo.guideRail.side.lengthData) {
if (ld.quantity <= 0) continue;
addWeight(mapping.guideRailFinish, SIDE_PART_WIDTH, ld.length, ld.quantity);
addWeight(mapping.bodyMaterial, SIDE_PART_WIDTH, ld.length, ld.quantity * 3);
}
const totalSideQty = bendingInfo.guideRail.side.lengthData.reduce((s, l) => s + l.quantity, 0);
addWeight('EGI 1.55T', BASE_WIDTH, SIDE_BASE_HEIGHT, totalSideQty);
}
// 하단마감재
if (bendingInfo.bottomBar.length3000Qty > 0) {
addWeight(mapping.bottomBarFinish, BOTTOM_BAR_WIDTH, 3000, bendingInfo.bottomBar.length3000Qty);
if (mapping.bottomBarExtraFinish !== '없음' && mapping.bottomBarExtraFinish) {
addWeight(mapping.bottomBarExtraFinish, EXTRA_FINISH_WIDTH, 3000, bendingInfo.bottomBar.length3000Qty);
}
}
if (bendingInfo.bottomBar.length4000Qty > 0) {
addWeight(mapping.bottomBarFinish, BOTTOM_BAR_WIDTH, 4000, bendingInfo.bottomBar.length4000Qty);
if (mapping.bottomBarExtraFinish !== '없음' && mapping.bottomBarExtraFinish) {
addWeight(mapping.bottomBarExtraFinish, EXTRA_FINISH_WIDTH, 4000, bendingInfo.bottomBar.length4000Qty);
}
}
// 셔터박스
for (const box of bendingInfo.shutterBox) {
const boxRows = buildShutterBoxRows(box);
for (const row of boxRows) {
if (row.weight > 0) egiTotal += row.weight; // 셔터박스는 항상 EGI
}
}
// 연기차단재
const smokeRows = buildSmokeBarrierRows(bendingInfo.smokeBarrier);
for (const row of smokeRows) {
if (row.weight > 0) egiTotal += row.weight; // 연기차단재는 항상 EGI
}
susTotal = Math.round(susTotal * 100) / 100;
egiTotal = Math.round(egiTotal * 100) / 100;
return {
susTotal,
egiTotal,
grandTotal: Math.round((susTotal + egiTotal) * 100) / 100,
};
}
// ============================================================
// 숫자 포맷 헬퍼
// ============================================================
export function fmt(v?: number): string {
return v != null && v > 0 ? v.toLocaleString() : '-';
}
export function fmtWeight(v: number): string {
return v > 0 ? v.toFixed(2) : '-';
}

View File

@@ -119,6 +119,8 @@ export interface WorkOrderItem {
orderNodeId: number | null; // 개소 ID
orderNodeName: string; // 개소명
slatInfo?: { length: number; slatCount: number; jointBar: number; glassQty: number }; // 슬랫 공정 정보
bendingInfo?: Record<string, unknown>; // 절곡 공정 정보 (bending_info JSON)
materialInputLots?: string[]; // 개소별 투입자재 LOT 번호 목록
}
// 전개도 상세 (절곡용)
@@ -272,6 +274,13 @@ export interface WorkOrderItemApi {
code: string;
} | null;
} | null;
material_inputs?: Array<{
id: number;
stock_lot_id: number;
item_id: number;
qty: number;
stock_lot?: { id: number; lot_no: string };
}>;
}
// API 응답 - 벤딩 상세
@@ -473,6 +482,15 @@ export function transformApiToFrontend(api: WorkOrderApi): WorkOrder {
const si = item.options.slat_info as { length?: number; slat_count?: number; joint_bar?: number; glass_qty?: number };
return { length: si.length || 0, slatCount: si.slat_count || 0, jointBar: si.joint_bar || 0, glassQty: si.glass_qty || 0 };
})() : undefined,
bendingInfo: item.options?.bending_info
? (item.options.bending_info as Record<string, unknown>)
: undefined,
materialInputLots: (() => {
const inputs = item.material_inputs as Array<{ stock_lot?: { lot_no?: string } }> | undefined;
if (!inputs || inputs.length === 0) return undefined;
const lots = inputs.map(mi => mi.stock_lot?.lot_no).filter((v): v is string => !!v);
return lots.length > 0 ? [...new Set(lots)] : undefined;
})(),
})),
bendingDetails: api.bending_detail ? transformBendingDetail(api.bending_detail) : undefined,
issues: (api.issues || []).map(issue => ({