From b7e865d481845e32bdf7f9336e25ef3c2d93c8f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Mon, 23 Mar 2026 12:29:55 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[=EC=9E=91=EC=97=85=EC=9E=90=ED=99=94?= =?UTF-8?q?=EB=A9=B4]=20=EC=A0=88=EA=B3=A1=20=EB=B0=94=EB=9D=BC=EC=8B=9C?= =?UTF-8?q?=20=EB=AA=A8=EB=8B=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../WorkerScreen/BendingBarashiContent.tsx | 226 ++++++++++++++++++ .../WorkerScreen/BendingBarashiModal.tsx | 171 +++++++++++++ .../production/WorkerScreen/index.tsx | 29 +++ 3 files changed, 426 insertions(+) create mode 100644 src/components/production/WorkerScreen/BendingBarashiContent.tsx create mode 100644 src/components/production/WorkerScreen/BendingBarashiModal.tsx diff --git a/src/components/production/WorkerScreen/BendingBarashiContent.tsx b/src/components/production/WorkerScreen/BendingBarashiContent.tsx new file mode 100644 index 00000000..febca9cb --- /dev/null +++ b/src/components/production/WorkerScreen/BendingBarashiContent.tsx @@ -0,0 +1,226 @@ +'use client'; + +/** + * 절곡 바라시 작업지시서 문서 콘텐츠 + * + * DocumentViewer 내에서 사용 (PDF 저장 지원) + * 모델 API에서 가져온 components 데이터로 부품별 절곡치수 테이블 렌더링 + * + * 구성: + * - 헤더: 현장명 / 모델코드 / 유형 / 마감 + * - 부품별 절곡치수 테이블: 번호 / 재질 / 절곡치수(개별 셀) / 길이 / 수량 / 면적(폭합) + * - 재질별 폭합 합계 + */ + +import { useMemo } from 'react'; +import { ConstructionApprovalTable } from '@/components/document-system'; +import type { ComponentData } from '../bending/types'; +import type { BendingInfoExtended, LengthQuantity } from '../WorkOrders/documents/bending/types'; + +interface BendingBarashiContentProps { + components: ComponentData[]; + bendingInfo?: BendingInfoExtended; + projectName?: string; + lotNo?: string; + assignee?: string; +} + +// 재질별 폭합 합계 계산 +function calcMaterialSummary(components: ComponentData[]): Record { + const summary: Record = {}; + for (const c of components) { + if (c.material) { + summary[c.material] = (summary[c.material] || 0) + c.width_sum * c.quantity; + } + } + return summary; +} + +// 길이별 수량 포맷 +function formatLengthQuantities(lqs: LengthQuantity[]): string { + if (!lqs || lqs.length === 0) return '-'; + return lqs.map(lq => `${lq.length.toLocaleString()}mm × ${lq.quantity}개`).join(', '); +} + +// 폭합 → mm² → ㎡ 면적 계산 (width_sum mm × length mm) +function calcArea(widthSum: number, lengthQuantities: LengthQuantity[]): string { + if (!lengthQuantities || lengthQuantities.length === 0 || widthSum === 0) return '-'; + // 총 길이 합계 + const totalLength = lengthQuantities.reduce((acc, lq) => acc + lq.length * lq.quantity, 0); + const areaMm2 = widthSum * totalLength; + const areaM2 = areaMm2 / 1_000_000; + return areaM2.toFixed(2); +} + +export function BendingBarashiContent({ + components, + bendingInfo, + projectName, + lotNo, + assignee, +}: BendingBarashiContentProps) { + const today = new Date().toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + const modelCode = bendingInfo?.productCode || '-'; + const modelType = bendingInfo?.common?.type || '-'; + const finishMaterial = bendingInfo?.finishMaterial || '-'; + const lengthQuantities = bendingInfo?.common?.lengthQuantities || []; + + // 부품별 절곡치수 최대 컬럼 수 + const maxBendingCols = useMemo(() => { + return Math.max(...components.map(c => c.bendingData?.length || 0), 0); + }, [components]); + + // 재질별 폭합 + const materialSummary = useMemo(() => calcMaterialSummary(components), [components]); + + return ( +
+ {/* ===== 헤더 ===== */} +
+
+

절곡 바라시 작업지시서

+

+ 작성일자: {today} +

