'use client'; /** * 템플릿 기반 중간검사 성적서 콘텐츠 * * mng 미리보기(buildDocumentPreviewHtml) 레이아웃 기준: * - 헤더: KD + 회사명(좌) | 제목(중앙) | 결재라인(우) * - 기본필드: 2열 배치 (15:35:15:35) * - 이미지 섹션: items 없는 섹션 → 이미지 표시 * - DATA 테이블: template.columns 기반 헤더, work items 행 * - 푸터: 비고(좌) + 종합판정(우) 병렬 배치 * * 컬럼 column_type별 셀 렌더링: * - text (일련번호/NO): 행 번호 * - check: 양호/불량 토글 * - complex (sub_labels): 기준값 표시 + 측정값 입력 / OK·NG 토글 * - select (판정): 자동 계산 적/부 */ import React, { useState, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react'; import type { WorkOrder } from '../types'; import type { WorkItemData } from '@/components/production/WorkerScreen/types'; import type { InspectionDataMap } from './InspectionReportModal'; import type { InspectionTemplateFormat, InspectionTemplateSectionItem, } from '@/components/production/WorkerScreen/types'; import { type InspectionContentRef, InspectionCheckbox, JudgmentCell, calculateOverallResult, getFullDate, getOrderInfo, INPUT_CLASS, } from './inspection-shared'; import { formatNumber } from '@/lib/utils/amount'; import type { BendingInfoExtended } from './bending/types'; import { getInspectionConfig } from '../actions'; import type { InspectionConfigData } from '../actions'; export type { InspectionContentRef }; // ===== 셀 값 타입 ===== interface CellValue { status?: 'good' | 'bad' | null; measurements?: [string, string, string]; value?: string; text?: string; } // ===== Props ===== interface TemplateInspectionContentProps { data: WorkOrder; template: InspectionTemplateFormat; readOnly?: boolean; workItems?: WorkItemData[]; inspectionDataMap?: InspectionDataMap; /** 기존 document_data EAV 레코드 (문서 로딩 시 복원용) */ documentRecords?: Array<{ section_id: number | null; column_id: number | null; row_index: number; field_key: string; field_value: string | null; }>; } // ===== 유틸 ===== /** API 저장소 이미지 URL 생성 — R2 전환 후 프록시 사용 */ function getImageUrl(path: string | null | undefined, fileId?: number | null): string { if (!path && !fileId) return ''; // file_id가 있으면 프록시 경로 사용 if (fileId) return `/api/proxy/files/${fileId}/view`; if (!path) return ''; if (path.startsWith('http://') || path.startsWith('https://')) return path; if (path.startsWith('/api/proxy/')) return path; // R2 전환 후 /storage/ 직접 접근 불가 — 경로만 반환 (fallback) return path; } /** field_values.reference_attribute에서 작업 아이템의 실제 치수를 resolve */ function resolveReferenceValue( item: InspectionTemplateSectionItem, workItem?: WorkItemData ): number | null { if (!item.field_values || !workItem) return null; const refAttr = item.field_values.reference_attribute; if (typeof refAttr !== 'string') return null; const mapping: Record = { width: workItem.width, height: workItem.height, length: workItem.width, }; return mapping[refAttr] ?? null; } function formatStandard(item: InspectionTemplateSectionItem, workItem?: WorkItemData): string { const refVal = resolveReferenceValue(item, workItem); if (refVal !== null) return formatNumber(refVal); const sc = item.standard_criteria; if (!sc) return item.standard || '-'; if (typeof sc === 'object') { if ('nominal' in sc) return String(sc.nominal); if ('min' in sc && 'max' in sc) return `${sc.min} ~ ${sc.max}`; if ('max' in sc) return `≤ ${sc.max}`; if ('min' in sc) return `≥ ${sc.min}`; } return String(sc); } function getNominalValue(item: InspectionTemplateSectionItem, workItem?: WorkItemData): number | null { const refVal = resolveReferenceValue(item, workItem); if (refVal !== null) return refVal; const sc = item.standard_criteria; if (!sc || typeof sc !== 'object') { if (typeof sc === 'string') { const v = parseFloat(sc); return isNaN(v) ? null : v; } return null; } if ('nominal' in sc) return sc.nominal; return null; } /** 측정값이 공차 범위 내인지 판정 */ function isWithinTolerance(measured: number, item: InspectionTemplateSectionItem, workItem?: WorkItemData): boolean { const nominal = getNominalValue(item, workItem); const tol = item.tolerance; if (nominal === null || !tol) return true; switch (tol.type) { case 'symmetric': return Math.abs(measured - nominal) <= (tol.value ?? 0); case 'asymmetric': return measured >= nominal - (tol.minus ?? 0) && measured <= nominal + (tol.plus ?? 0); case 'range': return measured >= (tol.min ?? -Infinity) && measured <= (tol.max ?? Infinity); default: return true; } } /** 컬럼 라벨에서 번호 기호와 공백을 제거하여 비교용 키 생성 */ function normalizeLabel(label: string): string { return label.replace(/[①②③④⑤⑥⑦⑧⑨⑩\s]/g, '').trim(); } function isSerialColumn(label: string): boolean { const l = label.trim().toLowerCase(); return l === 'no' || l === 'no.' || l === '일련번호'; } function isJudgmentColumn(label: string): boolean { return label.includes('판정'); } // ===== Bending 검사 DATA 지원 (레거시 PHP 동기화) ===== interface BendingGapPoint { point: string; designValue: string; } interface BendingProduct { id: string; category: string; productName: string; productType: string; lengthDesign: string; widthDesign: string; gapPoints: BendingGapPoint[]; } interface BendingExpandedRow { productIdx: number; product: BendingProduct; pointIdx: number; gapPoint: BendingGapPoint; isFirstRow: boolean; totalPoints: number; } // 절곡 제품별 기본 간격 POINT (단면 치수 - 제품 사양에 따른 고정값) const DEFAULT_GAP_PROFILES: Record = { guideRailWall: [ { point: '(1)', designValue: '30' }, { point: '(2)', designValue: '78' }, { point: '(3)', designValue: '25' }, { point: '(4)', designValue: '45' }, ], guideRailSide: [ { point: '(1)', designValue: '28' }, { point: '(2)', designValue: '75' }, { point: '(3)', designValue: '42' }, { point: '(4)', designValue: '38' }, { point: '(5)', designValue: '32' }, ], bottomBar: [{ point: '(1)', designValue: '60' }], caseBox: [ { point: '(1)', designValue: '550' }, { point: '(2)', designValue: '50' }, { point: '(3)', designValue: '385' }, { point: '(4)', designValue: '50' }, { point: '(5)', designValue: '410' }, ], smokeW50: [ { point: '(1)', designValue: '50' }, { point: '(2)', designValue: '12' }, ], smokeW80: [ { point: '(1)', designValue: '80' }, { point: '(2)', designValue: '12' }, ], }; /** bending_info에서 제품 목록 생성, 없으면 기본값 */ function buildBendingProducts(order: WorkOrder): BendingProduct[] { const bi = order.bendingInfo as BendingInfoExtended | undefined; const productCode = bi?.productCode || 'KQTS01'; const products: BendingProduct[] = []; // 가이드레일 벽면형 if (!bi || bi.guideRail?.wall) { const len = bi?.guideRail?.wall?.lengthData?.[0]?.length || 3500; products.push({ id: 'guide-wall', category: productCode, productName: '가이드레일', productType: '벽면형', lengthDesign: String(len), widthDesign: 'N/A', gapPoints: DEFAULT_GAP_PROFILES.guideRailWall, }); } // 가이드레일 측면형 if (bi?.guideRail?.side) { const len = bi.guideRail.side.lengthData?.[0]?.length || 3000; products.push({ id: 'guide-side', category: productCode, productName: '가이드레일', productType: '측면형', lengthDesign: String(len), widthDesign: 'N/A', gapPoints: DEFAULT_GAP_PROFILES.guideRailSide, }); } // 하단마감재 products.push({ id: 'bottom-bar', category: productCode, productName: '하단마감재', productType: '60×40', lengthDesign: '3000', widthDesign: 'N/A', gapPoints: DEFAULT_GAP_PROFILES.bottomBar, }); // 케이스 (shutterBox) if (bi?.shutterBox?.length) { bi.shutterBox.forEach((box, boxIdx) => { (box.lengthData || []).forEach((ld, li) => { products.push({ id: `case-${boxIdx}-${li}`, category: productCode, productName: '케이스', productType: `${box.size || '양면'}\n${box.direction || ''}`.trim(), lengthDesign: String(ld.length), widthDesign: 'N/A', gapPoints: DEFAULT_GAP_PROFILES.caseBox, }); }); }); } else { products.push({ id: 'case-0', category: productCode, productName: '케이스', productType: '양면', lengthDesign: '3000', widthDesign: 'N/A', gapPoints: DEFAULT_GAP_PROFILES.caseBox, }); } // 연기차단재 products.push({ id: 'smoke-w50', category: productCode, productName: '연기차단재', productType: '화이바 W50\n가이드레일용', lengthDesign: '-', widthDesign: '50', gapPoints: DEFAULT_GAP_PROFILES.smokeW50, }); products.push({ id: 'smoke-w80', category: productCode, productName: '연기차단재', productType: '화이바 W80\n케이스용', lengthDesign: '-', widthDesign: '80', gapPoints: DEFAULT_GAP_PROFILES.smokeW80, }); return products; } // ===== 이미지 섹션 컴포넌트 (onError 핸들링) ===== function SectionImage({ section }: { section: { id: number; title?: string; name?: string; image_path?: string | null } }) { const [imgError, setImgError] = useState(false); const title = section.title || section.name || ''; const url = section.image_path ? getImageUrl(section.image_path) : ''; return (

■ {title}

{url && !imgError ? ( {title} setImgError(true)} /> ) : (
이미지 미등록
)}
); } // ===== 컴포넌트 ===== export const TemplateInspectionContent = forwardRef( function TemplateInspectionContent({ data: order, template, readOnly = false, workItems, inspectionDataMap, documentRecords }, ref) { const fullDate = getFullDate(); const { primaryAssignee } = getOrderInfo(order); // 섹션 분류: 이미지 섹션(items 없음) vs 데이터 섹션(items 있음) const imageSections = useMemo( () => template.sections.filter(s => s.items.length === 0), [template.sections] ); const dataSections = useMemo( () => template.sections.filter(s => s.items.length > 0), [template.sections] ); // 모든 데이터 섹션의 아이템을 평탄화 const allItems = useMemo( () => dataSections.flatMap(s => s.items), [dataSections] ); // sectionItem.id → section.id 역매핑 const itemSectionMap = useMemo(() => { const map = new Map(); for (const s of dataSections) { for (const item of s.items) { map.set(item.id, s.id); } } return map; }, [dataSections]); // 컬럼 → 섹션 아이템 매핑 (라벨 정규화 비교) const columnItemMap = useMemo(() => { const map = new Map(); for (const col of template.columns) { const colKey = normalizeLabel(col.label); const matched = allItems.find(item => { const itemKey = normalizeLabel(item.item || item.category || ''); return itemKey === colKey; }); if (matched) map.set(col.id, matched); } return map; }, [template.columns, allItems]); // 셀 값 상태: key = `${rowIdx}-${colId}` const [cellValues, setCellValues] = useState>({}); const [inadequateContent, setInadequateContent] = useState(''); const effectiveWorkItems = workItems || []; // ===== Bending template detection & expanded rows ===== const isBending = useMemo(() => { if (order.processType === 'bending') return true; return template.columns.some(col => col.sub_labels?.some(sl => sl.toLowerCase().includes('point')) ); }, [order.processType, template.columns]); const gapColumnId = useMemo(() => { if (!isBending) return null; return template.columns.find(col => col.sub_labels?.some(sl => sl.toLowerCase().includes('point')) )?.id ?? null; }, [isBending, template.columns]); // ===== inspection-config API 연동 ===== const [inspectionConfig, setInspectionConfig] = useState(null); useEffect(() => { if (!isBending || !order.id) return; let cancelled = false; getInspectionConfig(order.id).then(result => { if (!cancelled && result.success && result.data) { setInspectionConfig(result.data); } }); return () => { cancelled = true; }; }, [isBending, order.id]); const bendingProducts = useMemo(() => { if (!isBending) return []; // API 응답이 있고 items가 있으면 API 기반 구성품 사용 if (inspectionConfig?.items?.length) { const productCode = inspectionConfig.product_code || ''; // bending_info에서 dimension 보조 데이터 추출 const bi = order.bendingInfo as BendingInfoExtended | undefined; const wallLen = bi?.guideRail?.wall?.lengthData?.[0]?.length; const sideLen = bi?.guideRail?.side?.lengthData?.[0]?.length; return inspectionConfig.items.map((item): BendingProduct => { // API id → 표시용 매핑 (이름, 타입, 치수) const displayMap: Record = { guide_rail_wall: { name: '가이드레일', type: '벽면형', len: String(wallLen || 3500), wid: 'N/A' }, guide_rail_side: { name: '가이드레일', type: '측면형', len: String(sideLen || 3000), wid: 'N/A' }, bottom_bar: { name: '하단마감재', type: '60×40', len: '3000', wid: 'N/A' }, case_box: { name: '케이스', type: '양면', len: '3000', wid: 'N/A' }, smoke_w50: { name: '연기차단재', type: '화이바 W50\n가이드레일용', len: '-', wid: '50' }, smoke_w80: { name: '연기차단재', type: '화이바 W80\n케이스용', len: '-', wid: '80' }, }; const d = displayMap[item.id] || { name: item.name, type: '', len: '-', wid: 'N/A' }; return { id: item.id, category: productCode, productName: d.name, productType: d.type, lengthDesign: d.len, widthDesign: d.wid, gapPoints: item.gap_points.map(gp => ({ point: gp.point, designValue: gp.design_value, })), }; }); } // fallback: 기존 프론트 로직 사용 return buildBendingProducts(order); }, [isBending, order, inspectionConfig]); const bendingExpandedRows = useMemo(() => { if (!isBending) return []; const rows: BendingExpandedRow[] = []; bendingProducts.forEach((product, productIdx) => { const total = product.gapPoints.length; product.gapPoints.forEach((gp, pointIdx) => { rows.push({ productIdx, product, pointIdx, gapPoint: gp, isFirstRow: pointIdx === 0, totalPoints: total, }); }); }); return rows; }, [isBending, bendingProducts]); // inspectionDataMap에서 초기값 복원 // InspectionInputModal 저장 키: section_{sectionId}_item_{itemId} / 값: "ok"|"ng"|number // TemplateInspectionContent 내부 키: {rowIdx}-{colId} / 값: CellValue useEffect(() => { if (!inspectionDataMap || !workItems) return; const initial: Record = {}; workItems.forEach((wi, rowIdx) => { const itemData = inspectionDataMap.get(wi.id); if (!itemData?.templateValues) return; for (const col of template.columns) { const sectionItem = columnItemMap.get(col.id); if (!sectionItem) continue; const cellKey = `${rowIdx}-${col.id}`; const sectionId = itemSectionMap.get(sectionItem.id); // InspectionInputModal 키 형식으로 조회 const inputKey = sectionId != null ? `section_${sectionId}_item_${sectionItem.id}` : null; // 레거시 키 형식 폴백 const legacyKey = `item_${sectionItem.id}`; const rawVal = (inputKey ? itemData.templateValues?.[inputKey] : undefined) ?? itemData.templateValues?.[legacyKey]; if (rawVal == null) continue; // 값 형식 변환: InputModal 형식 → CellValue 형식 // complex 컬럼(측정값)은 measurements 배열, check 컬럼은 status, 나머지는 value const isComplex = col.column_type === 'complex' && col.sub_labels && col.sub_labels.length > 0; if (typeof rawVal === 'object') { // 이미 CellValue 객체 initial[cellKey] = rawVal as CellValue; } else if (rawVal === 'ok') { initial[cellKey] = { status: 'good' }; } else if (rawVal === 'ng') { initial[cellKey] = { status: 'bad' }; } else if (isComplex) { // complex 컬럼: 숫자/문자열을 첫 번째 측정값으로 매핑 const strVal = typeof rawVal === 'number' ? String(rawVal) : String(rawVal); initial[cellKey] = { measurements: [strVal, '', ''] }; } else if (typeof rawVal === 'number') { initial[cellKey] = { value: String(rawVal) }; } else if (typeof rawVal === 'string') { initial[cellKey] = { value: rawVal }; } } }); // ★ 템플릿 버전 불일치 폴백: templateValues 키가 현재 템플릿과 맞지 않으면 // 레거시 필드(processingStatus, sewingStatus 등)를 컬럼 라벨로 매칭하여 복원 if (Object.keys(initial).length === 0) { const hasAnyData = workItems.some((wi) => inspectionDataMap.get(wi.id)); if (hasAnyData) { // 컬럼 라벨 → 레거시 필드 매핑 const labelToLegacy: Record) => unknown> = { '가공상태': (d) => d.processingStatus === 'good' ? 'ok' : d.processingStatus === 'bad' ? 'ng' : null, '재봉상태': (d) => d.sewingStatus === 'good' ? 'ok' : d.sewingStatus === 'bad' ? 'ng' : null, '조립상태': (d) => d.assemblyStatus === 'good' ? 'ok' : d.assemblyStatus === 'bad' ? 'ng' : null, '절곡상태': (d) => d.bendingStatus === 'good' ? 'ok' : d.bendingStatus === 'bad' ? 'ng' : null, '길이': (d) => d.length, '높이': (d) => d.width, '간격': (d) => d.gapStatus === 'ok' ? 'ok' : d.gapStatus === 'ng' ? 'ng' : null, }; workItems.forEach((wi, rowIdx) => { const itemData = inspectionDataMap.get(wi.id) as Record | undefined; if (!itemData) return; for (const col of template.columns) { const cellKey = `${rowIdx}-${col.id}`; // 컬럼 라벨에서 번호 접두사 제거 후 매칭 (예: "① 길이" → "길이") const labelClean = col.label.replace(/[①②③④⑤⑥⑦⑧⑨⑩\s]/g, ''); const matchEntry = Object.entries(labelToLegacy).find(([key]) => labelClean.includes(key)); if (!matchEntry) continue; const val = matchEntry[1](itemData); if (val == null) continue; if (val === 'ok') initial[cellKey] = { status: 'good' }; else if (val === 'ng') initial[cellKey] = { status: 'bad' }; else if (typeof val === 'number') initial[cellKey] = { value: String(val) }; else if (typeof val === 'string') initial[cellKey] = { value: val }; } }); } } if (Object.keys(initial).length > 0) setCellValues(initial); // 부적합 내용 복원: 각 개소의 nonConformingContent를 수집 const remarks: string[] = []; workItems.forEach((wi) => { const itemData = inspectionDataMap.get(wi.id); if (itemData?.nonConformingContent) { remarks.push(itemData.nonConformingContent); } }); if (remarks.length > 0) setInadequateContent(remarks.join('\n')); }, [inspectionDataMap, workItems]); // ===== Bending: document_data EAV 레코드에서 복원 ===== useEffect(() => { if (!isBending || !documentRecords || documentRecords.length === 0 || bendingProducts.length === 0) return; const initial: Record = {}; // field_key 패턴: b{productIdx}_ok, b{productIdx}_ng, b{productIdx}_p{pointIdx}_n1, b{productIdx}_n{n}, b{productIdx}_judgment for (const rec of documentRecords) { const fk = rec.field_key; if (!fk.startsWith('b')) continue; const val = rec.field_value; if (val == null) continue; // b{productIdx}_ok / b{productIdx}_ng → check status const checkMatch = fk.match(/^b(\d+)_(ok|ng)$/); if (checkMatch && rec.column_id) { const productIdx = parseInt(checkMatch[1], 10); const cellKey = `b-${productIdx}-${rec.column_id}`; if (checkMatch[2] === 'ok' && val === 'OK') { initial[cellKey] = { ...initial[cellKey], status: 'good' }; } else if (checkMatch[2] === 'ng' && val === 'NG') { initial[cellKey] = { ...initial[cellKey], status: 'bad' }; } continue; } // b{productIdx}_p{pointIdx}_n1 → gap measurement const gapMatch = fk.match(/^b(\d+)_p(\d+)_n(\d+)$/); if (gapMatch && rec.column_id) { const productIdx = parseInt(gapMatch[1], 10); const pointIdx = parseInt(gapMatch[2], 10); const cellKey = `b-${productIdx}-p${pointIdx}-${rec.column_id}`; initial[cellKey] = { measurements: [val, '', ''] }; continue; } // b{productIdx}_n{n} → complex measurement (길이/너비) const complexMatch = fk.match(/^b(\d+)_n(\d+)$/); if (complexMatch && rec.column_id) { const productIdx = parseInt(complexMatch[1], 10); const mIdx = parseInt(complexMatch[2], 10) - 1; const cellKey = `b-${productIdx}-${rec.column_id}`; const prev = initial[cellKey]?.measurements || ['', '', '']; const m: [string, string, string] = [...prev] as [string, string, string]; m[mIdx] = val; initial[cellKey] = { ...initial[cellKey], measurements: m }; continue; } // b{productIdx}_judgment → skip (자동 계산, 복원 불필요) if (fk.match(/^b\d+_judgment$/)) continue; // b{productIdx}_value → fallback value const valMatch = fk.match(/^b(\d+)_value$/); if (valMatch && rec.column_id) { const productIdx = parseInt(valMatch[1], 10); const cellKey = `b-${productIdx}-${rec.column_id}`; initial[cellKey] = { value: val }; continue; } } // overall_result, remark 복원 for (const rec of documentRecords) { if (rec.field_key === 'overall_result' && rec.field_value) { // overallResult는 자동 계산이므로 별도 처리 불필요 } if (rec.field_key === 'remark' && rec.field_value) { setInadequateContent(rec.field_value); } } if (Object.keys(initial).length > 0) { setCellValues(prev => ({ ...prev, ...initial })); } }, [documentRecords, isBending, bendingProducts]); // ===== Bending: inspectionDataMap의 products 배열에서 셀 값 복원 ===== // InspectionInputModal이 저장한 products 배열 → bending 셀 키로 매핑 // ★ inspectionDataMap의 products가 있으면 documentRecords(EAV)보다 우선 // (입력 모달에서 방금 저장한 신규 데이터가 이전 문서 데이터보다 최신) useEffect(() => { if (!isBending || !inspectionDataMap || !workItems || bendingProducts.length === 0) return; // inspectionDataMap에서 products 배열 찾기 type SavedProduct = { id: string; bendingStatus: string | null; lengthMeasured: string; widthMeasured: string; gapPoints: Array<{ point: string; designValue: string; measured: string }>; }; let savedProducts: SavedProduct[] | undefined; for (const wi of workItems) { const d = inspectionDataMap.get(wi.id) as Record | undefined; if (d?.products && Array.isArray(d.products)) { savedProducts = d.products as SavedProduct[]; break; } } if (!savedProducts || savedProducts.length === 0) return; const initial: Record = {}; // 컬럼 분류 const checkColId = template.columns.find(c => c.column_type === 'check')?.id; const complexCols = template.columns.filter(c => c.column_type === 'complex' && c.id !== gapColumnId ); // 각 template bendingProduct → 저장된 product 매핑 bendingProducts.forEach((bp, productIdx) => { // 1. ID 정규화 매칭 (guide-rail-wall ↔ guide_rail_wall) const normalizedBpId = bp.id.replace(/[-_]/g, '').toLowerCase(); let matched = savedProducts!.find(sp => sp.id.replace(/[-_]/g, '').toLowerCase() === normalizedBpId ); // 2. 이름 키워드 매칭 if (!matched) { const bpKey = `${bp.productName}${bp.productType}`.replace(/\s/g, '').toLowerCase(); matched = savedProducts!.find(sp => { const spId = sp.id.toLowerCase(); if (bpKey.includes('가이드레일') && bpKey.includes('벽면') && spId.includes('guide') && spId.includes('wall')) return true; if (bpKey.includes('가이드레일') && bpKey.includes('측면') && spId.includes('guide') && spId.includes('side')) return true; if (bpKey.includes('케이스') && spId.includes('case')) return true; if (bpKey.includes('하단마감') && (spId.includes('bottom-finish') || spId.includes('bottom_bar'))) return true; if (bpKey.includes('연기차단') && bpKey.includes('w50') && spId.includes('w50')) return true; if (bpKey.includes('연기차단') && bpKey.includes('w80') && spId.includes('w80')) return true; return false; }); } // 3. 인덱스 폴백 if (!matched && productIdx < savedProducts!.length) { matched = savedProducts![productIdx]; } if (!matched) return; // check 컬럼 (절곡상태) if (checkColId) { const cellKey = `b-${productIdx}-${checkColId}`; if (matched.bendingStatus === '양호') { initial[cellKey] = { status: 'good' }; } else if (matched.bendingStatus === '불량') { initial[cellKey] = { status: 'bad' }; } } // 간격 컬럼 if (gapColumnId && matched.gapPoints) { matched.gapPoints.forEach((gp, pointIdx) => { if (gp.measured) { const cellKey = `b-${productIdx}-p${pointIdx}-${gapColumnId}`; initial[cellKey] = { measurements: [gp.measured, '', ''] }; } }); } // complex 컬럼 (길이/너비) // bending 렌더링은 measurements[si] (si = sub_label raw index)를 읽으므로 // 측정값 sub_label의 실제 si 위치에 값을 넣어야 함 for (const col of complexCols) { const label = col.label.trim(); const cellKey = `b-${productIdx}-${col.id}`; // 측정값 sub_label의 si 인덱스 찾기 let measurementSi = 0; if (col.sub_labels) { for (let si = 0; si < col.sub_labels.length; si++) { const sl = col.sub_labels[si].toLowerCase(); if (!sl.includes('도면') && !sl.includes('기준')) { measurementSi = si; break; } } } const measurements: [string, string, string] = ['', '', '']; if (label.includes('길이') && matched.lengthMeasured) { measurements[measurementSi] = matched.lengthMeasured; initial[cellKey] = { measurements }; } else if ((label.includes('너비') || label.includes('폭') || label.includes('높이')) && matched.widthMeasured) { measurements[measurementSi] = matched.widthMeasured; initial[cellKey] = { measurements }; } } }); if (Object.keys(initial).length > 0) { setCellValues(prev => ({ ...prev, ...initial })); } }, [isBending, inspectionDataMap, workItems, bendingProducts, template.columns, gapColumnId]); const updateCell = (key: string, update: Partial) => { setCellValues(prev => ({ ...prev, [key]: { ...prev[key], ...update }, })); }; // 행별 판정 계산 const getRowJudgment = (rowIdx: number): '적' | '부' | null => { let hasAnyValue = false; let hasFail = false; for (const col of template.columns) { const sectionItem = columnItemMap.get(col.id); if (!sectionItem) continue; const key = `${rowIdx}-${col.id}`; const cell = cellValues[key]; if (!cell) continue; const mType = sectionItem.measurement_type; if (mType === 'checkbox' || col.column_type === 'check') { if (cell.status === 'bad') hasFail = true; if (cell.status) hasAnyValue = true; } else if (mType === 'numeric') { const measurements = cell.measurements || ['', '', '']; for (const m of measurements) { if (m) { hasAnyValue = true; const val = parseFloat(m); if (!isNaN(val) && !isWithinTolerance(val, sectionItem, effectiveWorkItems[rowIdx])) hasFail = true; } } } else if (mType === 'single_value') { if (cell.value) { hasAnyValue = true; const val = parseFloat(cell.value); if (!isNaN(val) && !isWithinTolerance(val, sectionItem, effectiveWorkItems[rowIdx])) hasFail = true; } } else if (mType === 'substitute') { hasAnyValue = true; } else if (cell.value || cell.text) { hasAnyValue = true; } } if (!hasAnyValue) return null; return hasFail ? '부' : '적'; }; // ref로 데이터 수집 노출 - 정규화된 document_data 레코드 형식 useImperativeHandle(ref, () => ({ getInspectionData: () => { const records: Array<{ section_id: number | null; column_id: number | null; row_index: number; field_key: string; field_value: string | null; }> = []; // ===== 1. 기본 필드 (bf_xxx) ===== if (template.basic_fields?.length > 0) { for (const field of template.basic_fields) { const val = resolveFieldValue(field); if (val && val !== '-' && val !== '(입력)') { records.push({ section_id: null, column_id: null, row_index: 0, field_key: `bf_${field.id}`, field_value: val, }); } } } // ===== 2. 행별 검사 데이터 ===== // Bending 모드: 구성품별 데이터 (개소 단위, field_key에 구성품/포인트 인코딩) if (isBending && bendingProducts.length > 0) { bendingProducts.forEach((product, productIdx) => { for (const col of template.columns) { const label = col.label.trim(); const isGapCol = col.id === gapColumnId; // text 컬럼 (분류/제품명, 타입) → bendingInfo에서 동적 생성이므로 저장 불필요 if (col.column_type === 'text') continue; // 판정 컬럼 → 자동 계산 결과 저장 if (isJudgmentColumn(label)) { const judgment = getBendingProductJudgment(productIdx); if (judgment) { records.push({ section_id: null, column_id: col.id, row_index: 0, field_key: `b${productIdx}_judgment`, field_value: judgment === '적' ? 'OK' : 'NG', }); } continue; } // 간격 컬럼 (per-point 데이터) if (isGapCol) { product.gapPoints.forEach((_gp, pointIdx) => { const cellKey = `b-${productIdx}-p${pointIdx}-${col.id}`; const cell = cellValues[cellKey]; if (cell?.measurements?.[0]) { records.push({ section_id: null, column_id: col.id, row_index: 0, field_key: `b${productIdx}_p${pointIdx}_n1`, field_value: cell.measurements[0], }); } }); continue; } // 비간격 merged 컬럼 const cellKey = `b-${productIdx}-${col.id}`; const cell = cellValues[cellKey]; // check 컬럼 (절곡상태) if (col.column_type === 'check') { records.push({ section_id: null, column_id: col.id, row_index: 0, field_key: `b${productIdx}_ok`, field_value: cell?.status === 'good' ? 'OK' : '', }); records.push({ section_id: null, column_id: col.id, row_index: 0, field_key: `b${productIdx}_ng`, field_value: cell?.status === 'bad' ? 'NG' : '', }); continue; } // complex 컬럼 (길이/너비 측정) if (col.column_type === 'complex' && col.sub_labels) { let inputIdx = 0; for (const sl of col.sub_labels) { const slLower = sl.toLowerCase(); if (slLower.includes('도면') || slLower.includes('기준')) continue; if (slLower.includes('point') || slLower.includes('포인트')) continue; const n = inputIdx + 1; const val = cell?.measurements?.[inputIdx] || null; if (val) { records.push({ section_id: null, column_id: col.id, row_index: 0, field_key: `b${productIdx}_n${n}`, field_value: val, }); } inputIdx++; } continue; } // fallback if (cell?.value) { records.push({ section_id: null, column_id: col.id, row_index: 0, field_key: `b${productIdx}_value`, field_value: cell.value, }); } } }); } // 개소(WorkItem)별 데이터: 비-Bending 또는 Bending이지만 구성품이 없는 경우 (AS-IS) if (!isBending || bendingProducts.length === 0) effectiveWorkItems.forEach((wi, rowIdx) => { for (const col of template.columns) { // 일련번호 컬럼 → 저장 (mng show에서 표시용) if (isSerialColumn(col.label)) { records.push({ section_id: null, column_id: col.id, row_index: rowIdx, field_key: 'value', field_value: String(rowIdx + 1), }); continue; } // 판정 컬럼 → 자동 계산 결과 저장 if (isJudgmentColumn(col.label)) { const judgment = getRowJudgment(rowIdx); if (judgment) { records.push({ section_id: null, column_id: col.id, row_index: rowIdx, field_key: 'value', field_value: judgment === '적' ? 'OK' : 'NG', }); } continue; } const sectionItem = columnItemMap.get(col.id); if (!sectionItem) continue; const sectionId = itemSectionMap.get(sectionItem.id) ?? null; const key = `${rowIdx}-${col.id}`; const cell = cellValues[key]; const mType = sectionItem.measurement_type || ''; if (col.column_type === 'complex' && col.sub_labels) { // 복합 컬럼: sub_label 유형별 처리 let inputIdx = 0; for (const subLabel of col.sub_labels) { const sl = subLabel.toLowerCase(); if (sl.includes('도면') || sl.includes('기준')) { // 기준치 → formatStandard 결과 저장 const stdVal = formatStandard(sectionItem, wi); records.push({ section_id: sectionId, column_id: col.id, row_index: rowIdx, field_key: 'standard', field_value: stdVal || null, }); } else if (sl.includes('ok') || sl.includes('ng')) { // OK·NG → cell.status 저장 const n = inputIdx + 1; if (mType === 'checkbox') { records.push({ section_id: sectionId, column_id: col.id, row_index: rowIdx, field_key: `n${n}_ok`, field_value: cell?.status === 'good' ? 'OK' : '', }); records.push({ section_id: sectionId, column_id: col.id, row_index: rowIdx, field_key: `n${n}_ng`, field_value: cell?.status === 'bad' ? 'NG' : '', }); } inputIdx++; } else { // 측정값 const n = inputIdx + 1; const val = cell?.measurements?.[inputIdx] || null; if (mType === 'checkbox') { records.push({ section_id: sectionId, column_id: col.id, row_index: rowIdx, field_key: `n${n}_ok`, field_value: val?.toLowerCase() === 'ok' ? 'OK' : '', }); records.push({ section_id: sectionId, column_id: col.id, row_index: rowIdx, field_key: `n${n}_ng`, field_value: val?.toLowerCase() === 'ng' ? 'NG' : '', }); } else { records.push({ section_id: sectionId, column_id: col.id, row_index: rowIdx, field_key: `n${n}`, field_value: val, }); } inputIdx++; } } } else if (cell?.value !== undefined) { records.push({ section_id: sectionId, column_id: col.id, row_index: rowIdx, field_key: 'value', field_value: cell.value || null, }); } else if (cell?.text !== undefined) { records.push({ section_id: sectionId, column_id: col.id, row_index: rowIdx, field_key: 'value', field_value: cell.text || null, }); } } }); // ===== 3. 종합판정 ===== records.push({ section_id: null, column_id: null, row_index: 0, field_key: 'overall_result', field_value: overallResult, }); // ===== 4. 부적합 내용 (비고) ===== if (inadequateContent) { records.push({ section_id: null, column_id: null, row_index: 0, field_key: 'remark', field_value: inadequateContent, }); } return { template_id: template.id, records }; }, })); // Bending 제품별 판정 const getBendingProductJudgment = (productIdx: number): '적' | '부' | null => { const checkCol = template.columns.find(c => c.column_type === 'check'); if (!checkCol) return null; const cell = cellValues[`b-${productIdx}-${checkCol.id}`]; if (cell?.status === 'bad') return '부'; if (cell?.status === 'good') return '적'; return null; }; // 종합판정 const judgments = isBending && bendingProducts.length > 0 ? bendingProducts.map((_, idx) => getBendingProductJudgment(idx)) : effectiveWorkItems.map((_, idx) => getRowJudgment(idx)); const overallResult = calculateOverallResult(judgments); // 컬럼별 colspan 계산 (mng _colSpan 동기화) const getColSpan = (col: (typeof template.columns)[0]) => { if (col.column_type === 'complex' && col.sub_labels && col.sub_labels.length > 0) { return col.sub_labels.length; } const label = (col.label || '').trim(); if (label.includes('검사항목') || label.includes('항목') || label.includes('검사기준') || label.includes('기준')) return 2; return 1; }; // check 컬럼의 체크박스 라벨 추출 (sub_labels 기반, 없으면 양호/불량) const getCheckLabels = (col: (typeof template.columns)[0]): [string, string] => { if (col.sub_labels && col.sub_labels.length >= 2) { const a = (col.sub_labels[0] || '').trim(); const b = (col.sub_labels[1] || '').trim(); if (a && b && !/^n?\d+$/i.test(a)) return [a, b]; } return ['양호', '불량']; }; // 기본필드 값 해석 (field_key 또는 label 기반 매핑) const resolveFieldValue = (field: (typeof template.basic_fields)[0]) => { if (!field) return ''; // field_key가 있으면 field_key 기준, 없으면 label 기준 매칭 const key = field.field_key || ''; const label = (field.label || '').trim(); const LABEL_TO_KEY: Record = { '품명': 'product_name', '제품명': 'product_name', '규격': 'specification', '수주 LOT NO': 'lot_no', 'LOT NO': 'lot_no', '로트크기': 'lot_size', '발주처': 'client', '현장명': 'site_name', '검사일자': 'inspection_date', '검사자': 'inspector', }; const resolvedKey = key || LABEL_TO_KEY[label] || ''; switch (resolvedKey) { case 'product_name': return order.items?.[0]?.productName || '-'; case 'specification': return field.default_value || '-'; case 'lot_no': return order.lotNo || '-'; case 'lot_size': return `${effectiveWorkItems.length || order.items?.length || 0} 개소`; case 'client': return order.client || '-'; case 'site_name': return order.projectName || '-'; case 'inspection_date': return fullDate; case 'inspector': return primaryAssignee; default: return field.default_value || '-'; } }; // --- complex 컬럼 하위 셀 렌더링 --- const renderComplexCells = ( col: (typeof template.columns)[0], cellKey: string, cell: CellValue | undefined, workItem: WorkItemData, ) => { if (!col.sub_labels) return null; const sectionItem = columnItemMap.get(col.id); let inputIdx = 0; return col.sub_labels.map((subLabel, subIdx) => { const sl = subLabel.toLowerCase(); // 도면치수/기준치 → 기준값 readonly 표시 if (sl.includes('도면') || sl.includes('기준')) { return ( {sectionItem ? formatStandard(sectionItem, workItem) : '-'} ); } // OK/NG → 체크박스 토글 if (sl.includes('ok') || sl.includes('ng')) { return (
); } // POINT → 포인트 번호 표시 (mng mCell 동기화) if (sl.includes('point') || sl.includes('포인트')) { return ( ({subIdx + 1}) ); } // 측정값 → 입력 필드 const mIdx = inputIdx++; return ( { const m: [string, string, string] = [ ...(cell?.measurements || ['', '', '']), ] as [string, string, string]; m[mIdx] = e.target.value; updateCell(cellKey, { measurements: m }); }} readOnly={readOnly} placeholder="측정값" /> ); }); }; // 다단계 헤더 행 빌드 (group_name "/" 구분자 지원, mng renderHeaders 동기화) const buildHeaderRows = () => { const cols = template.columns; if (cols.length === 0) return []; const thCls = 'border border-gray-400 px-2 py-1.5 bg-gray-100 text-center'; const thSubCls = 'border border-gray-400 px-1 py-1 bg-gray-100 text-center text-[10px]'; // 1) group_name "/" split → depth 배열 const colGroups = cols.map(col => { const gn = (col.group_name || '').trim(); return gn ? gn.split('/').map(s => s.trim()) : []; }); // 단일 레벨 group_name은 그룹 행 생성 안 함 (하위 호환) const maxDepth = Math.max(0, ...colGroups.map(g => g.length > 1 ? g.length : 0)); const needSubRow = cols.some(c => c.column_type === 'complex' && c.sub_labels && c.sub_labels.length > 0 ); const totalHeaderRows = maxDepth + 1 + (needSubRow ? 1 : 0); const rows: React.ReactElement[] = []; // 2) 그룹 행들 (maxDepth > 0일 때만) if (maxDepth > 0) { for (let depth = 0; depth < maxDepth; depth++) { const cells: React.ReactElement[] = []; let ci = 0; while (ci < cols.length) { const levels = colGroups[ci]; const col = cols[ci]; // 그룹 없는/단일 레벨: depth=0에서 전체 rowspan if (levels.length <= 1) { if (depth === 0) { const cs = getColSpan(col); cells.push( 1 ? cs : undefined} rowSpan={totalHeaderRows} style={col.width ? { width: col.width } : undefined}> {col.label} ); } ci++; continue; } if (levels.length <= depth) { ci++; continue; } // 같은 prefix 중복 방지 const prefix = levels.slice(0, depth + 1).join('/'); let isFirst = true; for (let k = 0; k < ci; k++) { if (colGroups[k].length > depth && colGroups[k].slice(0, depth + 1).join('/') === prefix) { isFirst = false; break; } } if (!isFirst) { ci++; continue; } // colspan 합산 let span = getColSpan(col); let cj = ci + 1; while (cj < cols.length) { if (colGroups[cj].length > depth && colGroups[cj].slice(0, depth + 1).join('/') === prefix) { span += getColSpan(cols[cj]); cj++; } else break; } // 하위 그룹 존재 여부 let hasDeeper = false; for (let k = ci; k < cj; k++) { if (colGroups[k].length > depth + 1) { hasDeeper = true; break; } } const groupLabel = levels[depth]; const rs = (!hasDeeper && depth < maxDepth - 1) ? maxDepth - depth : undefined; cells.push( {groupLabel} ); ci = cj; } rows.push({cells}); } } // 3) 컬럼 라벨 행 const labelCells: React.ReactElement[] = []; cols.forEach((col, idx) => { const levels = colGroups[idx]; if (maxDepth > 0 && levels.length <= 1) return; const cs = getColSpan(col); const isComplex = col.column_type === 'complex' && col.sub_labels && col.sub_labels.length > 0; if (needSubRow && isComplex) { labelCells.push( {col.label} ); } else if (needSubRow) { labelCells.push( 1 ? cs : undefined} rowSpan={2} style={col.width ? { width: col.width } : undefined}> {col.label} ); } else { labelCells.push( 1 ? cs : undefined} style={col.width ? { width: col.width } : undefined}> {col.label} ); } }); rows.push({labelCells}); // 4) sub_labels 행 if (needSubRow) { const subCells: React.ReactElement[] = []; cols.forEach(col => { if (col.column_type === 'complex' && col.sub_labels && col.sub_labels.length > 0) { col.sub_labels.forEach((sl, si) => { const slLower = (sl || '').toLowerCase(); const isBasis = slLower.includes('도면') || slLower.includes('기준'); subCells.push( {sl} ); }); } }); rows.push({subCells}); } return rows; }; // --- Bending DATA 테이블 body 렌더링 (제품별 다중 POINT 행 + rowSpan 병합) --- const renderBendingBody = () => { return bendingExpandedRows.map((row) => { const { productIdx, product, pointIdx, gapPoint, isFirstRow, totalPoints } = row; return ( {template.columns.map(col => { const isGapCol = col.id === gapColumnId; // 간격 외 컬럼: 첫 행만 렌더 (rowSpan으로 병합) if (!isGapCol && !isFirstRow) return null; const rs = isFirstRow && !isGapCol ? totalPoints : undefined; const cellKey = isGapCol ? `b-${productIdx}-p${pointIdx}-${col.id}` : `b-${productIdx}-${col.id}`; const cell = cellValues[cellKey]; const label = col.label.trim(); // 1. 분류/제품명 (text) if (col.column_type === 'text' && (label.includes('분류') || label.includes('제품명'))) { return (
{product.category}
{product.productName}
); } // 2. 타입 (text) if (col.column_type === 'text' && label === '타입') { return ( {product.productType} ); } // 3. check (절곡상태 - 양호/불량) if (col.column_type === 'check') { const [goodLabel, badLabel] = getCheckLabels(col); return ( 1 ? getColSpan(col) : undefined}>
); } // 4. complex 간격 (per-POINT) → POINT + 도면치수 + 측정값 if (isGapCol && col.column_type === 'complex' && col.sub_labels) { return col.sub_labels.map((sl, si) => { const slLower = sl.toLowerCase(); if (slLower.includes('point') || slLower.includes('포인트')) { return ( {gapPoint.point} ); } if (slLower.includes('도면') || slLower.includes('기준')) { return ( {gapPoint.designValue} ); } return ( updateCell(cellKey, { measurements: [e.target.value, '', ''] })} readOnly={readOnly} placeholder="측정값" /> ); }); } // 5. complex 길이/너비 (merged) → 도면치수 + 측정값 if (!isGapCol && col.column_type === 'complex' && col.sub_labels) { const isLen = label.includes('길이'); const designVal = isLen ? product.lengthDesign : product.widthDesign; return col.sub_labels.map((sl, si) => { const slLower = sl.toLowerCase(); if (slLower.includes('도면') || slLower.includes('기준')) { return ( {designVal} ); } return ( { const m: [string, string, string] = [...(cell?.measurements || ['', '', ''])] as [string, string, string]; m[si] = e.target.value; updateCell(cellKey, { measurements: m }); }} readOnly={readOnly} placeholder="측정값" /> ); }); } // 6. 판정 (적/부) if (isJudgmentColumn(label)) { return ; } // fallback return ( - ); })} ); }); }; return (
{/* ===== 헤더: KD + 회사명(좌) | 제목(중앙) | 결재라인(우) ===== */}
KD
{template.company_name && (
{template.company_name}
)}

