diff --git a/src/components/production/WorkOrders/documents/BendingWorkLogContent.tsx b/src/components/production/WorkOrders/documents/BendingWorkLogContent.tsx
index bc285a92..c82cf060 100644
--- a/src/components/production/WorkOrders/documents/BendingWorkLogContent.tsx
+++ b/src/components/production/WorkOrders/documents/BendingWorkLogContent.tsx
@@ -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 (
{/* ===== 헤더 영역 ===== */}
- {/* 좌측: 제목 + 문서번호 */}
작업일지 (절곡)
문서번호: {documentNo} | 작성일자: {fullDate}
-
- {/* 우측: 결재란 */}
- | |
- |
- |
- |
+
+ {hasBendingData ? firstBendingInfo!.productCode : '-'}
+ |
+
+ {mapping?.bodyMaterial || '-'}
+ |
+
+ {hasBendingData ? firstBendingInfo!.finishMaterial : '-'}
+ |
+
+ {hasBendingData ? firstBendingInfo!.common.type : '-'}
+ |
- {/* ===== 작업내역 ===== */}
- 작업내역
-
-
-
- | 유형명 |
- 세부품명 |
- 재질 |
- 입고 & 생산 LOT NO |
- 길이/규격 |
- 수량 |
-
-
-
- {items.length > 0 ? (
- items.map((item, idx) => (
-
- | {item.productName} |
- - |
- - |
- {order.lotNo} |
- {item.specification || '-'} |
- {item.quantity} |
-
- ))
- ) : (
- Array.from({ length: EMPTY_ROWS }).map((_, idx) => (
-
- | |
- |
- |
- |
- |
- |
-
- ))
- )}
-
-
+ {/* ===== 4개 카테고리 섹션 ===== */}
+ {hasBendingData && mapping ? (
+ <>
+
+
+
+
+ >
+ ) : (
+
+ 절곡 데이터가 없습니다. (bending_info 미등록)
+
+ )}
- {/* ===== 생산량 합계 [kg] ===== */}
-
-
-
- | 생산량 합계 [kg] |
- SUS |
- EGI |
-
-
+ {/* ===== 생산량 합계 ===== */}
+
+
+ {/* ===== 비고 ===== */}
+
- | |
- |
- |
-
-
- | |
- |
- |
+ 비고 |
+
+ {order.note || ''}
+ |
diff --git a/src/components/production/WorkOrders/documents/SlatWorkLogContent.tsx b/src/components/production/WorkOrders/documents/SlatWorkLogContent.tsx
index 6dfe356d..1da4edff 100644
--- a/src/components/production/WorkOrders/documents/SlatWorkLogContent.tsx
+++ b/src/components/production/WorkOrders/documents/SlatWorkLogContent.tsx
@@ -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 (
| {idx + 1} |
- {lotNoDisplay} |
+ {item.materialInputLots?.join(', ') || ''} |
{glassQty > 0 ? fmt(glassQty) : '-'} |
{item.productName} |
{fmt(item.width)} |
diff --git a/src/components/production/WorkOrders/documents/bending/BottomBarSection.tsx b/src/components/production/WorkOrders/documents/bending/BottomBarSection.tsx
new file mode 100644
index 00000000..c06fbe4e
--- /dev/null
+++ b/src/components/production/WorkOrders/documents/bending/BottomBarSection.tsx
@@ -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 (
+
+
+ 2. 하단마감재
+
+
+
+ {/* 좌측: 이미지 */}
+
+

+
+
+ {/* 우측: 테이블 */}
+
+
+
+
+ | 세부품명 |
+ 재질 |
+ 길이 |
+ 수량 |
+ LOT NO |
+ 무게(kg) |
+
+
+
+ {rows.map((row, idx) => (
+
+ | {row.partName} |
+ {row.material} |
+ {fmt(row.length)} |
+ {fmt(row.quantity)} |
+
+ {row.lotPrefix}-
+ |
+ {fmtWeight(row.weight)} |
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/components/production/WorkOrders/documents/bending/GuideRailSection.tsx b/src/components/production/WorkOrders/documents/bending/GuideRailSection.tsx
new file mode 100644
index 00000000..83fb2051
--- /dev/null
+++ b/src/components/production/WorkOrders/documents/bending/GuideRailSection.tsx
@@ -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 (
+
+
{title}
+
+ {/* 좌측: 이미지 + LOT */}
+
+

+
+ 입고&생산 LOT NO:
+ _______________
+
+
+
+ {/* 우측: 테이블 */}
+
+
+
+
+ | 세부품명 |
+ 재질 |
+ 길이 |
+ 수량 |
+ LOT NO |
+ 무게(kg) |
+
+
+
+ {rows.map((row, idx) => (
+
+ | {row.partName} |
+ {row.material} |
+
+ {row.partName === '하부BASE' ? (baseSize || '-') : fmt(row.length)}
+ |
+ {fmt(row.quantity)} |
+
+ {row.lotPrefix}-
+ |
+ {fmtWeight(row.weight)} |
+
+ ))}
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+ 1. 가이드레일
+
+
+ {wallRows.length > 0 && (
+
+ )}
+
+ {sideRows.length > 0 && (
+
+ )}
+
+ );
+}
diff --git a/src/components/production/WorkOrders/documents/bending/ProductionSummarySection.tsx b/src/components/production/WorkOrders/documents/bending/ProductionSummarySection.tsx
new file mode 100644
index 00000000..a2d7e3be
--- /dev/null
+++ b/src/components/production/WorkOrders/documents/bending/ProductionSummarySection.tsx
@@ -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 (
+
+
+
+
+ | 생산량 합계 [kg] |
+ SUS |
+ EGI |
+ 합계 |
+
+
+
+
+ | 무게 |
+
+ {summary.susTotal > 0 ? `${summary.susTotal.toFixed(2)} kg` : '-'}
+ |
+
+ {summary.egiTotal > 0 ? `${summary.egiTotal.toFixed(2)} kg` : '-'}
+ |
+
+ {summary.grandTotal > 0 ? `${summary.grandTotal.toFixed(2)} kg` : '-'}
+ |
+
+
+
+
+ );
+}
diff --git a/src/components/production/WorkOrders/documents/bending/ShutterBoxSection.tsx b/src/components/production/WorkOrders/documents/bending/ShutterBoxSection.tsx
new file mode 100644
index 00000000..46fac236
--- /dev/null
+++ b/src/components/production/WorkOrders/documents/bending/ShutterBoxSection.tsx
@@ -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 = {
+ '양면': 'both',
+ '밑면': 'bottom',
+ '후면': 'rear',
+ };
+ const imageType = directionMap[box.direction] || 'both';
+
+ return (
+
+
+ 3.{index + 1} 셔터박스 [{box.size}] {box.direction}
+
+
+
+ {/* 좌측: 이미지 (source 이미지에 치수 오버레이) */}
+
+
+

+ {/* 치수 텍스트 오버레이 */}
+
+ {box.size} ({box.direction})
+
+
+
+ 레일폭: {box.railWidth}mm
+
+
+
+ {/* 우측: 테이블 */}
+
+
+
+
+ | 구성요소 |
+ 재질 |
+ 길이/치수 |
+ 수량 |
+ LOT NO |
+ 무게(kg) |
+
+
+
+ {rows.map((row, idx) => (
+
+ | {row.partName} |
+ {row.material} |
+ {row.dimension} |
+ {fmt(row.quantity)} |
+
+ {row.lotPrefix}-
+ |
+ {fmtWeight(row.weight)} |
+
+ ))}
+
+
+
+
+
+ );
+}
+
+export function ShutterBoxSection({ bendingInfo }: ShutterBoxSectionProps) {
+ const boxes = bendingInfo.shutterBox;
+ if (!boxes || boxes.length === 0) return null;
+
+ return (
+
+
+ 3. 셔터박스
+
+
+ {boxes.map((box, idx) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/production/WorkOrders/documents/bending/SmokeBarrierSection.tsx b/src/components/production/WorkOrders/documents/bending/SmokeBarrierSection.tsx
new file mode 100644
index 00000000..4e6ba5e3
--- /dev/null
+++ b/src/components/production/WorkOrders/documents/bending/SmokeBarrierSection.tsx
@@ -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 (
+
+
+ 4. 연기차단재
+
+
+
+ {/* 좌측: 이미지 */}
+
+

+
+
+ {/* 우측: 테이블 */}
+
+
+
+
+ | 파트 |
+ 재질 |
+ 길이 |
+ 수량 |
+ LOT NO |
+ 무게(kg) |
+
+
+
+ {rows.map((row, idx) => (
+
+ | {row.partName} |
+ {row.material} |
+ {fmt(row.length)} |
+ {fmt(row.quantity)} |
+ {row.lotCode} |
+ {fmtWeight(row.weight)} |
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/components/production/WorkOrders/documents/bending/types.ts b/src/components/production/WorkOrders/documents/bending/types.ts
new file mode 100644
index 00000000..91b171ca
--- /dev/null
+++ b/src/components/production/WorkOrders/documents/bending/types.ts
@@ -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;
+}
\ No newline at end of file
diff --git a/src/components/production/WorkOrders/documents/bending/utils.ts b/src/components/production/WorkOrders/documents/bending/utils.ts
new file mode 100644
index 00000000..946885a3
--- /dev/null
+++ b/src/components/production/WorkOrders/documents/bending/utils.ts
@@ -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 = {
+ 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) : '-';
+}
diff --git a/src/components/production/WorkOrders/types.ts b/src/components/production/WorkOrders/types.ts
index 40202016..caa1bf06 100644
--- a/src/components/production/WorkOrders/types.ts
+++ b/src/components/production/WorkOrders/types.ts
@@ -119,6 +119,8 @@ export interface WorkOrderItem {
orderNodeId: number | null; // 개소 ID
orderNodeName: string; // 개소명
slatInfo?: { length: number; slatCount: number; jointBar: number; glassQty: number }; // 슬랫 공정 정보
+ bendingInfo?: Record; // 절곡 공정 정보 (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)
+ : 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 => ({