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:
@@ -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"> </td>
|
||||
<td className="border border-gray-400 p-2"> </td>
|
||||
<td className="border border-gray-400 p-2"> </td>
|
||||
<td className="border border-gray-400 p-2"> </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">입고 & 생산 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"> </td>
|
||||
<td className="border border-gray-400 p-2"> </td>
|
||||
<td className="border border-gray-400 p-2"> </td>
|
||||
<td className="border border-gray-400 p-2"> </td>
|
||||
<td className="border border-gray-400 p-2"> </td>
|
||||
<td className="border border-gray-400 p-2"> </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"> </td>
|
||||
<td className="border border-gray-400 p-2"> </td>
|
||||
<td className="border border-gray-400 p-2"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 p-2"> </td>
|
||||
<td className="border border-gray-400 p-2"> </td>
|
||||
<td className="border border-gray-400 p-2"> </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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
입고&생산 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
130
src/components/production/WorkOrders/documents/bending/types.ts
Normal file
130
src/components/production/WorkOrders/documents/bending/types.ts
Normal 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;
|
||||
}
|
||||
567
src/components/production/WorkOrders/documents/bending/utils.ts
Normal file
567
src/components/production/WorkOrders/documents/bending/utils.ts
Normal 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) : '-';
|
||||
}
|
||||
@@ -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 => ({
|
||||
|
||||
Reference in New Issue
Block a user