- middleware CSP: *.kakao.com, *.kakaocdn.net 추가 (다음 주소찾기 차단 해결) - frame-src에 'self' 추가 - 공지 팝업 이미지 중앙정렬 ([&_img]:mx-auto) - HR 사원관리, 결재, 품목, 생산 등 다수 개선 - API 에러 핸들링 및 JSON 파싱 안정화
1719 lines
69 KiB
TypeScript
1719 lines
69 KiB
TypeScript
'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"> </span>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
);
|