feat:문서 미리보기 다단계 헤더 및 플레이스홀더 개선
- group_name "/" 구분자로 다단계(4단) 헤더 지원 - sub_label 이름 기반 셀 의미 구분 (기준값/입력/POINT) - check 컬럼 커스텀 체크박스 라벨 (양호/불량 등) 지원 - maxN 제거, 각 컬럼 자체 sub_labels.length 기반 colspan - 기존 템플릿 하위 호환성 유지
This commit is contained in:
@@ -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('');
|
||||
|
||||
Reference in New Issue
Block a user