Files
sam-manage/resources/views/document-templates/partials/preview-modal.blade.php
김보곤 baf1fb5ddf fix: [document-templates] 양식 디자이너 미리보기 렌더러 분기 처리
- builder_type=block 템플릿은 buildBlockPreviewHtml() 사용
- 레거시 템플릿은 기존 buildDocumentPreviewHtml() 유지
2026-02-28 20:16:37 +09:00

694 lines
39 KiB
PHP

{{-- 문서 미리보기 공통 모달 + 렌더링 함수 --}}
{{-- 사용법: @include('document-templates.partials.preview-modal') --}}
{{-- JS: buildDocumentPreviewHtml(data) 호출하여 HTML 생성 --}}
<!-- 미리보기 모달 -->
<div id="preview-modal" class="fixed inset-0 z-50 hidden overflow-y-auto">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="fixed inset-0 bg-black bg-opacity-50" onclick="closePreviewModal()"></div>
<div class="relative bg-white rounded-lg shadow-xl max-w-5xl w-full max-h-[90vh] overflow-hidden">
<div class="flex justify-between items-center p-4 border-b">
<h3 class="text-lg font-bold text-gray-800">문서 미리보기</h3>
<button onclick="closePreviewModal()" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div id="preview-content" class="p-4 overflow-auto max-h-[calc(90vh-120px)]">
<!-- 미리보기 내용 -->
</div>
</div>
</div>
</div>
@push('scripts')
<script>
// ===== 공통 미리보기 함수 =====
function closePreviewModal() {
document.getElementById('preview-modal').classList.add('hidden');
}
// ESC로 모달 닫기
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closePreviewModal();
});
// 이미지 URL 헬퍼 (API tenant storage vs MNG local storage)
function _previewImageUrl(imagePath) {
if (!imagePath) return '';
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) return imagePath;
// 새 API 업로드 형식 (tenant path: 1/temp/2026/02/xxx.jpg)
if (/^\d+\//.test(imagePath)) {
return 'http://api.sam.kr/storage/tenants/' + imagePath;
}
// 레거시 형식 (document-templates/xxx.jpg)
return '/storage/' + imagePath;
}
// HTML 이스케이프
function _previewEsc(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 공차 포맷팅
function _previewFormatTolerance(tol) {
if (!tol) return '-';
// 문자열인 경우 (Index API 응답)
if (typeof tol === 'string') return tol || '-';
// 객체인 경우 (Edit templateState)
if (!tol.type) return '-';
switch (tol.type) {
case 'symmetric':
return tol.value != null ? `\u00B1${tol.value}` : '-';
case 'asymmetric':
return (tol.plus != null || tol.minus != null)
? `+${tol.plus ?? 0} / -${tol.minus ?? 0}` : '-';
case 'range':
return (tol.min != null || tol.max != null)
? `${tol.min ?? ''} ~ ${tol.max ?? ''}` : '-';
case 'limit': {
const opSymbol = { lte: '\u2264', lt: '<', gte: '\u2265', gt: '>' };
return tol.value != null ? `${opSymbol[tol.op] || '\u2264'}${tol.value}` : '-';
}
default: return '-';
}
}
// 검사기준 포맷팅
function _previewFormatStandard(item) {
const c = item.standard_criteria;
if (c && (c.min != null || c.max != null)) {
const opLabel = { gte: '이상', gt: '초과', lte: '이하', lt: '미만' };
const parts = [];
if (c.min != null) parts.push(`${c.min} ${opLabel[c.min_op || 'gte']}`);
if (c.max != null) parts.push(`${c.max} ${opLabel[c.max_op || 'lte']}`);
return parts.join(' ~ ');
}
let std = item.standard || '-';
const tolStr = _previewFormatTolerance(item.tolerance);
if (tolStr !== '-') std += ' (' + tolStr + ')';
return _previewEsc(std);
}
// 주기 포맷팅
function _previewFormatFrequency(item) {
const parts = [];
if (item.frequency_n != null && item.frequency_n !== '') {
let nc = `n=${item.frequency_n}`;
if (item.frequency_c != null && item.frequency_c !== '') nc += `, c=${item.frequency_c}`;
parts.push(nc);
}
if (item.frequency) parts.push(_previewEsc(item.frequency));
return parts.length > 0 ? parts.join(' / ') : '-';
}
/**
* 문서 미리보기 HTML 생성 (공통)
*
* @param {Object} data - 미리보기 데이터
* @param {string} data.title - 문서 제목
* @param {string} data.company_name - 회사명
* @param {string} data.footer_remark_label - 비고 라벨
* @param {string} data.footer_judgement_label - 종합판정 라벨
* @param {Array} data.footer_judgement_options - 판정 옵션
* @param {Array} data.approval_lines - 결재라인 [{name, dept}]
* @param {Array} data.basic_fields - 기본필드 [{label, default_value}]
* @param {Array} data.sections - 검사 섹션 [{title, image_path, items}]
* @param {Array} data.columns - 테이블 컬럼
* @param {Function} [data.methodResolver] - 검사방법 코드→이름 변환 함수 (선택)
*/
/**
* 블록 기반(양식 디자이너) 미리보기 HTML 생성
*
* @param {Object} data - 템플릿 데이터 (builder_type === 'block')
* @param {Object} data.schema - 블록 스키마 { version, page, blocks[] }
*/
function buildBlockPreviewHtml(data) {
const schema = data.schema;
if (!schema || !schema.blocks || schema.blocks.length === 0) {
return '<div class="text-center text-gray-400 py-12">블록이 없습니다. 양식 디자이너에서 블록을 추가하세요.</div>';
}
const page = schema.page || {};
const orientation = page.orientation || 'portrait';
const blocks = schema.blocks;
function esc(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function nl2br(text) {
return (text || '').replace(/\n/g, '<br>');
}
function renderBlock(block) {
const type = block.type || '';
const props = block.props || {};
switch (type) {
case 'heading': {
const level = Math.min(Math.max(parseInt(props.level || 2), 1), 6);
const text = esc(props.text || '');
const align = props.align || 'left';
const sizes = {1:'1.5em',2:'1.25em',3:'1.1em',4:'1em',5:'0.9em',6:'0.85em'};
return `<h${level} style="text-align:${align};font-size:${sizes[level]};font-weight:bold;margin:0.5em 0;">${text}</h${level}>`;
}
case 'paragraph': {
const text = nl2br(esc(props.text || ''));
const align = props.align || 'left';
return `<p style="text-align:${align};margin:0.3em 0;font-size:0.9em;line-height:1.6;">${text}</p>`;
}
case 'table': {
const headers = props.headers || [];
const rows = props.rows || [];
const showHeader = props.showHeader !== false;
let html = '<table style="width:100%;border-collapse:collapse;border:1px solid #333;margin:0.5em 0;font-size:0.85em;">';
if (showHeader && headers.length > 0) {
html += '<thead><tr>';
headers.forEach(h => { html += `<th style="border:1px solid #333;padding:4px 8px;background:#f5f5f5;font-weight:bold;text-align:center;">${esc(h)}</th>`; });
html += '</tr></thead>';
}
html += '<tbody>';
if (rows.length > 0) {
rows.forEach(row => {
html += '<tr>';
(row || []).forEach(cell => { html += `<td style="border:1px solid #333;padding:4px 8px;">${esc(cell || '')}</td>`; });
html += '</tr>';
});
} else {
const colCount = headers.length || 3;
html += '<tr>';
for (let i = 0; i < colCount; i++) html += '<td style="border:1px solid #333;padding:4px 8px;color:#ccc;text-align:center;">(입력)</td>';
html += '</tr>';
}
html += '</tbody></table>';
return html;
}
case 'columns': {
const count = parseInt(props.count || 2);
const children = props.children || [];
let html = '<div style="display:flex;gap:10px;margin:0.5em 0;">';
for (let i = 0; i < count; i++) {
html += '<div style="flex:1;">';
const colChildren = children[i] || [];
colChildren.forEach(child => { html += renderBlock(child); });
html += '</div>';
}
html += '</div>';
return html;
}
case 'divider': {
const style = (props.style || 'solid') === 'dashed' ? 'dashed' : 'solid';
return `<hr style="border:none;border-top:1px ${style} #ccc;margin:0.8em 0;">`;
}
case 'spacer': {
const height = Math.max(parseInt(props.height || 20), 0);
return `<div style="height:${height}px;"></div>`;
}
case 'text_field': {
const label = esc(props.label || '');
const req = props.required ? ' <span style="color:red;">*</span>' : '';
return `<div style="margin:0.3em 0;"><label style="font-size:0.8em;font-weight:bold;display:block;margin-bottom:2px;">${label}${req}</label><div style="border:1px solid #ccc;padding:4px 8px;min-height:24px;font-size:0.9em;color:#ccc;">(입력)</div></div>`;
}
case 'number_field': {
const label = esc(props.label || '');
const unit = props.unit ? ` (${esc(props.unit)})` : '';
return `<div style="margin:0.3em 0;"><label style="font-size:0.8em;font-weight:bold;display:block;margin-bottom:2px;">${label}${unit}</label><div style="border:1px solid #ccc;padding:4px 8px;min-height:24px;font-size:0.9em;color:#ccc;">(숫자)</div></div>`;
}
case 'date_field': {
const label = esc(props.label || '');
return `<div style="margin:0.3em 0;"><label style="font-size:0.8em;font-weight:bold;display:block;margin-bottom:2px;">${label}</label><div style="border:1px solid #ccc;padding:4px 8px;min-height:24px;font-size:0.9em;color:#ccc;">(날짜)</div></div>`;
}
case 'select_field': {
const label = esc(props.label || '');
const options = (props.options || []).map(o => esc(o)).join(' / ');
return `<div style="margin:0.3em 0;"><label style="font-size:0.8em;font-weight:bold;display:block;margin-bottom:2px;">${label}</label><div style="border:1px solid #ccc;padding:4px 8px;min-height:24px;font-size:0.9em;">${options || '<span style="color:#ccc;">(선택)</span>'}</div></div>`;
}
case 'checkbox_field': {
const label = esc(props.label || '');
const options = props.options || [];
let html = `<div style="margin:0.3em 0;"><label style="font-size:0.8em;font-weight:bold;display:block;margin-bottom:2px;">${label}</label><div style="display:flex;gap:12px;flex-wrap:wrap;">`;
options.forEach(opt => { html += `<label style="font-size:0.85em;display:flex;align-items:center;gap:4px;"><input type="checkbox" disabled> ${esc(opt)}</label>`; });
html += '</div></div>';
return html;
}
case 'textarea_field': {
const label = esc(props.label || '');
const rows = Math.max(parseInt(props.rows || 3), 1);
const height = rows * 24;
return `<div style="margin:0.3em 0;"><label style="font-size:0.8em;font-weight:bold;display:block;margin-bottom:2px;">${label}</label><div style="border:1px solid #ccc;padding:4px 8px;min-height:${height}px;font-size:0.9em;color:#ccc;">(장문 입력)</div></div>`;
}
case 'signature_field': {
const label = esc(props.label || '서명');
return `<div style="margin:0.5em 0;"><label style="font-size:0.8em;font-weight:bold;display:block;margin-bottom:2px;">${label}</label><div style="border:2px dashed #ccc;height:60px;display:flex;align-items:center;justify-content:center;font-size:0.8em;color:#999;">서명 영역</div></div>`;
}
default:
return `<div style="color:#999;font-size:0.8em;margin:0.3em 0;">[ ${esc(type)} 블록 ]</div>`;
}
}
let html = `<div style="font-family:'Noto Sans KR',sans-serif;max-width:210mm;margin:0 auto;padding:20mm 15mm;background:white;">`;
blocks.forEach(block => { html += renderBlock(block); });
html += '</div>';
return `
<div class="bg-white p-4" style="max-width:900px;margin:0 auto;">
<div class="flex items-center gap-2 mb-3 pb-2 border-b">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-700">양식 디자이너</span>
<span class="text-sm font-medium text-gray-700">${esc(data.name || '')}</span>
<span class="text-xs text-gray-400 ml-auto">${blocks.length}개 블록</span>
</div>
<div class="border rounded-lg overflow-hidden">${html}</div>
</div>
`;
}
function buildDocumentPreviewHtml(data) {
const title = data.title || '문서 양식';
const companyName = data.company_name || '';
const remarkLabel = data.footer_remark_label || '비고';
const judgementLabel = data.footer_judgement_label || '종합판정';
const judgementOptions = (data.footer_judgement_options || []).filter(o => (o || '').trim());
const approvalLines = data.approval_lines || [];
const basicFields = data.basic_fields || [];
const sections = data.sections || [];
const columns = data.columns || [];
const methodResolver = data.methodResolver || function(code) { return code || '-'; };
// 결재란
const approvalHtml = approvalLines.length === 0
? '<span class="text-xs text-gray-400">결재라인 미설정</span>'
: `<table class="border-collapse text-xs">
<tr>${approvalLines.map(l => `<td class="border border-gray-400 px-3 py-1 bg-gray-100 text-center font-medium">${_previewEsc(l.name || '-')}</td>`).join('')}</tr>
<tr>${approvalLines.map(l => `<td class="border border-gray-400 px-3 py-1 text-center"><div class="text-gray-400 text-xs">${_previewEsc(l.dept || '')}</div><div class="h-6"></div></td>`).join('')}</tr>
</table>`;
// 기본필드 (15:35:15:35 비율)
let basicHtml = '';
if (basicFields.length > 0) {
const rows = [];
for (let i = 0; i < basicFields.length; i += 2) {
const f1 = basicFields[i], f2 = basicFields[i + 1];
rows.push(`<tr>
<td class="border border-gray-400 px-2 py-1.5 bg-gray-100 font-medium text-center" style="width:15%">${_previewEsc(f1.label || '-')}</td>
<td class="border border-gray-400 px-2 py-1.5 text-gray-400" style="width:35%">${_previewEsc(f1.default_value || '(입력)')}</td>
${f2 ? `<td class="border border-gray-400 px-2 py-1.5 bg-gray-100 font-medium text-center" style="width:15%">${_previewEsc(f2.label || '-')}</td>
<td class="border border-gray-400 px-2 py-1.5 text-gray-400" style="width:35%">${_previewEsc(f2.default_value || '(입력)')}</td>` : '<td class="border border-gray-400 px-2 py-1.5" colspan="2"></td>'}
</tr>`);
}
basicHtml = `<table class="w-full border-collapse text-xs mb-4" style="table-layout:fixed">${rows.join('')}</table>`;
}
// 컬럼의 실제 colspan 계산
function _colSpan(col) {
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 컬럼의 체크박스 라벨 추출
function _getCheckLabels(col) {
if (col.sub_labels && col.sub_labels.length >= 2) {
const [a, b] = col.sub_labels.map(s => (s || '').trim());
if (a && b && !/^n?\d+$/i.test(a)) return `☐${a} ☐${b}`;
}
return '☐OK ☐NG';
}
// 컬럼 헤더 (다단계 group_name "/" 구분자 지원)
const renderHeaders = () => {
if (columns.length === 0) return '';
// 1) 각 컬럼의 group_name을 "/" 로 split → levels 배열
const colGroups = columns.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));
// sub_labels 존재 여부
const hasSubLabels = columns.some(c =>
c.column_type === 'complex' && c.sub_labels && c.sub_labels.length > 0
);
// check 타입 + sub_labels도 sub_labels 행에 포함
const hasCheckSub = columns.some(c =>
c.column_type === 'check' && c.sub_labels && c.sub_labels.length >= 2
);
const needSubRow = hasSubLabels; // sub_labels 행 필요 여부
const totalHeaderRows = maxDepth + 1 + (needSubRow ? 1 : 0);
const thClass = 'border border-gray-400 px-2 py-1.5 bg-gray-100 text-center';
const thSubClass = 'border border-gray-400 px-1 py-1 bg-gray-100 text-center';
let html = '';
// 2) 그룹 행들 (depth 0 ~ maxDepth-1)
for (let depth = 0; depth < maxDepth; depth++) {
html += '<tr>';
let ci = 0;
while (ci < columns.length) {
const levels = colGroups[ci];
if (levels.length <= depth) {
// 이 컬럼은 이 depth에 그룹이 없음 → 이미 상위에서 rowspan 처리됨
ci++;
continue;
}
if (depth < levels.length - 1 || (depth === 0 && levels.length > 0)) {
// 실제로 이 depth에 그룹 라벨이 있는 경우
const groupLabel = levels[depth];
// 같은 prefix를 가진 연속 컬럼 찾기
const prefix = levels.slice(0, depth + 1).join('/');
let span = _colSpan(columns[ci]);
let cj = ci + 1;
while (cj < columns.length) {
const otherLevels = colGroups[cj];
const otherPrefix = otherLevels.slice(0, depth + 1).join('/');
if (otherPrefix === prefix) {
span += _colSpan(columns[cj]);
cj++;
} else {
break;
}
}
// 이 depth가 이 컬럼그룹의 마지막 depth인지 확인
// (하위 depth가 더 있으면 rowspan 불필요)
const allSameMaxDepth = (() => {
for (let k = ci; k < cj; k++) {
if (colGroups[k].length > depth + 1) return false;
}
return true;
})();
if (depth === levels.length - 1 && allSameMaxDepth && maxDepth > depth + 1) {
// 하위 그룹이 없는데 다른 컬럼에 하위 그룹 있음 → rowspan으로 확장
const rs = maxDepth - depth;
html += `<th class="${thClass}" colspan="${span}" rowspan="${rs}">${_previewEsc(groupLabel)}</th>`;
} else {
html += `<th class="${thClass}" colspan="${span}">${_previewEsc(groupLabel)}</th>`;
}
ci = cj;
} else {
ci++;
}
}
html += '</tr>';
}
// 그룹이 없는 컬럼(group_name = null)은 첫 번째 그룹 행에서 전체 rowspan
// → 위 로직에서 skip 되었으므로, 첫 그룹 행에 삽입해야 함
// 재구성: 완전한 행 빌드 방식으로 변경
if (maxDepth > 0) {
// 첫 번째 행을 재빌드하여 group_name=null 컬럼의 rowspan 포함
html = ''; // 리셋
for (let depth = 0; depth < maxDepth; depth++) {
html += '<tr>';
let ci = 0;
while (ci < columns.length) {
const levels = colGroups[ci];
// 그룹 없는 컬럼 또는 단일 레벨 그룹: depth=0에서만 rowspan으로 렌더
if (levels.length <= 1) {
if (depth === 0) {
const cs = _colSpan(columns[ci]);
const rs = totalHeaderRows;
html += `<th class="${thClass}" ${cs > 1 ? `colspan="${cs}"` : ''} rowspan="${rs}" style="width:${columns[ci].width || '80px'}">${_previewEsc(columns[ci].label)}</th>`;
}
ci++;
continue;
}
// 이 컬럼의 그룹 depth가 현재 depth보다 작음 → 이미 상위에서 처리됨
if (levels.length <= depth) {
ci++;
continue;
}
// 같은 prefix 연속 컬럼 모으기
const prefix = levels.slice(0, depth + 1).join('/');
let span = _colSpan(columns[ci]);
let cj = ci + 1;
while (cj < columns.length) {
const otherLevels = colGroups[cj];
if (otherLevels.length > depth) {
const otherPrefix = otherLevels.slice(0, depth + 1).join('/');
if (otherPrefix === prefix) {
span += _colSpan(columns[cj]);
cj++;
continue;
}
}
break;
}
// 이 depth에서 이미 렌더된 prefix인지 확인 (중복 방지)
const firstInPrefix = (() => {
for (let k = 0; k < ci; k++) {
const kLevels = colGroups[k];
if (kLevels.length > depth && kLevels.slice(0, depth + 1).join('/') === prefix) return false;
}
return true;
})();
if (!firstInPrefix) {
ci++;
continue;
}
const groupLabel = levels[depth];
// 하위 그룹이 더 있는지 확인
const hasDeeper = (() => {
for (let k = ci; k < cj; k++) {
if (colGroups[k].length > depth + 1) return true;
}
return false;
})();
if (!hasDeeper && depth < maxDepth - 1) {
// 이 그룹은 여기서 끝나지만 다른 그룹은 더 깊음 → rowspan
const rs = maxDepth - depth;
html += `<th class="${thClass}" colspan="${span}" rowspan="${rs}">${_previewEsc(groupLabel)}</th>`;
} else {
html += `<th class="${thClass}" colspan="${span}">${_previewEsc(groupLabel)}</th>`;
}
ci = cj;
}
html += '</tr>';
}
}
// 3) 컬럼 라벨 행
html += '<tr>';
columns.forEach(col => {
const levels = colGroups[columns.indexOf(col)];
// 그룹 없는 컬럼 또는 단일 레벨 그룹은 이미 rowspan으로 처리됨 → skip
if (maxDepth > 0 && levels.length <= 1) return;
const cs = _colSpan(col);
if (needSubRow && (col.column_type === 'complex' && col.sub_labels && col.sub_labels.length > 0)) {
// complex: colspan만, rowspan 없음 (다음 행에 sub_labels)
html += `<th class="${thClass}" colspan="${cs}">${_previewEsc(col.label)}</th>`;
} else if (needSubRow) {
// sub_labels 행이 있지만 이 컬럼은 sub_labels 없음 → rowspan=2
html += `<th class="${thClass}" ${cs > 1 ? `colspan="${cs}"` : ''} rowspan="2" style="width:${col.width || '80px'}">${_previewEsc(col.label)}</th>`;
} else {
html += `<th class="${thClass}" ${cs > 1 ? `colspan="${cs}"` : ''} style="width:${col.width || '80px'}">${_previewEsc(col.label)}</th>`;
}
});
html += '</tr>';
// 4) sub_labels 행 (필요시)
if (needSubRow) {
html += '<tr>';
columns.forEach(col => {
if (col.column_type === 'complex' && col.sub_labels && col.sub_labels.length > 0) {
col.sub_labels.forEach(sl => {
const slName = (sl || '').trim();
const isBasis = slName.includes('도면') || slName.includes('기준');
const bgClass = isBasis ? ' background:#f3f4f6;' : '';
html += `<th class="${thSubClass}" style="width:40px;font-size:10px;${bgClass}">${_previewEsc(slName)}</th>`;
});
}
// check 타입이나 다른 컬럼은 sub_labels 행에서 skip (rowspan으로 처리됨)
});
html += '</tr>';
}
return html;
};
// 측정치 셀 (sub_label 이름 기반 의미 구분)
const mCell = (col, mType, frequencyN) => {
const total = _colSpan(col);
if (mType === 'single_value') return `<td class="border border-gray-400 px-1 py-1.5 text-center" colspan="${total}"><span style="color:#ccc;font-size:10px">( 입력 )</span></td>`;
if (mType === 'substitute') return `<td class="border border-gray-400 px-1 py-1.5 text-center text-gray-500" colspan="${total}" style="font-size:10px">성적서로 대체</td>`;
if (col.column_type === 'check') {
const checkLabel = _getCheckLabels(col);
return `<td class="border border-gray-400 px-1 py-1.5 text-center"><span style="font-size:11px">${checkLabel}</span></td>`;
}
if (col.sub_labels && col.sub_labels.length > 0) {
if (mType === 'checkbox') {
// complex 컬럼은 기본 OK/NG, check 컬럼만 커스텀 라벨
const checkText = col.column_type === 'check' ? _getCheckLabels(col) : '☐OK ☐NG';
return col.sub_labels.map(() => `<td class="border border-gray-400 px-1 py-1.5 text-center"><span style="font-size:11px">${checkText}</span></td>`).join('');
}
return col.sub_labels.map(sl => {
const slName = (sl || '').trim().toLowerCase();
if (slName.includes('도면') || slName.includes('기준')) {
return '<td class="border border-gray-400 px-1 py-1.5 text-center" style="background:#f0f4f8"><span style="color:#6b7280;font-size:10px">(기준값)</span></td>';
}
if (slName.includes('point') || slName.includes('포인트')) {
return '<td class="border border-gray-400 px-1 py-1.5 text-center"><span style="color:#9ca3af;font-size:10px;font-style:italic">(n)</span></td>';
}
// 측정값 등 입력 셀
return '<td class="border border-gray-400 px-1 py-1.5 text-center"><span style="color:#9ca3af;font-size:10px">[입력]</span></td>';
}).join('');
}
return Array(total).fill('').map(() => '<td class="border border-gray-400 px-1 py-1.5 text-center text-gray-300">-</td>').join('');
};
// 검사항목 행
const renderRows = () => {
const allItems = [];
sections.forEach(s => (s.items || []).forEach(item => allItems.push(item)));
if (allItems.length === 0) {
const cs = columns.reduce((sum, c) => sum + _colSpan(c), 0) || 1;
return `<tr><td colspan="${cs}" class="text-center py-4 text-gray-400 border border-gray-400">검사항목이 없습니다.</td></tr>`;
}
// 카테고리별 그룹핑
const groups = [];
let i = 0;
while (i < allItems.length) {
const item = allItems[i], cat = (item.category || '').trim();
if (cat) {
const g = [item];
while (i + 1 < allItems.length && (allItems[i + 1].category || '').trim() === cat) { i++; g.push(allItems[i]); }
groups.push({ type: 'group', category: cat, items: g });
} else {
groups.push({ type: 'single', item });
}
i++;
}
let rowNum = 0;
return groups.map(row => {
rowNum++;
if (row.type === 'single') {
const item = row.item;
return '<tr>' + columns.map(col => {
if ((col.column_type === 'complex' || col.column_type === 'check') && col.sub_labels && col.sub_labels.length > 0) return mCell(col, item.measurement_type || col.column_type, item.frequency_n);
const label = (col.label || '').trim();
if (label === 'NO' || label === 'no') return `<td class="border border-gray-400 px-2 py-1.5 text-center">${rowNum}</td>`;
if (label.includes('검사항목') || label.includes('항목')) return `<td class="border border-gray-400 px-2 py-1.5 text-center" colspan="2">${_previewEsc(item.item || '-')}</td>`;
if (label.includes('검사기준') || label.includes('기준')) return `<td class="border border-gray-400 px-2 py-1.5 text-center" colspan="2">${_previewFormatStandard(item)}</td>`;
if (label.includes('검사방')) return `<td class="border border-gray-400 px-2 py-1.5 text-center">${_previewEsc(methodResolver(item.method))}</td>`;
if (label.includes('주기')) return `<td class="border border-gray-400 px-2 py-1.5 text-center">${_previewFormatFrequency(item)}</td>`;
if (label.includes('판정')) return '<td class="border border-gray-400 px-2 py-1.5 text-center">☐</td>';
if (col.column_type === 'check') return `<td class="border border-gray-400 px-1 py-1.5 text-center"><span style="font-size:11px">${_getCheckLabels(col)}</span></td>`;
return '<td class="border border-gray-400 px-2 py-1.5 text-center">-</td>';
}).join('') + '</tr>';
}
// 그룹 항목
const n = row.items.length;
return row.items.map((item, idx) => {
return '<tr>' + columns.map(col => {
if ((col.column_type === 'complex' || col.column_type === 'check') && col.sub_labels && col.sub_labels.length > 0) return mCell(col, item.measurement_type || col.column_type, item.frequency_n);
const label = (col.label || '').trim();
const rs = idx === 0 ? ` rowspan="${n}"` : '';
if (label === 'NO' || label === 'no') return idx === 0 ? `<td class="border border-gray-400 px-2 py-1.5 text-center"${rs}>${rowNum}</td>` : '';
if (label.includes('검사항목') || label.includes('항목')) {
let catCell = idx === 0 ? `<td class="border border-gray-400 px-2 py-1.5 text-center font-medium"${rs}>${_previewEsc(row.category)}</td>` : '';
return catCell + `<td class="border border-gray-400 px-2 py-1.5 text-center">${_previewEsc(item.item || '-')}</td>`;
}
if (label.includes('검사기준') || label.includes('기준')) return `<td class="border border-gray-400 px-2 py-1.5 text-center">${_previewFormatStandard(item)}</td><td class="border border-gray-400 px-2 py-1.5 text-center">${_previewEsc(_previewFormatTolerance(item.tolerance))}</td>`;
if (label.includes('검사방')) return `<td class="border border-gray-400 px-2 py-1.5 text-center">${_previewEsc(methodResolver(item.method))}</td>`;
if (label.includes('주기')) return `<td class="border border-gray-400 px-2 py-1.5 text-center">${_previewFormatFrequency(item)}</td>`;
if (label.includes('판정')) return '<td class="border border-gray-400 px-2 py-1.5 text-center">☐</td>';
if (col.column_type === 'check') return `<td class="border border-gray-400 px-1 py-1.5 text-center"><span style="font-size:11px">${_getCheckLabels(col)}</span></td>`;
return '<td class="border border-gray-400 px-2 py-1.5 text-center">-</td>';
}).join('') + '</tr>';
}).join('');
}).join('');
};
// 섹션을 이미지 섹션(items 없음)과 데이터 섹션(items 있음)으로 분리
const imageSections = sections.filter(s => (s.items || []).length === 0);
const dataSections = sections.filter(s => (s.items || []).length > 0);
// 이미지 섹션: ■ 타이틀 + 이미지 또는 플레이스홀더
const imageSectionsHtml = imageSections.map(s => `
<div class="mb-3">
<p class="text-sm font-bold mb-1">■ ${_previewEsc(s.title)}</p>
${s.image_path
? `<img src="${_previewImageUrl(s.image_path)}" alt="${_previewEsc(s.title)}" class="max-h-48 mx-auto border rounded">`
: `<div class="border border-dashed border-gray-300 rounded p-6 text-center text-gray-400 text-xs">이미지 미등록 (편집에서 업로드)</div>`
}
</div>
`).join('');
// 데이터 섹션 타이틀
const dataSectionTitle = dataSections.length > 0
? `<p class="text-sm font-bold mb-1 mt-3">■ ${_previewEsc(dataSections[0].title)}</p>`
: '';
return `
<div class="bg-white p-6" style="font-family: 'Malgun Gothic', sans-serif; font-size: 12px; max-width: 900px; margin: 0 auto;">
<div class="flex justify-between items-start mb-4">
<div class="text-center" style="width: 80px;">
<div class="text-2xl font-bold">KD</div>
<div class="text-xs">${_previewEsc(companyName)}</div>
</div>
<div class="flex-1 text-center">
<h1 class="text-xl font-bold tracking-widest">${_previewEsc(title)}</h1>
</div>
<div>${approvalHtml}</div>
</div>
${basicHtml}
${imageSectionsHtml}
${columns.length > 0 ? `
${dataSectionTitle}
<table class="w-full border-collapse text-xs">
<thead>${renderHeaders()}</thead>
<tbody>${renderRows()}</tbody>
</table>
` : '<p class="text-xs text-gray-400 text-center py-4">테이블 컬럼이 설정되지 않았습니다.</p>'}
<div class="mt-4 flex gap-4">
<div class="flex-1">
<table class="w-full border-collapse text-xs">
<tr>
<td class="border border-gray-400 px-3 py-2 bg-gray-100 font-medium" style="width:100px">${_previewEsc(remarkLabel)}</td>
<td class="border border-gray-400 px-3 py-2 text-gray-400">(내용 입력)</td>
</tr>
</table>
</div>
<div>
<table class="border-collapse text-xs">
<tr><td class="border border-gray-400 px-4 py-2 bg-gray-100 font-medium">${_previewEsc(judgementLabel)}</td></tr>
<tr><td class="border border-gray-400 px-4 py-3 text-center">
${judgementOptions.length > 0 ? judgementOptions.map(o => `<span class="inline-block mx-1 px-2 py-0.5 border border-gray-300 rounded text-gray-500">${_previewEsc(o)}</span>`).join('') : '<span class="text-gray-400">옵션 미설정</span>'}
</td></tr>
</table>
</div>
</div>
</div>
`;
}
</script>
@endpush