feat:문서 미리보기 다단계 헤더 및 플레이스홀더 개선

- group_name "/" 구분자로 다단계(4단) 헤더 지원
- sub_label 이름 기반 셀 의미 구분 (기준값/입력/POINT)
- check 컬럼 커스텀 체크박스 라벨 (양호/불량 등) 지원
- maxN 제거, 각 컬럼 자체 sub_labels.length 기반 colspan
- 기존 템플릿 하위 호환성 유지
This commit is contained in:
2026-02-21 15:09:46 +09:00
parent f4879de9ba
commit c6ff4d639d

View File

@@ -158,45 +158,258 @@ function buildDocumentPreviewHtml(data) {
basicHtml = `<table class="w-full border-collapse text-xs mb-4" style="table-layout:fixed">${rows.join('')}</table>`;
}
// 전체 항목에서 최대 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 += `<th class="border border-gray-400 px-2 py-1.5 bg-gray-100" colspan="${cs}">${_previewEsc(col.label)}</th>`;
for (let ni = 1; ni <= cs; ni++) h2 += `<th class="border border-gray-400 px-1 py-1 bg-gray-100" style="width:40px">n${ni}</th>`;
} else {
const label = (col.label || '').trim();
const isItem = label.includes('검사항목') || label.includes('항목');
const isStd = label.includes('검사기준') || label.includes('기준');
if (isItem || isStd) {
h1 += `<th class="border border-gray-400 px-2 py-1.5 bg-gray-100" colspan="2" ${hasComplex ? 'rowspan="2"' : ''}>${_previewEsc(col.label)}</th>`;
// 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 {
h1 += `<th class="border border-gray-400 px-2 py-1.5 bg-gray-100" ${hasComplex ? 'rowspan="2"' : ''} style="width:${col.width || '80px'}">${_previewEsc(col.label)}</th>`;
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>`;
}
});
return hasComplex ? `<tr>${h1}</tr><tr>${h2}</tr>` : `<tr>${h1}</tr>`;
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 = Math.max(col.sub_labels.length, maxN);
const cnt = frequencyN ? frequencyN : total;
const rem = total - cnt;
if (mType === 'checkbox') return Array(cnt).fill('').map(() => '<td class="border border-gray-400 px-1 py-1.5 text-center"><span style="font-size:11px">☐OK ☐NG</span></td>').join('') + (rem > 0 ? `<td class="border border-gray-400 px-1 py-1.5" colspan="${rem}"></td>` : '');
if (mType === 'numeric') return Array(cnt).fill('').map(() => '<td class="border border-gray-400 px-1 py-1.5 text-center"><span style="color:#ccc;font-size:10px">___</span></td>').join('') + (rem > 0 ? `<td class="border border-gray-400 px-1 py-1.5" colspan="${rem}"></td>` : '');
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('');
};
@@ -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 `<tr><td colspan="${cs}" class="text-center py-4 text-gray-400 border border-gray-400">검사항목이 없습니다.</td></tr>`;
}
@@ -230,7 +443,7 @@ function buildDocumentPreviewHtml(data) {
if (row.type === 'single') {
const item = row.item;
return '<tr>' + 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 `<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>`;
@@ -238,6 +451,7 @@ function buildDocumentPreviewHtml(data) {
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>';
}
@@ -246,7 +460,7 @@ function buildDocumentPreviewHtml(data) {
const n = row.items.length;
return row.items.map((item, idx) => {
return '<tr>' + 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 ? `<td class="border border-gray-400 px-2 py-1.5 text-center"${rs}>${rowNum}</td>` : '';
@@ -258,6 +472,7 @@ function buildDocumentPreviewHtml(data) {
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('');