diff --git a/resources/views/document-templates/partials/preview-modal.blade.php b/resources/views/document-templates/partials/preview-modal.blade.php index 4db6c837..2c4ccb8f 100644 --- a/resources/views/document-templates/partials/preview-modal.blade.php +++ b/resources/views/document-templates/partials/preview-modal.blade.php @@ -158,45 +158,258 @@ function buildDocumentPreviewHtml(data) { basicHtml = `${rows.join('')}
`; } - // 전체 항목에서 최대 frequency_n - let maxN = 0; - sections.forEach(s => (s.items || []).forEach(item => { - if (item.frequency_n && item.frequency_n > maxN) maxN = item.frequency_n; - })); + // 컬럼의 실제 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 ''; - const hasComplex = columns.some(c => c.column_type === 'complex' && c.sub_labels && c.sub_labels.length > 0); - let h1 = '', h2 = ''; - columns.forEach(col => { - if (col.column_type === 'complex' && col.sub_labels && col.sub_labels.length > 0) { - const cs = Math.max(col.sub_labels.length, maxN); - h1 += `${_previewEsc(col.label)}`; - for (let ni = 1; ni <= cs; ni++) h2 += `n${ni}`; - } else { - const label = (col.label || '').trim(); - const isItem = label.includes('검사항목') || label.includes('항목'); - const isStd = label.includes('검사기준') || label.includes('기준'); - if (isItem || isStd) { - h1 += `${_previewEsc(col.label)}`; + + // 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 += ''; + 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 += `${_previewEsc(groupLabel)}`; + } else { + html += `${_previewEsc(groupLabel)}`; + } + ci = cj; } else { - h1 += `${_previewEsc(col.label)}`; + ci++; } } + html += ''; + } + + // 그룹이 없는 컬럼(group_name = null)은 첫 번째 그룹 행에서 전체 rowspan + // → 위 로직에서 skip 되었으므로, 첫 그룹 행에 삽입해야 함 + // 재구성: 완전한 행 빌드 방식으로 변경 + if (maxDepth > 0) { + // 첫 번째 행을 재빌드하여 group_name=null 컬럼의 rowspan 포함 + html = ''; // 리셋 + for (let depth = 0; depth < maxDepth; depth++) { + html += ''; + 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 += ` 1 ? `colspan="${cs}"` : ''} rowspan="${rs}" style="width:${columns[ci].width || '80px'}">${_previewEsc(columns[ci].label)}`; + } + 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 += `${_previewEsc(groupLabel)}`; + } else { + html += `${_previewEsc(groupLabel)}`; + } + ci = cj; + } + html += ''; + } + } + + // 3) 컬럼 라벨 행 + html += ''; + 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 += `${_previewEsc(col.label)}`; + } else if (needSubRow) { + // sub_labels 행이 있지만 이 컬럼은 sub_labels 없음 → rowspan=2 + html += ` 1 ? `colspan="${cs}"` : ''} rowspan="2" style="width:${col.width || '80px'}">${_previewEsc(col.label)}`; + } else { + html += ` 1 ? `colspan="${cs}"` : ''} style="width:${col.width || '80px'}">${_previewEsc(col.label)}`; + } }); - return hasComplex ? `${h1}${h2}` : `${h1}`; + html += ''; + + // 4) sub_labels 행 (필요시) + if (needSubRow) { + html += ''; + 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 += `${_previewEsc(slName)}`; + }); + } + // check 타입이나 다른 컬럼은 sub_labels 행에서 skip (rowspan으로 처리됨) + }); + html += ''; + } + + return html; }; - // 측정치 셀 + // 측정치 셀 (sub_label 이름 기반 의미 구분) const mCell = (col, mType, frequencyN) => { - const total = Math.max(col.sub_labels.length, maxN); - const cnt = frequencyN ? frequencyN : total; - const rem = total - cnt; - if (mType === 'checkbox') return Array(cnt).fill('').map(() => '☐OK ☐NG').join('') + (rem > 0 ? `` : ''); - if (mType === 'numeric') return Array(cnt).fill('').map(() => '___').join('') + (rem > 0 ? `` : ''); + const total = _colSpan(col); if (mType === 'single_value') return `( 입력 )`; if (mType === 'substitute') return `성적서로 대체`; + + if (col.column_type === 'check') { + const checkLabel = _getCheckLabels(col); + return `${checkLabel}`; + } + + 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(() => `${checkText}`).join(''); + } + return col.sub_labels.map(sl => { + const slName = (sl || '').trim().toLowerCase(); + if (slName.includes('도면') || slName.includes('기준')) { + return '(기준값)'; + } + if (slName.includes('point') || slName.includes('포인트')) { + return '(n)'; + } + // 측정값 등 입력 셀 + return '[입력]'; + }).join(''); + } + return Array(total).fill('').map(() => '-').join(''); }; @@ -205,7 +418,7 @@ function buildDocumentPreviewHtml(data) { const allItems = []; sections.forEach(s => (s.items || []).forEach(item => allItems.push(item))); if (allItems.length === 0) { - const cs = columns.reduce((sum, c) => sum + (c.column_type === 'complex' && c.sub_labels ? Math.max(c.sub_labels.length, maxN) : 1), 0) || 1; + const cs = columns.reduce((sum, c) => sum + _colSpan(c), 0) || 1; return `검사항목이 없습니다.`; } @@ -230,7 +443,7 @@ function buildDocumentPreviewHtml(data) { if (row.type === 'single') { const item = row.item; return '' + columns.map(col => { - if (col.column_type === 'complex' && col.sub_labels && col.sub_labels.length > 0) return mCell(col, item.measurement_type || '', item.frequency_n); + 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 `${rowNum}`; if (label.includes('검사항목') || label.includes('항목')) return `${_previewEsc(item.item || '-')}`; @@ -238,6 +451,7 @@ function buildDocumentPreviewHtml(data) { if (label.includes('검사방')) return `${_previewEsc(methodResolver(item.method))}`; if (label.includes('주기')) return `${_previewFormatFrequency(item)}`; if (label.includes('판정')) return '☐'; + if (col.column_type === 'check') return `${_getCheckLabels(col)}`; return '-'; }).join('') + ''; } @@ -246,7 +460,7 @@ function buildDocumentPreviewHtml(data) { const n = row.items.length; return row.items.map((item, idx) => { return '' + columns.map(col => { - if (col.column_type === 'complex' && col.sub_labels && col.sub_labels.length > 0) return mCell(col, item.measurement_type || '', item.frequency_n); + 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 ? `${rowNum}` : ''; @@ -258,6 +472,7 @@ function buildDocumentPreviewHtml(data) { if (label.includes('검사방')) return `${_previewEsc(methodResolver(item.method))}`; if (label.includes('주기')) return `${_previewFormatFrequency(item)}`; if (label.includes('판정')) return '☐'; + if (col.column_type === 'check') return `${_getCheckLabels(col)}`; return '-'; }).join('') + ''; }).join('');