{template.title || template.name || '중간검사 성적서'}

{template.approval_lines?.length > 0 ? ( {template.approval_lines.map(line => ( ))} {template.approval_lines.map(line => ( ))}
{line.name || '-'}
{line.dept || ''}
) : ( 결재라인 미설정 )}
{/* ===== 기본 필드: 2열 배치 (15:35:15:35) ===== */} {template.basic_fields?.length > 0 && ( {Array.from({ length: Math.ceil(template.basic_fields.length / 2) }, (_, rowIdx) => { const f1 = template.basic_fields[rowIdx * 2]; const f2 = template.basic_fields[rowIdx * 2 + 1]; return ( {f2 ? ( <> ) : ( ); })}
{f1.label} {resolveFieldValue(f1)} {f2.label} {resolveFieldValue(f2)} )}
)} {/* ===== 이미지 섹션: items 없는 섹션 ===== */} {imageSections.map(section => ( ))} {/* ===== DATA 테이블: columns 기반 헤더 + work items 행 ===== */} {template.columns.length > 0 && (isBending ? bendingProducts.length > 0 : effectiveWorkItems.length > 0) && ( <> {dataSections.length > 0 && (

■ {dataSections[0].title || dataSections[0].name}

)}
{buildHeaderRows()} {isBending ? renderBendingBody() : effectiveWorkItems.map((wi, rowIdx) => ( {template.columns.map(col => { const cellKey = `${rowIdx}-${col.id}`; const cell = cellValues[cellKey]; // 일련번호/NO if (isSerialColumn(col.label)) { return ( ); } // 판정 (자동 계산) if (isJudgmentColumn(col.label)) { return ; } // check → 커스텀 라벨 (mng _getCheckLabels 동기화) if (col.column_type === 'check') { const [goodLabel, badLabel] = getCheckLabels(col); return ( ); } // complex → sub_labels 개수만큼 셀 생성 if (col.column_type === 'complex' && col.sub_labels && col.sub_labels.length > 0) { return renderComplexCells(col, cellKey, cell, wi); } // select (판정 외) → 텍스트 입력 if (col.column_type === 'select') { return ( ); } // text/기타 → 텍스트 입력 return ( ); })} ))}
{rowIdx + 1} 1 ? getColSpan(col) : undefined}>
1 ? getColSpan(col) : undefined}> updateCell(cellKey, { value: e.target.value })} readOnly={readOnly} placeholder="-" /> 1 ? getColSpan(col) : undefined}> updateCell(cellKey, { value: e.target.value })} readOnly={readOnly} placeholder="-" />
)} {/* ===== 푸터: 비고(좌) + 종합판정(우) 높이 동일 배치 ===== */}
{template.footer_remark_label || '비고'}