- builder_type=block 템플릿은 buildBlockPreviewHtml() 사용 - 레거시 템플릿은 기존 buildDocumentPreviewHtml() 유지
694 lines
39 KiB
PHP
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 |