+
+ +
+ + {/* ===== 기본 정보 ===== */} + + + + + + + + + + + + + + + + + + + + + +
현장명{projectName || '-'}모델코드{modelCode}
유형{modelType}마감{finishMaterial}
LOT NO{lotNo || '-'}길이별 수량{formatLengthQuantities(lengthQuantities)}
+ + {/* ===== 부품별 절곡치수 ===== */} +
+

부품별 절곡치수

+ {components.length === 0 ? ( +
+ 절곡 부품 데이터가 없습니다. +
+ ) : ( + + + + + + + + + + + + + {components.map((comp, idx) => { + const bendingData = comp.bendingData || []; + const emptyColCount = maxBendingCols - bendingData.length; + + return ( + + {/* 번호 + 부품명 */} + + + {/* 재질 */} + + + {/* 절곡치수 개별 셀 */} + {bendingData.map((d, i) => ( + + ))} + {/* 빈 셀 채우기 (최대 컬럼 수 맞춤) */} + {emptyColCount > 0 && + Array.from({ length: emptyColCount }).map((_, i) => ( + + + {/* 수량 */} + + + {/* 면적 */} + + + ); + })} + +
번호재질절곡치수폭합수량면적
+ {comp.orderNumber} +
+ ({comp.itemName}) +
+ {comp.material} + + {d.sum} + {d.aAngle && } + + )) + } + + {/* 폭합 */} + + {comp.width_sum.toLocaleString()} + + {comp.quantity} + + {calcArea(comp.width_sum, lengthQuantities)} +
+ )} +
+ + {/* ===== 재질별 폭합 합계 ===== */} + {Object.keys(materialSummary).length > 0 && ( +
+

재질별 폭합 합계

+ + + + + + + + + {Object.entries(materialSummary).map(([material, total]) => ( + + + + + ))} + +
재질폭합 합계 (mm)
{material} + {total.toLocaleString()} +
+
+ )} +
+ ); +} diff --git a/src/components/production/WorkerScreen/BendingBarashiModal.tsx b/src/components/production/WorkerScreen/BendingBarashiModal.tsx new file mode 100644 index 00000000..7d528cc6 --- /dev/null +++ b/src/components/production/WorkerScreen/BendingBarashiModal.tsx @@ -0,0 +1,171 @@ +'use client'; + +/** + * 절곡 바라시 모달 + * + * DocumentViewer 사용 (PDF 저장 지원) + * 작업지시 → bendingInfo.productCode → 모델 API → components(BendingData[]) 로드 + */ + +import { useState, useEffect } from 'react'; +import { Loader2 } from 'lucide-react'; +import { DocumentViewer } from '@/components/document-system'; +import { getWorkOrderById } from '../WorkOrders/actions'; +import { getGuiderailModels, getGuiderailModel } from '../bending/actions'; +import { parseApiComponent, recalculateSums, getWidthSum } from '../bending/types'; +import type { WorkOrder } from '../WorkOrders/types'; +import type { BendingInfoExtended } from '../WorkOrders/documents/bending/types'; +import type { ComponentData, GuiderailModelDetail } from '../bending/types'; +import { BendingBarashiContent } from './BendingBarashiContent'; + +interface BendingBarashiModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + workOrderId: string | null; +} + +// API raw component → ComponentData 파싱 (BendingModelForm과 동일 로직) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function parseComponent(c: any, i: number): ComponentData { + if (c.inputList) return parseApiComponent(c, i); + return { + ...c, + orderNumber: c.orderNumber || i + 1, + quantity: c.quantity || 1, + bendingData: c.bendingData ? recalculateSums(c.bendingData) : [], + width_sum: c.width_sum || getWidthSum(c.bendingData || []), + sourceItemId: c.sourceItemId || c.sam_item_id || undefined, + }; +} + +export function BendingBarashiModal({ + open, + onOpenChange, + workOrderId, +}: BendingBarashiModalProps) { + const [order, setOrder] = useState(null); + const [components, setComponents] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open || !workOrderId) return; + + // mock ID 처리 + if (workOrderId.startsWith('mock-') || workOrderId.startsWith('order-')) { + setError('절곡 바라시 데이터를 불러올 수 없습니다.'); + return; + } + + setIsLoading(true); + setError(null); + + (async () => { + try { + // 1. 작업지시 상세 조회 + const orderResult = await getWorkOrderById(workOrderId); + if (!orderResult.success || !orderResult.data) { + setError('작업지시 데이터를 불러올 수 없습니다.'); + return; + } + const wo = orderResult.data; + setOrder(wo); + + // 2. bendingInfo에서 productCode 추출 + const bendingInfo = wo.bendingInfo as BendingInfoExtended | undefined; + const productCode = bendingInfo?.productCode; + if (!productCode) { + setError('절곡 모델 코드가 없습니다.'); + return; + } + + // 3. 모델 검색 (GUIDERAIL_MODEL에서 productCode로 검색) + const searchResult = await getGuiderailModels({ + item_category: 'GUIDERAIL_MODEL', + search: productCode, + perPage: 10, + }); + + if (!searchResult.success || !searchResult.data?.length) { + // SHUTTERBOX_MODEL에서 재시도 + const sbResult = await getGuiderailModels({ + item_category: 'SHUTTERBOX_MODEL', + search: productCode, + perPage: 10, + }); + if (!sbResult.success || !sbResult.data?.length) { + setError(`모델 "${productCode}"을 찾을 수 없습니다.`); + return; + } + searchResult.data = sbResult.data; + } + + // code 또는 model_name 정확 매칭 + const matchedModel = searchResult.data.find( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (m: any) => m.code === productCode || m.model_name === productCode + ) || searchResult.data[0]; + + // 4. 모델 상세 조회 (components 포함) + const modelId = (matchedModel as { id: number }).id; + const detailResult = await getGuiderailModel(modelId); + if (!detailResult.success || !detailResult.data) { + setError('모델 상세 데이터를 불러올 수 없습니다.'); + return; + } + + const modelDetail = detailResult.data as GuiderailModelDetail; + const parsedComponents = (modelDetail.components || []).map(parseComponent); + setComponents(parsedComponents); + } catch { + setError('데이터를 불러오는 중 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + } + })(); + + return () => { + // cleanup on close + }; + }, [open, workOrderId]); + + // 모달 닫힐 때 상태 초기화 + useEffect(() => { + if (!open) { + setOrder(null); + setComponents([]); + setError(null); + } + }, [open]); + + const bendingInfo = order?.bendingInfo as BendingInfoExtended | undefined; + const assignee = order?.assignees?.find(a => a.isPrimary)?.name || order?.assignee || '-'; + + return ( + + {isLoading ? ( +
+ +
+ ) : error ? ( +
+

{error}

+
+ ) : ( + + )} +
+ ); +} diff --git a/src/components/production/WorkerScreen/index.tsx b/src/components/production/WorkerScreen/index.tsx index d3cd39aa..ac0a3fc5 100644 --- a/src/components/production/WorkerScreen/index.tsx +++ b/src/components/production/WorkerScreen/index.tsx @@ -90,6 +90,9 @@ const WorkCompletionResultDialog = dynamic( const InspectionReportModal = dynamic( () => import('../WorkOrders/documents').then(mod => ({ default: mod.InspectionReportModal })), ); +const BendingBarashiModal = dynamic( + () => import('./BendingBarashiModal').then(mod => ({ default: mod.BendingBarashiModal })), +); interface SidebarOrder { id: string; @@ -275,6 +278,7 @@ export default function WorkerScreen() { const [selectedWorkOrderItemName, setSelectedWorkOrderItemName] = useState(); const [isWorkLogModalOpen, setIsWorkLogModalOpen] = useState(false); const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false); + const [isBarashiModalOpen, setIsBarashiModalOpen] = useState(false); const [isIssueReportModalOpen, setIsIssueReportModalOpen] = useState(false); const [isInspectionInputModalOpen, setIsInspectionInputModalOpen] = useState(false); // 공정의 중간검사 설정 @@ -1321,6 +1325,16 @@ export default function WorkerScreen() { } }, [getTargetOrder]); + const handleBarashi = useCallback(() => { + const target = getTargetOrder(); + if (target) { + setSelectedOrder(target); + setIsBarashiModalOpen(true); + } else { + toast.error('표시할 작업이 없습니다.'); + } + }, [getTargetOrder]); + // 검사 완료 핸들러 (API 저장 + 메모리 업데이트 + 공정 단계 완료 처리) const handleInspectionComplete = useCallback(async (data: InspectionData) => { if (!selectedOrder) return; @@ -1699,6 +1713,15 @@ export default function WorkerScreen() { {(hasWipItems || activeProcessSettings.needsWorkLog || activeProcessSettings.hasDocumentTemplate) && (
+ {activeProcessTabKey === 'bending' && !hasWipItems && ( + + )} {(hasWipItems || activeProcessSettings.needsWorkLog) && (