feat: [approval] 견적서 양식 추가

- 견적서 전용 폼/조회 파셜 추가
- create/edit/show 페이지에 견적서 통합
- Alpine.js 동적 품목 테이블 (자동 세액 계산)
- 공급자 정보 테넌트에서 자동 로드
- 미리보기/인쇄 기능
This commit is contained in:
김보곤
2026-03-06 23:21:49 +09:00
parent 05321c8839
commit 5f1a211722
5 changed files with 891 additions and 5 deletions

View File

@@ -145,6 +145,11 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-
'tenantInfo' => $tenantInfo ?? [],
])
{{-- 견적서 전용 --}}
@include('approvals.partials._quotation-form', [
'tenantInfo' => $tenantInfo ?? [],
])
{{-- 지출결의서 전용 --}}
@include('approvals.partials._expense-form', [
'initialData' => [],
@@ -334,6 +339,11 @@ class="p-1 text-gray-400 hover:text-gray-600 transition">
color: 'border-slate-300 bg-slate-50', titleColor: 'text-slate-800', textColor: 'text-slate-600',
text: '이사회 개최 내용을 기록하는 공식 문서입니다. 일시, 장소, 출석현황, 의안, 의사경과, 기명날인 등을 기재하며, 법적 효력을 갖는 회의록입니다.',
},
quotation: {
title: '견적서', icon: '💵',
color: 'border-green-200 bg-green-50', titleColor: 'text-green-800', textColor: 'text-green-600',
text: '고객에게 제공할 물품/서비스의 가격을 견적하는 문서입니다. 품목, 수량, 단가, 공급가액, 세액을 기재하며, 승인 후 견적서로 사용할 수 있습니다.',
},
pr_expense: {
title: '지출품의서', icon: '📋',
color: 'border-orange-200 bg-orange-50', titleColor: 'text-orange-800', textColor: 'text-orange-700',
@@ -367,7 +377,7 @@ class="p-1 text-gray-400 hover:text-gray-600 transition">
leave: '인사/근태', attendance_request: '인사/근태', resignation: '인사/근태', reason_report: '인사/근태', delegation: '인사/근태', board_minutes: '인사/근태',
employment_cert: '증명서', career_cert: '증명서', appointment_cert: '증명서', seal_usage: '증명서',
pr_expense: '품의', pr_contract: '품의', pr_purchase: '품의', pr_trip: '품의', pr_settlement: '품의',
expense: '재무',
quotation: '재무', expense: '재무',
};
const categoryIcons = {
'일반': '📄', '인사/근태': '👤', '증명서': '📜', '품의': '📋', '재무': '💰',
@@ -454,6 +464,7 @@ function updateFormDescription(formId) {
let isSealUsageForm = false;
let isDelegationForm = false;
let isBoardMinutesForm = false;
let isQuotationForm = false;
// 양식코드별 표시할 유형 목록
const leaveTypesByFormCode = {
@@ -705,6 +716,7 @@ function switchFormMode(formId) {
const sealUsageContainer = document.getElementById('seal-usage-form-container');
const delegationContainer = document.getElementById('delegation-form-container');
const boardMinutesContainer = document.getElementById('board-minutes-form-container');
const quotationContainer = document.getElementById('quotation-form-container');
const bodyArea = document.getElementById('body-area');
const expenseLoadBtn = document.getElementById('expense-load-btn');
@@ -721,6 +733,7 @@ function switchFormMode(formId) {
sealUsageContainer.style.display = 'none';
delegationContainer.style.display = 'none';
boardMinutesContainer.style.display = 'none';
quotationContainer.style.display = 'none';
expenseLoadBtn.style.display = 'none';
bodyArea.style.display = 'none';
isExpenseForm = false;
@@ -733,6 +746,7 @@ function switchFormMode(formId) {
isSealUsageForm = false;
isDelegationForm = false;
isBoardMinutesForm = false;
isQuotationForm = false;
if (code === 'expense') {
isExpenseForm = true;
@@ -801,6 +815,9 @@ function switchFormMode(formId) {
} else if (code === 'board_minutes') {
isBoardMinutesForm = true;
boardMinutesContainer.style.display = '';
} else if (code === 'quotation') {
isQuotationForm = true;
quotationContainer.style.display = '';
} else {
bodyArea.style.display = '';
}
@@ -811,7 +828,7 @@ function applyBodyTemplate(formId) {
switchFormMode(formId);
// 전용 폼이면 제목을 양식명으로 설정하고 body template 적용 건너뜀
if (isExpenseForm || isPurchaseRequestForm || isLeaveForm || isCertForm || isCareerCertForm || isAppointmentCertForm || isResignationForm || isSealUsageForm || isDelegationForm || isBoardMinutesForm) {
if (isExpenseForm || isPurchaseRequestForm || isLeaveForm || isCertForm || isCareerCertForm || isAppointmentCertForm || isResignationForm || isSealUsageForm || isDelegationForm || isBoardMinutesForm || isQuotationForm) {
const titleEl = document.getElementById('title');
const formSelect = document.getElementById('form_id');
titleEl.value = formSelect.options[formSelect.selectedIndex].text;
@@ -1074,6 +1091,55 @@ function applyBodyTemplate(formId) {
meeting_date: bmDatetime.split('T')[0],
};
formBody = null;
} else if (isQuotationForm) {
const qtClientName = document.getElementById('qt-client-name').value.trim();
if (!qtClientName) {
showToast('수신(고객명)을 입력해주세요.', 'warning');
return;
}
const qtQuoteDate = document.getElementById('qt-quote-date').value;
if (!qtQuoteDate) {
showToast('견적일자를 입력해주세요.', 'warning');
return;
}
const qtContainer = document.getElementById('quotation-form-container');
const qtAlpine = qtContainer._x_dataStack?.[0];
const qtItems = qtAlpine ? qtAlpine.items : [];
if (qtItems.length === 0 || !qtItems[0].name.trim()) {
showToast('품목을 1건 이상 입력해주세요.', 'warning');
return;
}
const mappedItems = qtItems.filter(i => i.name.trim()).map(i => ({
name: i.name.trim(),
spec: (i.spec || '').trim(),
qty: parseInt(i.qty) || 0,
unit_price: parseInt(i.unit_price) || 0,
supply_amount: (parseInt(i.qty) || 0) * (parseInt(i.unit_price) || 0),
tax: qtAlpine.itemTax(i),
note: (i.note || '').trim(),
}));
formContent = {
client_name: qtClientName,
quote_date: qtQuoteDate,
company_name: document.getElementById('qt-company-name').value,
business_num: document.getElementById('qt-business-num').value,
ceo_name: document.getElementById('qt-ceo-name').value,
company_address: document.getElementById('qt-company-address').value,
business_type: document.getElementById('qt-business-type-input')?.value || document.getElementById('qt-business-type').value,
business_item: document.getElementById('qt-business-item-input')?.value || document.getElementById('qt-business-item').value,
phone: document.getElementById('qt-phone-input')?.value || document.getElementById('qt-phone').value,
bank_account: document.getElementById('qt-bank-input')?.value || document.getElementById('qt-bank-account').value,
items: mappedItems,
total_supply: qtAlpine ? qtAlpine.totalSupply() : 0,
total_tax: qtAlpine ? qtAlpine.totalTax() : 0,
total_amount: qtAlpine ? qtAlpine.totalAmount() : 0,
remarks: document.getElementById('qt-remarks').value.trim(),
};
formBody = null;
} else if (isDelegationForm) {
const dlAgentName = document.getElementById('dl-agent-name').value.trim();
if (!dlAgentName) {
@@ -2228,6 +2294,143 @@ function printBoardMinutesPreview() {
win.print();
}
// ─── 견적서 미리보기 ───
function buildQuotationPreviewHtml(data) {
const fmt = n => (n || 0).toLocaleString('ko-KR');
let itemsHtml = '';
(data.items || []).forEach((item, idx) => {
itemsHtml += `<tr style="border-bottom:1px solid #ddd;">
<td style="padding:6px 8px;text-align:center;font-size:12px;">${idx + 1}</td>
<td style="padding:6px 8px;font-size:12px;">${item.name || ''}</td>
<td style="padding:6px 8px;font-size:12px;color:#555;">${item.spec || ''}</td>
<td style="padding:6px 8px;text-align:right;font-size:12px;">${item.qty || 0}</td>
<td style="padding:6px 8px;text-align:right;font-size:12px;">${fmt(item.unit_price)}</td>
<td style="padding:6px 8px;text-align:right;font-size:12px;font-weight:600;">${fmt(item.supply_amount)}</td>
<td style="padding:6px 8px;text-align:right;font-size:12px;color:#555;">${fmt(item.tax)}</td>
<td style="padding:6px 8px;font-size:12px;color:#555;">${item.note || ''}</td>
</tr>`;
});
return `
<div style="text-align:center;margin-bottom:32px;">
<h1 style="font-size:28px;font-weight:800;letter-spacing:8px;margin:0;">견 적 서</h1>
</div>
<div style="display:flex;justify-content:space-between;margin-bottom:24px;">
<div style="font-size:14px;">
<p style="margin:0 0 4px;"><strong>${data.client_name || ''}</strong> 귀하</p>
<p style="margin:0;font-size:12px;color:#555;">아래와 같이 견적합니다.</p>
</div>
<div style="font-size:12px;text-align:right;color:#555;">
<p style="margin:0;">견적일자: ${data.quote_date || ''}</p>
</div>
</div>
<div style="background:#EFF6FF;border:2px solid #3B82F6;border-radius:8px;padding:16px 24px;text-align:center;margin-bottom:24px;">
<p style="margin:0 0 4px;font-size:13px;color:#555;">견적금액</p>
<p style="margin:0;font-size:24px;font-weight:800;color:#1E40AF;">₩ ${fmt(data.total_amount)}</p>
<p style="margin:4px 0 0;font-size:11px;color:#666;">(공급가액 ${fmt(data.total_supply)} + 부가세 ${fmt(data.total_tax)})</p>
</div>
<table style="width:100%;border-collapse:collapse;margin-bottom:16px;font-size:12px;">
<caption style="text-align:left;font-weight:700;font-size:13px;margin-bottom:8px;">공급자</caption>
<tbody>
<tr style="border-bottom:1px solid #eee;">
<td style="padding:6px 8px;background:#F9FAFB;width:100px;font-weight:600;font-size:11px;">사업자등록번호</td>
<td style="padding:6px 8px;">${data.business_num || ''}</td>
<td style="padding:6px 8px;background:#F9FAFB;width:60px;font-weight:600;font-size:11px;">상호</td>
<td style="padding:6px 8px;">${data.company_name || ''}</td>
<td style="padding:6px 8px;background:#F9FAFB;width:60px;font-weight:600;font-size:11px;">대표자</td>
<td style="padding:6px 8px;">${data.ceo_name || ''}</td>
</tr>
<tr style="border-bottom:1px solid #eee;">
<td style="padding:6px 8px;background:#F9FAFB;font-weight:600;font-size:11px;">소재지</td>
<td colspan="5" style="padding:6px 8px;">${data.company_address || ''}</td>
</tr>
<tr style="border-bottom:1px solid #eee;">
<td style="padding:6px 8px;background:#F9FAFB;font-weight:600;font-size:11px;">업태</td>
<td style="padding:6px 8px;">${data.business_type || ''}</td>
<td style="padding:6px 8px;background:#F9FAFB;font-weight:600;font-size:11px;">업종</td>
<td colspan="3" style="padding:6px 8px;">${data.business_item || ''}</td>
</tr>
<tr>
<td style="padding:6px 8px;background:#F9FAFB;font-weight:600;font-size:11px;">연락처</td>
<td style="padding:6px 8px;">${data.phone || ''}</td>
<td style="padding:6px 8px;background:#F9FAFB;font-weight:600;font-size:11px;">계좌</td>
<td colspan="3" style="padding:6px 8px;">${data.bank_account || ''}</td>
</tr>
</tbody>
</table>
<table style="width:100%;border-collapse:collapse;margin-bottom:16px;">
<thead>
<tr style="background:#F9FAFB;border-bottom:2px solid #ccc;">
<th style="padding:8px;text-align:center;font-size:11px;width:32px;">No</th>
<th style="padding:8px;text-align:left;font-size:11px;">품명</th>
<th style="padding:8px;text-align:left;font-size:11px;">규격</th>
<th style="padding:8px;text-align:right;font-size:11px;">수량</th>
<th style="padding:8px;text-align:right;font-size:11px;">단가</th>
<th style="padding:8px;text-align:right;font-size:11px;">공급가액</th>
<th style="padding:8px;text-align:right;font-size:11px;">세액</th>
<th style="padding:8px;text-align:left;font-size:11px;">비고</th>
</tr>
</thead>
<tbody>${itemsHtml}</tbody>
<tfoot>
<tr style="border-top:2px solid #999;background:#F9FAFB;">
<td colspan="5" style="padding:8px;text-align:right;font-weight:700;font-size:12px;">합 계</td>
<td style="padding:8px;text-align:right;font-weight:700;font-size:12px;color:#1E40AF;">${fmt(data.total_supply)}</td>
<td style="padding:8px;text-align:right;font-weight:700;font-size:12px;color:#1E40AF;">${fmt(data.total_tax)}</td>
<td></td>
</tr>
</tfoot>
</table>
${data.remarks ? `<div style="margin-top:16px;padding:12px 16px;background:#FFFBEB;border:1px solid #FDE68A;border-radius:6px;font-size:12px;"><strong style="font-size:11px;color:#92400E;">특이사항</strong><p style="margin:4px 0 0;white-space:pre-wrap;">${data.remarks}</p></div>` : ''}
`;
}
function openQuotationPreview() {
const qtContainer = document.getElementById('quotation-form-container');
const qtAlpine = qtContainer._x_dataStack?.[0];
const qtItems = qtAlpine ? qtAlpine.items.filter(i => i.name.trim()).map(i => ({
name: i.name.trim(), spec: (i.spec||'').trim(), qty: parseInt(i.qty)||0, unit_price: parseInt(i.unit_price)||0,
supply_amount: (parseInt(i.qty)||0) * (parseInt(i.unit_price)||0), tax: qtAlpine.itemTax(i), note: (i.note||'').trim(),
})) : [];
const data = {
client_name: document.getElementById('qt-client-name').value.trim(),
quote_date: document.getElementById('qt-quote-date').value,
company_name: document.getElementById('qt-company-name').value,
business_num: document.getElementById('qt-business-num').value,
ceo_name: document.getElementById('qt-ceo-name').value,
company_address: document.getElementById('qt-company-address').value,
business_type: document.getElementById('qt-business-type-input')?.value || '',
business_item: document.getElementById('qt-business-item-input')?.value || '',
phone: document.getElementById('qt-phone-input')?.value || '',
bank_account: document.getElementById('qt-bank-input')?.value || '',
items: qtItems,
total_supply: qtAlpine ? qtAlpine.totalSupply() : 0,
total_tax: qtAlpine ? qtAlpine.totalTax() : 0,
total_amount: qtAlpine ? qtAlpine.totalAmount() : 0,
remarks: document.getElementById('qt-remarks').value.trim(),
};
document.getElementById('quotation-preview-content').innerHTML = buildQuotationPreviewHtml(data);
document.getElementById('quotation-preview-modal').style.display = '';
document.body.style.overflow = 'hidden';
}
function closeQuotationPreview() {
document.getElementById('quotation-preview-modal').style.display = 'none';
document.body.style.overflow = '';
}
function printQuotationPreview() {
const content = document.getElementById('quotation-preview-content').innerHTML;
const win = window.open('', '_blank');
win.document.write('<html><head><title>견적서</title>');
win.document.write('<style>body{font-family:"Pretendard","Malgun Gothic",sans-serif;padding:48px 56px;margin:0;}@media print{body{padding:40px 48px;}}</style>');
win.document.write('</head><body>');
win.document.write(content);
win.document.write('</body></html>');
win.document.close();
win.print();
}
function printDelegationPreview() {
const content = document.getElementById('delegation-preview-content').innerHTML;
const win = window.open('', '_blank');

View File

@@ -163,6 +163,11 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-
'tenantInfo' => $tenantInfo ?? [],
])
{{-- 견적서 전용 --}}
@include('approvals.partials._quotation-form', [
'tenantInfo' => $tenantInfo ?? [],
])
{{-- 지출결의서 전용 --}}
@php
$existingFiles = [];
@@ -302,6 +307,7 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm fon
let isSealUsageForm = false;
let isDelegationForm = false;
let isBoardMinutesForm = false;
let isQuotationForm = false;
const formDescriptions = {
BUSINESS_DRAFT: {
@@ -364,6 +370,11 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm fon
color: 'border-slate-300 bg-slate-50', titleColor: 'text-slate-800', textColor: 'text-slate-600',
text: '이사회 개최 내용을 기록하는 공식 문서입니다. 일시, 장소, 출석현황, 의안, 의사경과, 기명날인 등을 기재하며, 법적 효력을 갖는 회의록입니다.',
},
quotation: {
title: '견적서', icon: '💰',
color: 'border-emerald-200 bg-emerald-50', titleColor: 'text-emerald-800', textColor: 'text-emerald-600',
text: '고객에게 제출할 견적서를 작성하는 문서입니다. 품목, 수량, 단가, 공급가액, 부가세를 기재하며, 승인 후 공식 견적서로 사용할 수 있습니다.',
},
pr_expense: {
title: '지출품의서', icon: '📋',
color: 'border-orange-200 bg-orange-50', titleColor: 'text-orange-800', textColor: 'text-orange-700',
@@ -397,7 +408,7 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm fon
leave: '인사/근태', attendance_request: '인사/근태', resignation: '인사/근태', reason_report: '인사/근태', delegation: '인사/근태', board_minutes: '인사/근태',
employment_cert: '증명서', career_cert: '증명서', appointment_cert: '증명서', seal_usage: '증명서',
pr_expense: '품의', pr_contract: '품의', pr_purchase: '품의', pr_trip: '품의', pr_settlement: '품의',
expense: '재무',
expense: '재무', quotation: '재무',
};
const categoryIcons = {
'일반': '📄', '인사/근태': '👤', '증명서': '📜', '품의': '📋', '재무': '💰',
@@ -637,6 +648,7 @@ function switchFormMode(formId) {
const sealUsageContainer = document.getElementById('seal-usage-form-container');
const delegationContainer = document.getElementById('delegation-form-container');
const boardMinutesContainer = document.getElementById('board-minutes-form-container');
const quotationContainer = document.getElementById('quotation-form-container');
const bodyArea = document.getElementById('body-area');
expenseContainer.style.display = 'none';
@@ -645,6 +657,7 @@ function switchFormMode(formId) {
sealUsageContainer.style.display = 'none';
delegationContainer.style.display = 'none';
boardMinutesContainer.style.display = 'none';
quotationContainer.style.display = 'none';
bodyArea.style.display = 'none';
isExpenseForm = false;
isPurchaseRequestForm = false;
@@ -652,6 +665,7 @@ function switchFormMode(formId) {
isSealUsageForm = false;
isDelegationForm = false;
isBoardMinutesForm = false;
isQuotationForm = false;
if (code === 'expense') {
isExpenseForm = true;
@@ -677,6 +691,9 @@ function switchFormMode(formId) {
} else if (code === 'board_minutes') {
isBoardMinutesForm = true;
boardMinutesContainer.style.display = '';
} else if (code === 'quotation') {
isQuotationForm = true;
quotationContainer.style.display = '';
} else {
bodyArea.style.display = '';
}
@@ -687,7 +704,7 @@ function applyBodyTemplate(formId) {
switchFormMode(formId);
// 전용 폼이면 제목만 자동 설정하고 body template 적용 건너뜀
if (isExpenseForm || isPurchaseRequestForm || isCertForm || isSealUsageForm || isDelegationForm || isBoardMinutesForm) {
if (isExpenseForm || isPurchaseRequestForm || isCertForm || isSealUsageForm || isDelegationForm || isBoardMinutesForm || isQuotationForm) {
const titleEl = document.getElementById('title');
if (!titleEl.value.trim()) {
const formSelect = document.getElementById('form_id');
@@ -831,8 +848,42 @@ function applyBodyTemplate(formId) {
}
}
// 견적서 기존 데이터 복원
if (isQuotationForm) {
const qtContent = @json($approval->content ?? []);
if (qtContent.client_name) {
document.getElementById('qt-client-name').value = qtContent.client_name || '';
document.getElementById('qt-quote-date').value = qtContent.quote_date || '';
document.getElementById('qt-remarks').value = qtContent.remarks || '';
if (qtContent.business_type) {
const btInput = document.getElementById('qt-business-type-input');
if (btInput) btInput.value = qtContent.business_type;
}
if (qtContent.business_item) {
const biInput = document.getElementById('qt-business-item-input');
if (biInput) biInput.value = qtContent.business_item;
}
if (qtContent.phone) {
const phInput = document.getElementById('qt-phone-input');
if (phInput) phInput.value = qtContent.phone;
}
if (qtContent.bank_account) {
const baInput = document.getElementById('qt-bank-input');
if (baInput) baInput.value = qtContent.bank_account;
}
const qtAlpine = document.getElementById('quotation-form-container')._x_dataStack?.[0];
if (qtAlpine && qtContent.items && qtContent.items.length > 0) {
qtAlpine.items = qtContent.items.map(i => ({
name: i.name || '', spec: i.spec || '', qty: i.qty || 1,
unit_price: i.unit_price || 0, tax: i.tax || 0, note: i.note || '',
}));
}
}
}
// 전용 폼이 아닌 경우에만 Quill 편집기 자동 활성화
if (!isExpenseForm && !isPurchaseRequestForm && !isCertForm && !isSealUsageForm && !isDelegationForm && !isBoardMinutesForm) {
if (!isExpenseForm && !isPurchaseRequestForm && !isCertForm && !isSealUsageForm && !isDelegationForm && !isBoardMinutesForm && !isQuotationForm) {
const existingBody = document.getElementById('body').value;
if (/<[a-z][\s\S]*>/i.test(existingBody)) {
document.getElementById('useEditor').checked = true;
@@ -961,6 +1012,38 @@ function applyBodyTemplate(formId) {
meeting_date: bmDatetime.split('T')[0],
};
formBody = null;
} else if (isQuotationForm) {
const qtClientName = document.getElementById('qt-client-name').value.trim();
if (!qtClientName) { showToast('수신(고객명)을 입력해주세요.', 'warning'); return; }
const qtQuoteDate = document.getElementById('qt-quote-date').value;
if (!qtQuoteDate) { showToast('견적일자를 입력해주세요.', 'warning'); return; }
const qtContainer = document.getElementById('quotation-form-container');
const qtAlpine = qtContainer._x_dataStack?.[0];
const qtItems = qtAlpine ? qtAlpine.items : [];
if (qtItems.length === 0 || !qtItems[0].name.trim()) { showToast('품목을 1건 이상 입력해주세요.', 'warning'); return; }
const mappedItems = qtItems.filter(i => i.name.trim()).map(i => ({
name: i.name.trim(), spec: (i.spec || '').trim(), qty: parseInt(i.qty) || 0,
unit_price: parseInt(i.unit_price) || 0, supply_amount: (parseInt(i.qty) || 0) * (parseInt(i.unit_price) || 0),
tax: qtAlpine.itemTax(i), note: (i.note || '').trim(),
}));
formContent = {
client_name: qtClientName, quote_date: qtQuoteDate,
company_name: document.getElementById('qt-company-name').value,
business_num: document.getElementById('qt-business-num').value,
ceo_name: document.getElementById('qt-ceo-name').value,
company_address: document.getElementById('qt-company-address').value,
business_type: document.getElementById('qt-business-type-input')?.value || document.getElementById('qt-business-type').value,
business_item: document.getElementById('qt-business-item-input')?.value || document.getElementById('qt-business-item').value,
phone: document.getElementById('qt-phone-input')?.value || document.getElementById('qt-phone').value,
bank_account: document.getElementById('qt-bank-input')?.value || document.getElementById('qt-bank-account').value,
items: mappedItems, total_supply: qtAlpine ? qtAlpine.totalSupply() : 0,
total_tax: qtAlpine ? qtAlpine.totalTax() : 0, total_amount: qtAlpine ? qtAlpine.totalAmount() : 0,
remarks: document.getElementById('qt-remarks').value.trim(),
};
formBody = null;
} else if (isDelegationForm) {
const dlAgentName = document.getElementById('dl-agent-name').value.trim();
if (!dlAgentName) {
@@ -1490,5 +1573,142 @@ function printDelegationPreview() {
win.document.close();
win.print();
}
// ─── 견적서 미리보기 ───
function buildQuotationPreviewHtml(data) {
const fmt = n => (n || 0).toLocaleString('ko-KR');
let itemsHtml = '';
(data.items || []).forEach((item, idx) => {
itemsHtml += `<tr style="border-bottom:1px solid #ddd;">
<td style="padding:6px 8px;text-align:center;font-size:12px;">${idx + 1}</td>
<td style="padding:6px 8px;font-size:12px;">${item.name || ''}</td>
<td style="padding:6px 8px;font-size:12px;color:#555;">${item.spec || ''}</td>
<td style="padding:6px 8px;text-align:right;font-size:12px;">${item.qty || 0}</td>
<td style="padding:6px 8px;text-align:right;font-size:12px;">${fmt(item.unit_price)}</td>
<td style="padding:6px 8px;text-align:right;font-size:12px;font-weight:600;">${fmt(item.supply_amount)}</td>
<td style="padding:6px 8px;text-align:right;font-size:12px;color:#555;">${fmt(item.tax)}</td>
<td style="padding:6px 8px;font-size:12px;color:#555;">${item.note || ''}</td>
</tr>`;
});
return `
<div style="text-align:center;margin-bottom:32px;">
<h1 style="font-size:28px;font-weight:800;letter-spacing:8px;margin:0;">견 적 서</h1>
</div>
<div style="display:flex;justify-content:space-between;margin-bottom:24px;">
<div style="font-size:14px;">
<p style="margin:0 0 4px;"><strong>${data.client_name || ''}</strong> 귀하</p>
<p style="margin:0;font-size:12px;color:#555;">아래와 같이 견적합니다.</p>
</div>
<div style="font-size:12px;text-align:right;color:#555;">
<p style="margin:0;">견적일자: ${data.quote_date || ''}</p>
</div>
</div>
<div style="background:#EFF6FF;border:2px solid #3B82F6;border-radius:8px;padding:16px 24px;text-align:center;margin-bottom:24px;">
<p style="margin:0 0 4px;font-size:13px;color:#555;">견적금액</p>
<p style="margin:0;font-size:24px;font-weight:800;color:#1E40AF;">₩ ${fmt(data.total_amount)}</p>
<p style="margin:4px 0 0;font-size:11px;color:#666;">(공급가액 ${fmt(data.total_supply)} + 부가세 ${fmt(data.total_tax)})</p>
</div>
<table style="width:100%;border-collapse:collapse;margin-bottom:16px;font-size:12px;">
<caption style="text-align:left;font-weight:700;font-size:13px;margin-bottom:8px;">공급자</caption>
<tbody>
<tr style="border-bottom:1px solid #eee;">
<td style="padding:6px 8px;background:#F9FAFB;width:100px;font-weight:600;font-size:11px;">사업자등록번호</td>
<td style="padding:6px 8px;">${data.business_num || ''}</td>
<td style="padding:6px 8px;background:#F9FAFB;width:60px;font-weight:600;font-size:11px;">상호</td>
<td style="padding:6px 8px;">${data.company_name || ''}</td>
<td style="padding:6px 8px;background:#F9FAFB;width:60px;font-weight:600;font-size:11px;">대표자</td>
<td style="padding:6px 8px;">${data.ceo_name || ''}</td>
</tr>
<tr style="border-bottom:1px solid #eee;">
<td style="padding:6px 8px;background:#F9FAFB;font-weight:600;font-size:11px;">소재지</td>
<td colspan="5" style="padding:6px 8px;">${data.company_address || ''}</td>
</tr>
<tr style="border-bottom:1px solid #eee;">
<td style="padding:6px 8px;background:#F9FAFB;font-weight:600;font-size:11px;">업태</td>
<td style="padding:6px 8px;">${data.business_type || ''}</td>
<td style="padding:6px 8px;background:#F9FAFB;font-weight:600;font-size:11px;">업종</td>
<td colspan="3" style="padding:6px 8px;">${data.business_item || ''}</td>
</tr>
<tr>
<td style="padding:6px 8px;background:#F9FAFB;font-weight:600;font-size:11px;">연락처</td>
<td style="padding:6px 8px;">${data.phone || ''}</td>
<td style="padding:6px 8px;background:#F9FAFB;font-weight:600;font-size:11px;">계좌</td>
<td colspan="3" style="padding:6px 8px;">${data.bank_account || ''}</td>
</tr>
</tbody>
</table>
<table style="width:100%;border-collapse:collapse;margin-bottom:16px;">
<thead>
<tr style="background:#F9FAFB;border-bottom:2px solid #ccc;">
<th style="padding:8px;text-align:center;font-size:11px;width:32px;">No</th>
<th style="padding:8px;text-align:left;font-size:11px;">품명</th>
<th style="padding:8px;text-align:left;font-size:11px;">규격</th>
<th style="padding:8px;text-align:right;font-size:11px;">수량</th>
<th style="padding:8px;text-align:right;font-size:11px;">단가</th>
<th style="padding:8px;text-align:right;font-size:11px;">공급가액</th>
<th style="padding:8px;text-align:right;font-size:11px;">세액</th>
<th style="padding:8px;text-align:left;font-size:11px;">비고</th>
</tr>
</thead>
<tbody>${itemsHtml}</tbody>
<tfoot>
<tr style="border-top:2px solid #999;background:#F9FAFB;">
<td colspan="5" style="padding:8px;text-align:right;font-weight:700;font-size:12px;">합 계</td>
<td style="padding:8px;text-align:right;font-weight:700;font-size:12px;color:#1E40AF;">${fmt(data.total_supply)}</td>
<td style="padding:8px;text-align:right;font-weight:700;font-size:12px;color:#1E40AF;">${fmt(data.total_tax)}</td>
<td></td>
</tr>
</tfoot>
</table>
${data.remarks ? `<div style="margin-top:16px;padding:12px 16px;background:#FFFBEB;border:1px solid #FDE68A;border-radius:6px;font-size:12px;"><strong style="font-size:11px;color:#92400E;">특이사항</strong><p style="margin:4px 0 0;white-space:pre-wrap;">${data.remarks}</p></div>` : ''}
`;
}
function openQuotationPreview() {
const qtContainer = document.getElementById('quotation-form-container');
const qtAlpine = qtContainer._x_dataStack?.[0];
const qtItems = qtAlpine ? qtAlpine.items.filter(i => i.name.trim()).map(i => ({
name: i.name.trim(), spec: (i.spec||'').trim(), qty: parseInt(i.qty)||0, unit_price: parseInt(i.unit_price)||0,
supply_amount: (parseInt(i.qty)||0) * (parseInt(i.unit_price)||0), tax: qtAlpine.itemTax(i), note: (i.note||'').trim(),
})) : [];
const data = {
client_name: document.getElementById('qt-client-name').value.trim(),
quote_date: document.getElementById('qt-quote-date').value,
company_name: document.getElementById('qt-company-name').value,
business_num: document.getElementById('qt-business-num').value,
ceo_name: document.getElementById('qt-ceo-name').value,
company_address: document.getElementById('qt-company-address').value,
business_type: document.getElementById('qt-business-type-input')?.value || '',
business_item: document.getElementById('qt-business-item-input')?.value || '',
phone: document.getElementById('qt-phone-input')?.value || '',
bank_account: document.getElementById('qt-bank-input')?.value || '',
items: qtItems,
total_supply: qtAlpine ? qtAlpine.totalSupply() : 0,
total_tax: qtAlpine ? qtAlpine.totalTax() : 0,
total_amount: qtAlpine ? qtAlpine.totalAmount() : 0,
remarks: document.getElementById('qt-remarks').value.trim(),
};
document.getElementById('quotation-preview-content').innerHTML = buildQuotationPreviewHtml(data);
document.getElementById('quotation-preview-modal').style.display = '';
document.body.style.overflow = 'hidden';
}
function closeQuotationPreview() {
document.getElementById('quotation-preview-modal').style.display = 'none';
document.body.style.overflow = '';
}
function printQuotationPreview() {
const content = document.getElementById('quotation-preview-content').innerHTML;
const win = window.open('', '_blank');
win.document.write('<html><head><title>견적서</title>');
win.document.write('<style>body{font-family:"Pretendard","Malgun Gothic",sans-serif;padding:48px 56px;margin:0;}@media print{body{padding:40px 48px;}}</style>');
win.document.write('</head><body>');
win.document.write(content);
win.document.write('</body></html>');
win.document.close();
win.print();
}
</script>
@endpush

View File

@@ -0,0 +1,265 @@
{{--
견적서 전용
Props:
$tenantInfo (array) - 테넌트(회사) 정보
--}}
@php
$tenantInfo = $tenantInfo ?? [];
@endphp
<div id="quotation-form-container" style="display: none;" class="mb-4"
x-data="{
items: [{ name: '', spec: '', qty: 1, unit_price: 0, tax: 0, note: '' }],
taxRate: 10,
autoTax: true,
addItem() {
this.items.push({ name: '', spec: '', qty: 1, unit_price: 0, tax: 0, note: '' });
},
removeItem(idx) {
if (this.items.length <= 1) return;
this.items.splice(idx, 1);
},
supplyAmount(item) {
return (parseInt(item.qty) || 0) * (parseInt(item.unit_price) || 0);
},
itemTax(item) {
if (this.autoTax) return Math.round(this.supplyAmount(item) * this.taxRate / 100);
return parseInt(item.tax) || 0;
},
totalSupply() {
return this.items.reduce((s, item) => s + this.supplyAmount(item), 0);
},
totalTax() {
return this.items.reduce((s, item) => s + this.itemTax(item), 0);
},
totalAmount() {
return this.totalSupply() + this.totalTax();
},
formatMoney(n) {
return n.toLocaleString('ko-KR');
},
}">
<input type="hidden" id="qt-company-name" value="{{ $tenantInfo['company_name'] ?? '' }}">
<input type="hidden" id="qt-business-num" value="{{ $tenantInfo['business_num'] ?? '' }}">
<input type="hidden" id="qt-ceo-name" value="{{ $tenantInfo['ceo_name'] ?? '' }}">
<input type="hidden" id="qt-company-address" value="{{ $tenantInfo['address'] ?? '' }}">
<input type="hidden" id="qt-business-type" value="{{ $tenantInfo['business_type'] ?? '' }}">
<input type="hidden" id="qt-business-item" value="{{ $tenantInfo['business_item'] ?? '' }}">
<input type="hidden" id="qt-phone" value="{{ $tenantInfo['phone'] ?? '' }}">
<input type="hidden" id="qt-bank-account" value="{{ $tenantInfo['bank_account'] ?? '' }}">
<div class="space-y-4">
{{-- 1. 수신/일자 --}}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-700">1. 수신 정보</h3>
</div>
<div class="p-4 space-y-3">
<div class="flex gap-4 flex-wrap">
<div style="flex: 1 1 250px;">
<label class="block text-xs font-medium text-gray-500 mb-1">수신 (고객명) <span class="text-red-500">*</span></label>
<input type="text" id="qt-client-name" placeholder="고객명 귀하"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div style="flex: 0 0 180px;">
<label class="block text-xs font-medium text-gray-500 mb-1">견적일자 <span class="text-red-500">*</span></label>
<input type="date" id="qt-quote-date" value="{{ now()->format('Y-m-d') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
</div>
</div>
{{-- 2. 공급자 정보 (읽기전용) --}}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-700">2. 공급자</h3>
</div>
<div class="p-4 space-y-3">
<div class="flex gap-4 flex-wrap">
<div style="flex: 1 1 200px; max-width: 250px;">
<label class="block text-xs font-medium text-gray-500 mb-1">사업자등록번호</label>
<input type="text" readonly value="{{ $tenantInfo['business_num'] ?? '' }}"
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 text-gray-700">
</div>
<div style="flex: 1 1 200px;">
<label class="block text-xs font-medium text-gray-500 mb-1">상호</label>
<input type="text" readonly value="{{ $tenantInfo['company_name'] ?? '' }}"
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 text-gray-700">
</div>
<div style="flex: 0 0 150px;">
<label class="block text-xs font-medium text-gray-500 mb-1">대표자</label>
<input type="text" readonly value="{{ $tenantInfo['ceo_name'] ?? '' }}"
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 text-gray-700">
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">사업장 소재지</label>
<input type="text" readonly value="{{ $tenantInfo['address'] ?? '' }}"
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 text-gray-700">
</div>
<div class="flex gap-4 flex-wrap">
<div style="flex: 1 1 180px; max-width: 220px;">
<label class="block text-xs font-medium text-gray-500 mb-1">업태</label>
<input type="text" id="qt-business-type-input" value="{{ $tenantInfo['business_type'] ?? '' }}" placeholder="업태"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div style="flex: 1 1 250px;">
<label class="block text-xs font-medium text-gray-500 mb-1">업종</label>
<input type="text" id="qt-business-item-input" value="{{ $tenantInfo['business_item'] ?? '' }}" placeholder="업종"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<div class="flex gap-4 flex-wrap">
<div style="flex: 1 1 200px; max-width: 250px;">
<label class="block text-xs font-medium text-gray-500 mb-1">연락처</label>
<input type="text" id="qt-phone-input" value="{{ $tenantInfo['phone'] ?? '' }}" placeholder="연락처"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div style="flex: 1 1 250px;">
<label class="block text-xs font-medium text-gray-500 mb-1">계좌번호</label>
<input type="text" id="qt-bank-input" value="{{ $tenantInfo['bank_account'] ?? '' }}" placeholder="(은행명) 계좌번호"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
</div>
</div>
{{-- 3. 품목 목록 --}}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-700">3. 견적 품목</h3>
<div class="flex items-center gap-3">
<label class="inline-flex items-center gap-1 text-xs text-gray-500 cursor-pointer">
<input type="checkbox" x-model="autoTax" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
부가세 자동(10%)
</label>
<button type="button" @click="addItem()"
class="px-2 py-1 bg-blue-50 text-blue-600 hover:bg-blue-100 border border-blue-200 rounded text-xs font-medium transition">+ 품목 추가</button>
</div>
</div>
<div class="p-4 overflow-x-auto">
<table class="w-full text-sm" style="min-width: 700px;">
<thead>
<tr class="bg-gray-50 text-xs text-gray-600">
<th class="px-2 py-2 text-left" style="width: 32px;">#</th>
<th class="px-2 py-2 text-left" style="min-width: 140px;">품명</th>
<th class="px-2 py-2 text-left" style="width: 80px;">규격</th>
<th class="px-2 py-2 text-right" style="width: 60px;">수량</th>
<th class="px-2 py-2 text-right" style="width: 110px;">단가</th>
<th class="px-2 py-2 text-right" style="width: 110px;">공급가액</th>
<th class="px-2 py-2 text-right" style="width: 100px;">세액</th>
<th class="px-2 py-2 text-left" style="width: 90px;">비고</th>
<th class="px-2 py-2" style="width: 32px;"></th>
</tr>
</thead>
<tbody>
<template x-for="(item, idx) in items" :key="idx">
<tr class="border-t border-gray-100">
<td class="px-2 py-1.5 text-xs text-gray-400" x-text="idx + 1"></td>
<td class="px-1 py-1">
<input type="text" x-model="item.name" placeholder="품명"
class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-1 focus:ring-blue-500">
</td>
<td class="px-1 py-1">
<input type="text" x-model="item.spec" placeholder="규격"
class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-1 focus:ring-blue-500">
</td>
<td class="px-1 py-1">
<input type="number" x-model.number="item.qty" min="1" class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm text-right focus:outline-none focus:ring-1 focus:ring-blue-500">
</td>
<td class="px-1 py-1">
<input type="text" inputmode="numeric"
:value="formatMoney(item.unit_price || 0)"
@focus="$el.value = item.unit_price || ''"
@blur="item.unit_price = parseInt($el.value.replace(/,/g,'')) || 0; $el.value = formatMoney(item.unit_price)"
class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm text-right focus:outline-none focus:ring-1 focus:ring-blue-500">
</td>
<td class="px-2 py-1.5 text-right text-sm font-medium text-gray-700" x-text="formatMoney(supplyAmount(item))"></td>
<td class="px-2 py-1.5 text-right text-sm text-gray-500" x-text="formatMoney(itemTax(item))"></td>
<td class="px-1 py-1">
<input type="text" x-model="item.note" placeholder=""
class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-1 focus:ring-blue-500">
</td>
<td class="px-1 py-1 text-center">
<button type="button" @click="removeItem(idx)" x-show="items.length > 1"
class="p-1 text-red-400 hover:text-red-600 transition">
<svg class="w-3.5 h-3.5" 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>
</td>
</tr>
</template>
</tbody>
<tfoot>
<tr class="border-t-2 border-gray-300 bg-gray-50 font-semibold">
<td colspan="5" class="px-2 py-2 text-right text-sm"> </td>
<td class="px-2 py-2 text-right text-sm text-blue-700" x-text="formatMoney(totalSupply())"></td>
<td class="px-2 py-2 text-right text-sm text-blue-700" x-text="formatMoney(totalTax())"></td>
<td colspan="2"></td>
</tr>
<tr class="bg-blue-50 font-bold">
<td colspan="5" class="px-2 py-2 text-right text-sm text-blue-800">견적금액 (공급가액 + 세액)</td>
<td colspan="2" class="px-2 py-2 text-right text-sm text-blue-800" x-text="formatMoney(totalAmount()) + '원'"></td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</div>
</div>
{{-- 4. 특이사항 --}}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-700">4. 특이사항</h3>
</div>
<div class="p-4">
<textarea id="qt-remarks" rows="3" placeholder="예: *부가세액 별도* 특이사항을 적어주세요"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
</div>
</div>
{{-- 미리보기 버튼 --}}
<div class="flex justify-end">
<button type="button" onclick="openQuotationPreview()"
class="px-3 py-2 bg-indigo-50 text-indigo-600 hover:bg-indigo-100 border border-indigo-200 rounded-lg text-sm font-medium transition inline-flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
미리보기
</button>
</div>
</div>
</div>
{{-- 견적서 미리보기 모달 --}}
<div id="quotation-preview-modal" style="display: none;" class="fixed inset-0 z-50">
<div class="absolute inset-0 bg-black/50" onclick="closeQuotationPreview()"></div>
<div class="relative flex items-center justify-center min-h-full p-4">
<div class="bg-white rounded-xl shadow-2xl w-full overflow-hidden relative" style="max-width: 780px;">
<div class="flex items-center justify-between px-5 py-3 border-b border-gray-200 bg-gray-50">
<h3 class="text-base font-semibold text-gray-800">견적서 미리보기</h3>
<div class="flex items-center gap-2">
<button type="button" onclick="printQuotationPreview()"
class="px-3 py-1.5 bg-white border border-gray-300 hover:bg-gray-50 rounded-lg text-xs font-medium transition inline-flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"/>
</svg>
인쇄
</button>
<button type="button" onclick="closeQuotationPreview()"
class="p-1 text-gray-400 hover:text-gray-600 transition">
<svg class="w-5 h-5" 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>
<div class="overflow-y-auto" style="max-height: 80vh;">
<div id="quotation-preview-content" style="padding: 40px 48px; font-family: 'Pretendard', 'Malgun Gothic', sans-serif;"></div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,196 @@
{{--
견적서 읽기전용 렌더링
Props:
$content (array) - approvals.content JSON
--}}
<div class="space-y-4">
{{-- 미리보기 버튼 --}}
<div class="flex justify-end gap-2">
<button type="button" onclick="openQuotationShowPreview()"
class="px-3 py-1.5 bg-indigo-50 text-indigo-600 hover:bg-indigo-100 border border-indigo-200 rounded-lg text-sm font-medium transition inline-flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
견적서 미리보기
</button>
</div>
{{-- 수신/일자 --}}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-700">수신 정보</h3>
</div>
<div class="p-4">
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));">
<div>
<span class="text-xs text-gray-500">수신 (고객명)</span>
<div class="text-sm font-medium mt-0.5">{{ $content['client_name'] ?? '-' }}</div>
</div>
<div>
<span class="text-xs text-gray-500">견적일자</span>
<div class="text-sm font-medium mt-0.5">{{ $content['quote_date'] ?? '-' }}</div>
</div>
</div>
</div>
</div>
{{-- 공급자 --}}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-700">공급자</h3>
</div>
<div class="p-4">
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));">
<div>
<span class="text-xs text-gray-500">상호</span>
<div class="text-sm font-medium mt-0.5">{{ $content['company_name'] ?? '-' }}</div>
</div>
<div>
<span class="text-xs text-gray-500">대표자</span>
<div class="text-sm font-medium mt-0.5">{{ $content['ceo_name'] ?? '-' }}</div>
</div>
<div>
<span class="text-xs text-gray-500">사업자등록번호</span>
<div class="text-sm font-medium mt-0.5">{{ $content['business_num'] ?? '-' }}</div>
</div>
</div>
</div>
</div>
{{-- 품목 테이블 --}}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-700">견적 품목</h3>
</div>
<div class="p-4 overflow-x-auto">
<table class="w-full text-sm" style="min-width: 600px;">
<thead>
<tr class="bg-gray-50 text-xs text-gray-600 border-b border-gray-200">
<th class="px-2 py-2 text-left">#</th>
<th class="px-2 py-2 text-left">품명</th>
<th class="px-2 py-2 text-left">규격</th>
<th class="px-2 py-2 text-right">수량</th>
<th class="px-2 py-2 text-right">단가</th>
<th class="px-2 py-2 text-right">공급가액</th>
<th class="px-2 py-2 text-right">세액</th>
<th class="px-2 py-2 text-left">비고</th>
</tr>
</thead>
<tbody>
@foreach(($content['items'] ?? []) as $idx => $item)
<tr class="border-t border-gray-100">
<td class="px-2 py-1.5 text-xs text-gray-400">{{ $idx + 1 }}</td>
<td class="px-2 py-1.5">{{ $item['name'] ?? '' }}</td>
<td class="px-2 py-1.5 text-gray-500">{{ $item['spec'] ?? '' }}</td>
<td class="px-2 py-1.5 text-right">{{ $item['qty'] ?? 0 }}</td>
<td class="px-2 py-1.5 text-right">{{ number_format($item['unit_price'] ?? 0) }}</td>
<td class="px-2 py-1.5 text-right font-medium">{{ number_format($item['supply_amount'] ?? 0) }}</td>
<td class="px-2 py-1.5 text-right text-gray-500">{{ number_format($item['tax'] ?? 0) }}</td>
<td class="px-2 py-1.5 text-gray-500">{{ $item['note'] ?? '' }}</td>
</tr>
@endforeach
</tbody>
<tfoot>
<tr class="border-t-2 border-gray-300 bg-gray-50 font-semibold">
<td colspan="5" class="px-2 py-2 text-right text-sm"> </td>
<td class="px-2 py-2 text-right text-sm text-blue-700">{{ number_format($content['total_supply'] ?? 0) }}</td>
<td class="px-2 py-2 text-right text-sm text-blue-700">{{ number_format($content['total_tax'] ?? 0) }}</td>
<td></td>
</tr>
<tr class="bg-blue-50 font-bold">
<td colspan="5" class="px-2 py-2 text-right text-sm text-blue-800">견적금액</td>
<td colspan="2" class="px-2 py-2 text-right text-sm text-blue-800">{{ number_format($content['total_amount'] ?? 0) }}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
{{-- 특이사항 --}}
@if(!empty($content['remarks']))
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-700">특이사항</h3>
</div>
<div class="p-4">
<div class="text-sm text-gray-700 whitespace-pre-wrap">{{ $content['remarks'] }}</div>
</div>
</div>
@endif
</div>
{{-- 미리보기 모달 --}}
<div id="quotation-show-preview-modal" style="display: none;" class="fixed inset-0 z-50">
<div class="absolute inset-0 bg-black/50" onclick="closeQuotationShowPreview()"></div>
<div class="relative flex items-center justify-center min-h-full p-4">
<div class="bg-white rounded-xl shadow-2xl w-full overflow-hidden relative" style="max-width: 780px;">
<div class="flex items-center justify-between px-5 py-3 border-b border-gray-200 bg-gray-50">
<h3 class="text-base font-semibold text-gray-800">견적서 미리보기</h3>
<div class="flex items-center gap-2">
<button type="button" onclick="printQuotationShowPreview()"
class="px-3 py-1.5 bg-white border border-gray-300 hover:bg-gray-50 rounded-lg text-xs font-medium transition inline-flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"/>
</svg>
인쇄
</button>
<button type="button" onclick="closeQuotationShowPreview()"
class="p-1 text-gray-400 hover:text-gray-600 transition">
<svg class="w-5 h-5" 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>
<div class="overflow-y-auto" style="max-height: 80vh;">
<div id="quotation-show-preview-content" style="padding: 40px 48px; font-family: 'Pretendard', 'Malgun Gothic', sans-serif;"></div>
</div>
</div>
</div>
</div>
@push('scripts')
<script>
function buildQuotationShowPreviewHtml(data) {
const fmt = n => (n || 0).toLocaleString('ko-KR');
let itemsHtml = '';
(data.items || []).forEach((item, idx) => {
itemsHtml += '<tr style="border-bottom:1px solid #ddd;">' +
'<td style="padding:6px 8px;text-align:center;font-size:12px;">' + (idx+1) + '</td>' +
'<td style="padding:6px 8px;font-size:12px;">' + (item.name||'') + '</td>' +
'<td style="padding:6px 8px;font-size:12px;color:#555;">' + (item.spec||'') + '</td>' +
'<td style="padding:6px 8px;text-align:right;font-size:12px;">' + (item.qty||0) + '</td>' +
'<td style="padding:6px 8px;text-align:right;font-size:12px;">' + fmt(item.unit_price) + '</td>' +
'<td style="padding:6px 8px;text-align:right;font-size:12px;font-weight:600;">' + fmt(item.supply_amount) + '</td>' +
'<td style="padding:6px 8px;text-align:right;font-size:12px;color:#555;">' + fmt(item.tax) + '</td>' +
'<td style="padding:6px 8px;font-size:12px;color:#555;">' + (item.note||'') + '</td></tr>';
});
return '<div style="text-align:center;margin-bottom:32px;"><h1 style="font-size:28px;font-weight:800;letter-spacing:8px;margin:0;">견 적 서</h1></div>' +
'<div style="display:flex;justify-content:space-between;margin-bottom:24px;"><div style="font-size:14px;"><p style="margin:0 0 4px;"><strong>' + (data.client_name||'') + '</strong> 귀하</p><p style="margin:0;font-size:12px;color:#555;">아래와 같이 견적합니다.</p></div><div style="font-size:12px;text-align:right;color:#555;"><p style="margin:0;">견적일자: ' + (data.quote_date||'') + '</p></div></div>' +
'<div style="background:#EFF6FF;border:2px solid #3B82F6;border-radius:8px;padding:16px 24px;text-align:center;margin-bottom:24px;"><p style="margin:0 0 4px;font-size:13px;color:#555;">견적금액</p><p style="margin:0;font-size:24px;font-weight:800;color:#1E40AF;">₩ ' + fmt(data.total_amount) + '</p><p style="margin:4px 0 0;font-size:11px;color:#666;">(공급가액 ' + fmt(data.total_supply) + ' + 부가세 ' + fmt(data.total_tax) + ')</p></div>' +
'<table style="width:100%;border-collapse:collapse;margin-bottom:16px;font-size:12px;"><caption style="text-align:left;font-weight:700;font-size:13px;margin-bottom:8px;">공급자</caption><tbody>' +
'<tr style="border-bottom:1px solid #eee;"><td style="padding:6px 8px;background:#F9FAFB;width:100px;font-weight:600;font-size:11px;">사업자등록번호</td><td style="padding:6px 8px;">' + (data.business_num||'') + '</td><td style="padding:6px 8px;background:#F9FAFB;width:60px;font-weight:600;font-size:11px;">상호</td><td style="padding:6px 8px;">' + (data.company_name||'') + '</td><td style="padding:6px 8px;background:#F9FAFB;width:60px;font-weight:600;font-size:11px;">대표자</td><td style="padding:6px 8px;">' + (data.ceo_name||'') + '</td></tr>' +
'<tr style="border-bottom:1px solid #eee;"><td style="padding:6px 8px;background:#F9FAFB;font-weight:600;font-size:11px;">소재지</td><td colspan="5" style="padding:6px 8px;">' + (data.company_address||'') + '</td></tr>' +
'</tbody></table>' +
'<table style="width:100%;border-collapse:collapse;margin-bottom:16px;"><thead><tr style="background:#F9FAFB;border-bottom:2px solid #ccc;"><th style="padding:8px;text-align:center;font-size:11px;width:32px;">No</th><th style="padding:8px;text-align:left;font-size:11px;">품명</th><th style="padding:8px;text-align:left;font-size:11px;">규격</th><th style="padding:8px;text-align:right;font-size:11px;">수량</th><th style="padding:8px;text-align:right;font-size:11px;">단가</th><th style="padding:8px;text-align:right;font-size:11px;">공급가액</th><th style="padding:8px;text-align:right;font-size:11px;">세액</th><th style="padding:8px;text-align:left;font-size:11px;">비고</th></tr></thead>' +
'<tbody>' + itemsHtml + '</tbody>' +
'<tfoot><tr style="border-top:2px solid #999;background:#F9FAFB;"><td colspan="5" style="padding:8px;text-align:right;font-weight:700;font-size:12px;">합 계</td><td style="padding:8px;text-align:right;font-weight:700;font-size:12px;color:#1E40AF;">' + fmt(data.total_supply) + '</td><td style="padding:8px;text-align:right;font-weight:700;font-size:12px;color:#1E40AF;">' + fmt(data.total_tax) + '</td><td></td></tr></tfoot></table>' +
(data.remarks ? '<div style="margin-top:16px;padding:12px 16px;background:#FFFBEB;border:1px solid #FDE68A;border-radius:6px;font-size:12px;"><strong style="font-size:11px;color:#92400E;">특이사항</strong><p style="margin:4px 0 0;white-space:pre-wrap;">' + data.remarks + '</p></div>' : '');
}
function openQuotationShowPreview() {
const data = @json($content);
document.getElementById('quotation-show-preview-content').innerHTML = buildQuotationShowPreviewHtml(data);
document.getElementById('quotation-show-preview-modal').style.display = '';
}
function closeQuotationShowPreview() { document.getElementById('quotation-show-preview-modal').style.display = 'none'; }
function printQuotationShowPreview() {
const content = document.getElementById('quotation-show-preview-content').innerHTML;
const win = window.open('', '_blank');
win.document.write('<html><head><title>견적서</title><style>body{font-family:"Pretendard","Malgun Gothic",sans-serif;padding:40px 48px;}@media print{body{padding:20px 30px;}}</style></head><body>' + content + '</body></html>');
win.document.close();
win.print();
}
</script>
@endpush

View File

@@ -119,6 +119,8 @@ class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition
@include('approvals.partials._delegation-show', ['content' => $approval->content])
@elseif(!empty($approval->content) && $approval->form?->code === 'board_minutes')
@include('approvals.partials._board-minutes-show', ['content' => $approval->content])
@elseif(!empty($approval->content) && $approval->form?->code === 'quotation')
@include('approvals.partials._quotation-show', ['content' => $approval->content])
@elseif($approval->body && preg_match('/<[a-z][\s\S]*>/i', $approval->body))
<div class="prose prose-sm max-w-none text-gray-700">
{!! strip_tags($approval->body, '<p><br><strong><b><em><i><u><s><del><h1><h2><h3><h4><h5><h6><ul><ol><li><blockquote><pre><code><a><span><div><table><thead><tbody><tr><th><td>') !!}