Files
sam-react-prod/src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx
유병철 ea6ca335f1 feat: CSP 다음/카카오 도메인 허용 + 입고 성적서 파일 백엔드 연동 + 팝업 이미지 중앙정렬
- middleware CSP: *.kakao.com, *.kakaocdn.net 추가 (다음 주소찾기 차단 해결)
- frame-src에 'self' 추가
- 공지 팝업 이미지 중앙정렬 ([&_img]:mx-auto)
- HR 사원관리, 결재, 품목, 생산 등 다수 개선
- API 에러 핸들링 및 JSON 파싱 안정화
2026-03-11 22:32:58 +09:00

1719 lines
69 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<string, number | undefined> = {
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<string, BendingGapPoint[]> = {
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 (
<div className="mb-3">
<p className="text-sm font-bold mb-1"> {title}</p>
{url && !imgError ? (
<img
src={url}
alt={title}
className="w-full border rounded"
onError={() => setImgError(true)}
/>
) : (
<div className="border border-dashed border-gray-300 rounded p-6 text-center text-gray-400 text-xs">
</div>
)}
</div>
);
}
// ===== 컴포넌트 =====
export const TemplateInspectionContent = forwardRef<InspectionContentRef, TemplateInspectionContentProps>(
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<number, number>();
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<number, InspectionTemplateSectionItem>();
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<Record<string, CellValue>>({});
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<InspectionConfigData | null>(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<string, { name: string; type: string; len: string; wid: string }> = {
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<string, CellValue> = {};
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<string, (d: Record<string, unknown>) => 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<string, unknown> | 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<string, CellValue> = {};
// 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<string, unknown> | undefined;
if (d?.products && Array.isArray(d.products)) {
savedProducts = d.products as SavedProduct[];
break;
}
}
if (!savedProducts || savedProducts.length === 0) return;
const initial: Record<string, CellValue> = {};
// 컬럼 분류
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<CellValue>) => {
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<string, string> = {
'품명': '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 (
<td key={`${col.id}-s${subIdx}`} className="border border-gray-400 px-2 py-1.5 text-center text-gray-500">
{sectionItem ? formatStandard(sectionItem, workItem) : '-'}
</td>
);
}
// OK/NG → 체크박스 토글
if (sl.includes('ok') || sl.includes('ng')) {
return (
<td key={`${col.id}-s${subIdx}`} className="border border-gray-400 p-1">
<div className="flex flex-col items-center gap-0.5">
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
<InspectionCheckbox
checked={cell?.status === 'good'}
onClick={() => updateCell(cellKey, { status: cell?.status === 'good' ? null : 'good' })}
readOnly={readOnly}
/>
OK
</label>
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
<InspectionCheckbox
checked={cell?.status === 'bad'}
onClick={() => updateCell(cellKey, { status: cell?.status === 'bad' ? null : 'bad' })}
readOnly={readOnly}
/>
NG
</label>
</div>
</td>
);
}
// POINT → 포인트 번호 표시 (mng mCell 동기화)
if (sl.includes('point') || sl.includes('포인트')) {
return (
<td key={`${col.id}-s${subIdx}`} className="border border-gray-400 px-1 py-1.5 text-center">
<span className="text-gray-400 text-[10px] italic">({subIdx + 1})</span>
</td>
);
}
// 측정값 → 입력 필드
const mIdx = inputIdx++;
return (
<td key={`${col.id}-s${subIdx}`} className="border border-gray-400 p-0.5">
<input
type="text"
className={INPUT_CLASS}
value={cell?.measurements?.[mIdx] || ''}
onChange={e => {
const m: [string, string, string] = [
...(cell?.measurements || ['', '', '']),
] as [string, string, string];
m[mIdx] = e.target.value;
updateCell(cellKey, { measurements: m });
}}
readOnly={readOnly}
placeholder="측정값"
/>
</td>
);
});
};
// 다단계 헤더 행 빌드 (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(
<th key={`g0-${ci}`} className={thCls}
colSpan={cs > 1 ? cs : undefined}
rowSpan={totalHeaderRows}
style={col.width ? { width: col.width } : undefined}>
{col.label}
</th>
);
}
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(
<th key={`g-${ci}-${depth}`} className={thCls} colSpan={span} rowSpan={rs}>
{groupLabel}
</th>
);
ci = cj;
}
rows.push(<tr key={`grp-${depth}`}>{cells}</tr>);
}
}
// 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(
<th key={col.id} className={thCls} colSpan={cs}>{col.label}</th>
);
} else if (needSubRow) {
labelCells.push(
<th key={col.id} className={thCls}
colSpan={cs > 1 ? cs : undefined} rowSpan={2}
style={col.width ? { width: col.width } : undefined}>
{col.label}
</th>
);
} else {
labelCells.push(
<th key={col.id} className={thCls}
colSpan={cs > 1 ? cs : undefined}
style={col.width ? { width: col.width } : undefined}>
{col.label}
</th>
);
}
});
rows.push(<tr key="labels">{labelCells}</tr>);
// 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(
<th key={`${col.id}-sh-${si}`} className={thSubCls}
style={isBasis ? { background: '#f3f4f6' } : undefined}>
{sl}
</th>
);
});
}
});
rows.push(<tr key="sublabels">{subCells}</tr>);
}
return rows;
};
// --- Bending DATA 테이블 body 렌더링 (제품별 다중 POINT 행 + rowSpan 병합) ---
const renderBendingBody = () => {
return bendingExpandedRows.map((row) => {
const { productIdx, product, pointIdx, gapPoint, isFirstRow, totalPoints } = row;
return (
<tr key={`b-${productIdx}-${pointIdx}`}>
{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 (
<td key={col.id} rowSpan={rs}
className="border border-gray-400 px-2 py-1.5 text-center align-middle text-xs"
style={col.width ? { width: col.width } : undefined}>
<div>{product.category}</div>
<div className="font-medium">{product.productName}</div>
</td>
);
}
// 2. 타입 (text)
if (col.column_type === 'text' && label === '타입') {
return (
<td key={col.id} rowSpan={rs}
className="border border-gray-400 px-2 py-1.5 text-center align-middle text-xs whitespace-pre-line"
style={col.width ? { width: col.width } : undefined}>
{product.productType}
</td>
);
}
// 3. check (절곡상태 - 양호/불량)
if (col.column_type === 'check') {
const [goodLabel, badLabel] = getCheckLabels(col);
return (
<td key={col.id} rowSpan={rs} className="border border-gray-400 p-1"
colSpan={getColSpan(col) > 1 ? getColSpan(col) : undefined}>
<div className="flex flex-col items-center gap-0.5">
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
<InspectionCheckbox
checked={cell?.status === 'good'}
onClick={() => updateCell(cellKey, { status: cell?.status === 'good' ? null : 'good' })}
readOnly={readOnly}
/>
{goodLabel}
</label>
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
<InspectionCheckbox
checked={cell?.status === 'bad'}
onClick={() => updateCell(cellKey, { status: cell?.status === 'bad' ? null : 'bad' })}
readOnly={readOnly}
/>
{badLabel}
</label>
</div>
</td>
);
}
// 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 (
<td key={`${col.id}-s${si}`}
className="border border-gray-400 px-1 py-1.5 text-center text-xs">
{gapPoint.point}
</td>
);
}
if (slLower.includes('도면') || slLower.includes('기준')) {
return (
<td key={`${col.id}-s${si}`}
className="border border-gray-400 px-2 py-1.5 text-center text-blue-600 font-bold text-xs">
{gapPoint.designValue}
</td>
);
}
return (
<td key={`${col.id}-s${si}`} className="border border-gray-400 p-0.5">
<input type="text" className={INPUT_CLASS}
value={cell?.measurements?.[0] || ''}
onChange={e => updateCell(cellKey, { measurements: [e.target.value, '', ''] })}
readOnly={readOnly} placeholder="측정값"
/>
</td>
);
});
}
// 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 (
<td key={`${col.id}-s${si}`} rowSpan={rs}
className="border border-gray-400 px-2 py-1.5 text-center text-blue-600 font-bold text-xs">
{designVal}
</td>
);
}
return (
<td key={`${col.id}-s${si}`} rowSpan={rs} className="border border-gray-400 p-0.5">
<input type="text" className={INPUT_CLASS}
value={cell?.measurements?.[si] || ''}
onChange={e => {
const m: [string, string, string] = [...(cell?.measurements || ['', '', ''])] as [string, string, string];
m[si] = e.target.value;
updateCell(cellKey, { measurements: m });
}}
readOnly={readOnly} placeholder="측정값"
/>
</td>
);
});
}
// 6. 판정 (적/부)
if (isJudgmentColumn(label)) {
return <JudgmentCell key={col.id} judgment={getBendingProductJudgment(productIdx)} rowSpan={rs} />;
}
// fallback
return (
<td key={col.id} rowSpan={rs}
className="border border-gray-400 px-2 py-1.5 text-center text-xs">-</td>
);
})}
</tr>
);
});
};
return (
<div className="p-6 bg-white">
{/* ===== 헤더: KD + 회사명(좌) | 제목(중앙) | 결재라인(우) ===== */}
<div className="flex justify-between items-start mb-4">
<div className="text-center" style={{ width: 80 }}>
<div className="text-2xl font-bold">KD</div>
{template.company_name && (
<div className="text-xs">{template.company_name}</div>
)}
</div>
<div className="flex-1 text-center">
<h1 className="text-xl font-bold tracking-widest">
{template.title || template.name || '중간검사 성적서'}
</h1>
</div>
<div>
{template.approval_lines?.length > 0 ? (
<table className="border-collapse text-xs">
<tbody>
<tr>
{template.approval_lines.map(line => (
<td key={`n-${line.id}`} className="border border-gray-400 px-3 py-1 bg-gray-100 text-center font-medium">
{line.name || '-'}
</td>
))}
</tr>
<tr>
{template.approval_lines.map(line => (
<td key={`d-${line.id}`} className="border border-gray-400 px-3 py-1 text-center">
<div className="text-gray-400 text-xs">{line.dept || ''}</div>
<div className="h-6" />
</td>
))}
</tr>
</tbody>
</table>
) : (
<span className="text-xs text-gray-400"> </span>
)}
</div>
</div>
{/* ===== 기본 필드: 2열 배치 (15:35:15:35) ===== */}
{template.basic_fields?.length > 0 && (
<table className="w-full border-collapse text-xs mb-4" style={{ tableLayout: 'fixed' }}>
<tbody>
{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 (
<tr key={rowIdx}>
<td className="border border-gray-400 px-2 py-1.5 bg-gray-100 font-medium text-center" style={{ width: '15%' }}>
{f1.label}
</td>
<td className="border border-gray-400 px-2 py-1.5" style={{ width: '35%' }}>
{resolveFieldValue(f1)}
</td>
{f2 ? (
<>
<td className="border border-gray-400 px-2 py-1.5 bg-gray-100 font-medium text-center" style={{ width: '15%' }}>
{f2.label}
</td>
<td className="border border-gray-400 px-2 py-1.5" style={{ width: '35%' }}>
{resolveFieldValue(f2)}
</td>
</>
) : (
<td className="border border-gray-400 px-2 py-1.5" colSpan={2} />
)}
</tr>
);
})}
</tbody>
</table>
)}
{/* ===== 이미지 섹션: items 없는 섹션 ===== */}
{imageSections.map(section => (
<SectionImage key={section.id} section={section} />
))}
{/* ===== DATA 테이블: columns 기반 헤더 + work items 행 ===== */}
{template.columns.length > 0 && (isBending ? bendingProducts.length > 0 : effectiveWorkItems.length > 0) && (
<>
{dataSections.length > 0 && (
<p className="text-sm font-bold mb-1 mt-3"> {dataSections[0].title || dataSections[0].name}</p>
)}
<div className="overflow-x-auto">
<table className="w-full border-collapse text-xs">
<thead>
{buildHeaderRows()}
</thead>
<tbody>
{isBending ? renderBendingBody() : effectiveWorkItems.map((wi, rowIdx) => (
<tr key={wi.id}>
{template.columns.map(col => {
const cellKey = `${rowIdx}-${col.id}`;
const cell = cellValues[cellKey];
// 일련번호/NO
if (isSerialColumn(col.label)) {
return (
<td key={col.id} className="border border-gray-400 px-2 py-1.5 text-center">
{rowIdx + 1}
</td>
);
}
// 판정 (자동 계산)
if (isJudgmentColumn(col.label)) {
return <JudgmentCell key={col.id} judgment={getRowJudgment(rowIdx)} />;
}
// check → 커스텀 라벨 (mng _getCheckLabels 동기화)
if (col.column_type === 'check') {
const [goodLabel, badLabel] = getCheckLabels(col);
return (
<td key={col.id} className="border border-gray-400 p-1"
colSpan={getColSpan(col) > 1 ? getColSpan(col) : undefined}>
<div className="flex flex-col items-center gap-0.5">
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
<InspectionCheckbox
checked={cell?.status === 'good'}
onClick={() => updateCell(cellKey, { status: cell?.status === 'good' ? null : 'good' })}
readOnly={readOnly}
/>
{goodLabel}
</label>
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
<InspectionCheckbox
checked={cell?.status === 'bad'}
onClick={() => updateCell(cellKey, { status: cell?.status === 'bad' ? null : 'bad' })}
readOnly={readOnly}
/>
{badLabel}
</label>
</div>
</td>
);
}
// 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 (
<td key={col.id} className="border border-gray-400 p-0.5"
colSpan={getColSpan(col) > 1 ? getColSpan(col) : undefined}>
<input
type="text"
className={INPUT_CLASS}
value={cell?.value || ''}
onChange={e => updateCell(cellKey, { value: e.target.value })}
readOnly={readOnly}
placeholder="-"
/>
</td>
);
}
// text/기타 → 텍스트 입력
return (
<td key={col.id} className="border border-gray-400 p-0.5"
colSpan={getColSpan(col) > 1 ? getColSpan(col) : undefined}>
<input
type="text"
className={INPUT_CLASS}
value={cell?.value || cell?.text || ''}
onChange={e => updateCell(cellKey, { value: e.target.value })}
readOnly={readOnly}
placeholder="-"
/>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{/* ===== 푸터: 비고(좌) + 종합판정(우) 높이 동일 배치 ===== */}
<div className="mt-4 flex items-stretch gap-4">
<div className="flex-1">
<table className="w-full h-full border-collapse text-xs">
<tbody>
<tr>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 font-medium align-top" style={{ width: 100 }}>
{template.footer_remark_label || '비고'}
</td>
<td className="border border-gray-400 px-3 py-2 align-top">
<textarea
value={inadequateContent}
onChange={e => !readOnly && setInadequateContent(e.target.value)}
disabled={readOnly}
className="w-full border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs resize-none"
rows={2}
/>
</td>
</tr>
</tbody>
</table>
</div>
<div>
<table className="h-full border-collapse text-xs">
<tbody>
<tr>
<td className="border border-gray-400 px-4 py-2 bg-gray-100 font-medium">
{template.footer_judgement_label || '종합판정'}
</td>
</tr>
<tr>
<td className="border border-gray-400 px-4 py-3 text-center">
{overallResult ? (
<span className={`font-bold text-sm ${
overallResult === '합격' ? 'text-blue-600' : 'text-red-600'
}`}>
{overallResult}
</span>
) : (
<span className="text-gray-300 text-sm">&nbsp;</span>
)}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
);
}
);