- basic_fields에 수주 LOT NO 필드 추가 - 중간검사 기준서 이미지 섹션 추가 (4종 공통) - 데이터 섹션 타이틀 "중간검사 DATA"로 통일 - 절곡품 4개 검사 섹션을 1개로 병합 - 미리보기에 ■ 섹션 타이틀 렌더링 (이미지/데이터 분리) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
318 lines
19 KiB
PHP
318 lines
19 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();
|
|
});
|
|
|
|
// 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] - 검사방법 코드→이름 변환 함수 (선택)
|
|
*/
|
|
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>`;
|
|
}
|
|
|
|
// 전체 항목에서 최대 frequency_n
|
|
let maxN = 0;
|
|
sections.forEach(s => (s.items || []).forEach(item => {
|
|
if (item.frequency_n && item.frequency_n > maxN) maxN = item.frequency_n;
|
|
}));
|
|
|
|
// 컬럼 헤더
|
|
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>`;
|
|
} 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>`;
|
|
}
|
|
}
|
|
});
|
|
return hasComplex ? `<tr>${h1}</tr><tr>${h2}</tr>` : `<tr>${h1}</tr>`;
|
|
};
|
|
|
|
// 측정치 셀
|
|
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>` : '');
|
|
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>`;
|
|
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 + (c.column_type === 'complex' && c.sub_labels ? Math.max(c.sub_labels.length, maxN) : 1), 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.sub_labels && col.sub_labels.length > 0) return mCell(col, item.measurement_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>';
|
|
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.sub_labels && col.sub_labels.length > 0) return mCell(col, item.measurement_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>';
|
|
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="/storage/${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 |