Files
sam-manage/resources/views/document-templates/partials/preview-modal.blade.php
권혁성 d910643558 feat:문서양식 목록에 연결 품목 컬럼 추가
- DocumentTemplateApiController: 연결 품목 ID로 품목명 조회
- table.blade.php: 연결 품목 컬럼 추가 (최대 3개 표시 + 더보기)
- index.blade.php: 카테고리 필터 code/name 구조 적용
- preview-modal.blade.php: 기본필드 테이블 비율 조정 (15:35:15:35)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 21:58:46 +09:00

305 lines
18 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('');
};
// 섹션 이미지
const imagesHtml = sections.filter(s => s.image_path).map(s => `
<div class="mb-4 text-center">
<p class="text-xs font-medium text-gray-600 mb-1">${_previewEsc(s.title || '검사 기준')}</p>
<img src="/storage/${s.image_path}" alt="${_previewEsc(s.title)}" class="max-h-40 mx-auto border rounded">
</div>
`).join('');
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}
${imagesHtml}
${columns.length > 0 ? `
<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