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 = `
`;
}
- // 전체 항목에서 최대 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('');