feat: [approvals] 품의서 양식 추가

- 품의서(purchase_request) 전용 폼/뷰 partial 추가
- 지출결의서 기반, 지출방법(카드/계좌) 제거, 구매목적 필드 추가
- 테이블: 품명/수량/단가/금액/업체명/비고 (수량×단가 자동계산)
- 희망 납기일, 요청부서/요청자, 첨부파일 지원
- create/edit/show 모두 분기 처리
This commit is contained in:
김보곤
2026-03-06 11:28:15 +09:00
parent e02f1daf0a
commit cba91713ee
5 changed files with 647 additions and 3 deletions

View File

@@ -122,6 +122,11 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-
'accounts' => $accounts ?? collect(),
])
{{-- 품의서 전용 --}}
@include('approvals.partials._purchase-request-form', [
'initialData' => [],
])
{{-- 액션 버튼 --}}
<div class="border-t pt-4 mt-4 flex gap-2 justify-end">
<button onclick="saveApproval('draft')"
@@ -236,6 +241,7 @@ class="p-1 text-gray-400 hover:text-gray-600 transition">
const formCodes = @json($forms->pluck('code', 'id'));
const linesData = @json($lines);
let isExpenseForm = false;
let isPurchaseRequestForm = false;
let isLeaveForm = false;
let isCertForm = false;
let isCareerCertForm = false;
@@ -482,6 +488,7 @@ function applyQuickLine(lineId) {
function switchFormMode(formId) {
const code = formCodes[formId];
const expenseContainer = document.getElementById('expense-form-container');
const purchaseRequestContainer = document.getElementById('purchase-request-form-container');
const leaveContainer = document.getElementById('leave-form-container');
const certContainer = document.getElementById('cert-form-container');
const careerCertContainer = document.getElementById('career-cert-form-container');
@@ -494,6 +501,7 @@ function switchFormMode(formId) {
// 모두 숨기기
expenseContainer.style.display = 'none';
purchaseRequestContainer.style.display = 'none';
leaveContainer.style.display = 'none';
certContainer.style.display = 'none';
careerCertContainer.style.display = 'none';
@@ -502,6 +510,7 @@ function switchFormMode(formId) {
expenseLoadBtn.style.display = 'none';
bodyArea.style.display = 'none';
isExpenseForm = false;
isPurchaseRequestForm = false;
isLeaveForm = false;
isCertForm = false;
isCareerCertForm = false;
@@ -512,6 +521,9 @@ function switchFormMode(formId) {
isExpenseForm = true;
expenseContainer.style.display = '';
expenseLoadBtn.style.display = '';
} else if (code === 'purchase_request') {
isPurchaseRequestForm = true;
purchaseRequestContainer.style.display = '';
} else if (leaveFormCodes.includes(code)) {
isLeaveForm = true;
leaveContainer.style.display = '';
@@ -639,6 +651,13 @@ function applyBodyTemplate(formId) {
attachmentFileIds = expenseEl._x_dataStack[0].getFileIds();
}
formBody = null;
} else if (isPurchaseRequestForm) {
const prEl = document.getElementById('purchase-request-form-container');
if (prEl && prEl._x_dataStack) {
formContent = prEl._x_dataStack[0].getFormData();
attachmentFileIds = prEl._x_dataStack[0].getFileIds();
}
formBody = null;
} else if (isLeaveForm) {
const leaveData = getLeaveFormData();

View File

@@ -155,6 +155,12 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-
'accounts' => $accounts ?? collect(),
])
{{-- 품의서 전용 --}}
@include('approvals.partials._purchase-request-form', [
'initialData' => $approval->content ?? [],
'initialFiles' => $existingFiles,
])
{{-- 액션 버튼 --}}
<div class="border-t pt-4 mt-4 flex gap-2 justify-end">
<button onclick="updateApproval('save')"
@@ -259,6 +265,7 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm fon
const formCodes = @json($forms->pluck('code', 'id'));
const linesData = @json($lines);
let isExpenseForm = false;
let isPurchaseRequestForm = false;
let isCertForm = false;
function escapeHtml(str) {
@@ -424,18 +431,24 @@ function applyQuickLine(lineId) {
function switchFormMode(formId) {
const code = formCodes[formId];
const expenseContainer = document.getElementById('expense-form-container');
const purchaseRequestContainer = document.getElementById('purchase-request-form-container');
const certContainer = document.getElementById('cert-form-container');
const bodyArea = document.getElementById('body-area');
expenseContainer.style.display = 'none';
purchaseRequestContainer.style.display = 'none';
certContainer.style.display = 'none';
bodyArea.style.display = 'none';
isExpenseForm = false;
isPurchaseRequestForm = false;
isCertForm = false;
if (code === 'expense') {
isExpenseForm = true;
expenseContainer.style.display = '';
} else if (code === 'purchase_request') {
isPurchaseRequestForm = true;
purchaseRequestContainer.style.display = '';
} else if (code === 'employment_cert') {
isCertForm = true;
certContainer.style.display = '';
@@ -451,7 +464,7 @@ function applyBodyTemplate(formId) {
switchFormMode(formId);
// 전용 폼이면 제목만 자동 설정하고 body template 적용 건너뜀
if (isExpenseForm || isCertForm) {
if (isExpenseForm || isPurchaseRequestForm || isCertForm) {
const titleEl = document.getElementById('title');
if (!titleEl.value.trim()) {
const formSelect = document.getElementById('form_id');
@@ -538,7 +551,7 @@ function applyBodyTemplate(formId) {
}
// 전용 폼이 아닌 경우에만 Quill 편집기 자동 활성화
if (!isExpenseForm && !isCertForm) {
if (!isExpenseForm && !isPurchaseRequestForm && !isCertForm) {
const existingBody = document.getElementById('body').value;
if (/<[a-z][\s\S]*>/i.test(existingBody)) {
document.getElementById('useEditor').checked = true;
@@ -581,6 +594,13 @@ function applyBodyTemplate(formId) {
attachmentFileIds = expenseEl._x_dataStack[0].getFileIds();
}
formBody = null;
} else if (isPurchaseRequestForm) {
const prEl = document.getElementById('purchase-request-form-container');
if (prEl && prEl._x_dataStack) {
formContent = prEl._x_dataStack[0].getFormData();
attachmentFileIds = prEl._x_dataStack[0].getFileIds();
}
formBody = null;
} else if (isCertForm) {
const purpose = getCertPurpose();
if (!purpose) {

View File

@@ -0,0 +1,502 @@
{{--
품의서 전용 (Alpine.js)
지출결의서에서 지출방법(카드/계좌) 관련 필드를 제거하고, 구매 목적 필드를 추가한 양식.
Props:
$initialData (array|null) - 기존 content JSON (edit )
$initialFiles (array|null) - 기존 첨부파일 [{id, name, size, mime_type}] (edit )
--}}
@php
$initialData = $initialData ?? [];
$initialFiles = $initialFiles ?? [];
$userName = auth()->user()->name ?? '';
@endphp
<div id="purchase-request-form-container" style="display: none;"
x-data="purchaseRequestForm({{ json_encode($initialData) }}, '{{ $userName }}', {{ json_encode($initialFiles) }})"
x-cloak>
{{-- 구매 목적 --}}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">구매 목적 <span class="text-red-500">*</span></label>
<textarea x-model="formData.purpose" 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 class="grid gap-4 mb-4" style="grid-template-columns: repeat(4, 1fr);">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">작성일자</label>
<input type="date" x-model="formData.write_date"
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>
<label class="block text-xs font-medium text-gray-600 mb-1">희망 납기일</label>
<input type="date" x-model="formData.desired_date"
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>
<label class="block text-xs font-medium text-gray-600 mb-1">요청부서</label>
<input type="text" x-model="formData.department" 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>
<label class="block text-xs font-medium text-gray-600 mb-1">요청자</label>
<input type="text" x-model="formData.writer_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>
{{-- 내역 테이블 --}}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">구매 내역</label>
<div class="overflow-x-auto border border-gray-200 rounded-lg">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-2 py-2 text-left text-xs font-medium text-gray-600" style="min-width: 160px;">품명</th>
<th class="px-2 py-2 text-left text-xs font-medium text-gray-600 whitespace-nowrap" style="min-width: 60px;">수량</th>
<th class="px-2 py-2 text-right text-xs font-medium text-gray-600 whitespace-nowrap" style="min-width: 100px;">단가</th>
<th class="px-2 py-2 text-right text-xs font-medium text-gray-600 whitespace-nowrap" style="min-width: 120px;">금액</th>
<th class="px-2 py-2 text-left text-xs font-medium text-gray-600" style="min-width: 120px;">업체명</th>
<th class="px-2 py-2 text-left text-xs font-medium text-gray-600" style="min-width: 100px;">비고</th>
<th class="px-2 py-2 text-center text-xs font-medium text-gray-600" style="width: 40px;"></th>
</tr>
</thead>
<tbody>
<template x-for="(item, index) in formData.items" :key="item._key">
<tr class="border-t border-gray-100">
<td class="px-1 py-1">
<input type="text" x-model="item.description" placeholder="품명/규격"
class="w-full px-2 py-1.5 border border-gray-200 rounded text-xs focus:outline-none focus:ring-1 focus:ring-blue-500">
</td>
<td class="px-1 py-1">
<input type="text" inputmode="numeric"
:value="item.quantity || ''"
@input="item.quantity = parseInt($event.target.value.replace(/[^0-9]/g,'')) || 0; recalcAmount(item)"
class="w-full px-2 py-1.5 border border-gray-200 rounded text-xs 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)"
@input="item.unit_price = parseMoney($event.target.value); $event.target.value = formatMoney(item.unit_price); recalcAmount(item)"
@focus="if(item.unit_price === 0) $event.target.value = ''"
@blur="if($event.target.value.trim() === '') { item.unit_price = 0; $event.target.value = '0'; }"
class="w-full px-2 py-1.5 border border-gray-200 rounded text-xs 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.amount)"
@input="item.amount = parseMoney($event.target.value); $event.target.value = formatMoney(item.amount)"
@focus="if(item.amount === 0) $event.target.value = ''"
@blur="if($event.target.value.trim() === '') { item.amount = 0; $event.target.value = '0'; }"
class="w-full px-2 py-1.5 border border-gray-200 rounded text-xs text-right focus:outline-none focus:ring-1 focus:ring-blue-500">
</td>
<td class="px-1 py-1">
<div x-data="prVendorSearch(item)">
<input type="text" x-ref="vinput" :value="item.vendor"
@input="onInput($event.target.value)"
@focus="onFocus()"
@keydown.escape="close()"
@keydown.arrow-down.prevent="moveDown()"
@keydown.arrow-up.prevent="moveUp()"
@keydown.enter.prevent="selectHighlighted()"
@blur="setTimeout(() => close(), 200)"
placeholder="거래처 검색"
class="w-full px-2 py-1.5 border border-gray-200 rounded text-xs focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
</td>
<td class="px-1 py-1">
<input type="text" x-model="item.remark" placeholder="비고"
class="w-full px-2 py-1.5 border border-gray-200 rounded text-xs focus:outline-none focus:ring-1 focus:ring-blue-500">
</td>
<td class="px-1 py-1 text-center">
<button type="button" @click="removeItem(index)"
class="text-red-400 hover:text-red-600 transition" title="삭제"
x-show="formData.items.length > 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</td>
</tr>
</template>
</tbody>
<tfoot class="bg-gray-50 border-t border-gray-200">
<tr>
<td class="px-2 py-2 text-xs font-semibold text-gray-700 text-right" colspan="3">합계</td>
<td class="px-2 py-2 text-xs font-bold text-blue-700 text-right" x-text="formatMoney(totalAmount)"></td>
<td colspan="3" class="px-2 py-2">
<button type="button" @click="addItem()"
class="text-xs text-blue-600 hover:text-blue-800 font-medium transition">
+ 추가
</button>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
{{-- 첨부서류 메모 --}}
<div class="mb-4">
<label class="block text-xs font-medium text-gray-600 mb-1">첨부서류</label>
<textarea x-model="formData.attachment_memo" rows="2" 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 class="mb-2">
<label class="block text-xs font-medium text-gray-600 mb-1">첨부파일</label>
<div @dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="isDragging = false; handleDrop($event)"
:class="isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300 bg-gray-50'"
class="border-2 border-dashed rounded-lg p-4 text-center transition-colors cursor-pointer"
@click="$refs.prFileInput.click()">
<input type="file" x-ref="prFileInput" @change="handleFileSelect($event)" multiple class="hidden"
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.jpg,.jpeg,.png,.gif,.zip,.rar">
<div class="flex items-center justify-center gap-2 text-sm text-gray-500">
<svg class="w-5 h-5" :class="isDragging ? 'text-blue-500' : 'text-gray-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
<span>파일을 드래그하거나 <span class="text-blue-600 font-medium">클릭하여 선택</span> (최대 20MB)</span>
</div>
</div>
<div x-show="fileUploading" class="mt-2">
<div class="flex items-center gap-2">
<div class="flex-1 bg-gray-200 rounded-full h-1.5">
<div class="h-full bg-blue-600 rounded-full transition-all" :style="'width:' + fileProgress + '%'"></div>
</div>
<span class="text-xs text-gray-500 whitespace-nowrap" x-text="fileUploadStatus"></span>
</div>
</div>
<div x-show="uploadedFiles.length > 0" class="mt-2 space-y-1">
<template x-for="(f, idx) in uploadedFiles" :key="f.id">
<div class="flex items-center gap-2 px-3 py-2 bg-gray-50 rounded-lg border border-gray-200 text-xs">
<svg class="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/>
</svg>
<span class="flex-1 truncate text-gray-700" x-text="f.name"></span>
<span class="text-gray-400 shrink-0" x-text="formatFileSize(f.size)"></span>
<button type="button" @click="removeFile(idx)" class="text-red-400 hover:text-red-600 shrink-0" title="삭제">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</template>
</div>
</div>
</div>
<script>
function purchaseRequestForm(initialData, authUserName, initialFiles) {
let _keyCounter = 0;
function makeItem(data) {
return {
_key: ++_keyCounter,
description: data?.description || '',
quantity: parseInt(data?.quantity) || 0,
unit_price: parseInt(data?.unit_price) || 0,
amount: parseInt(data?.amount) || 0,
vendor: data?.vendor || '',
vendor_id: data?.vendor_id || null,
vendor_biz_no: data?.vendor_biz_no || '',
remark: data?.remark || '',
};
}
const items = (initialData?.items && initialData.items.length > 0)
? initialData.items.map(makeItem)
: [makeItem({})];
const today = new Date().toISOString().slice(0, 10);
return {
formData: {
purpose: initialData?.purpose || '',
write_date: initialData?.write_date || today,
desired_date: initialData?.desired_date || '',
department: initialData?.department || '',
writer_name: initialData?.writer_name || authUserName,
items: items,
attachment_memo: initialData?.attachment_memo || '',
},
isDragging: false,
fileUploading: false,
fileProgress: 0,
fileUploadStatus: '',
uploadedFiles: initialFiles || [],
get totalAmount() {
return this.formData.items.reduce((sum, item) => sum + (parseInt(item.amount) || 0), 0);
},
addItem() {
this.formData.items.push(makeItem({}));
},
removeItem(index) {
if (this.formData.items.length > 1) {
this.formData.items.splice(index, 1);
}
},
recalcAmount(item) {
const qty = parseInt(item.quantity) || 0;
const price = parseInt(item.unit_price) || 0;
if (qty > 0 && price > 0) {
item.amount = qty * price;
}
},
formatMoney(value) {
const num = parseInt(value) || 0;
return num === 0 ? '0' : num.toLocaleString('ko-KR');
},
parseMoney(str) {
return parseInt(String(str).replace(/[^0-9-]/g, '')) || 0;
},
formatFileSize(bytes) {
if (!bytes) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
},
handleDrop(event) {
const files = Array.from(event.dataTransfer.files);
this.uploadFiles(files);
},
handleFileSelect(event) {
const files = Array.from(event.target.files);
this.uploadFiles(files);
event.target.value = '';
},
async uploadFiles(files) {
if (this.fileUploading || files.length === 0) return;
const maxSize = 20 * 1024 * 1024;
const valid = files.filter(f => {
if (f.size > maxSize) {
showToast(`${f.name}: 20MB를 초과합니다.`, 'warning');
return false;
}
return true;
});
if (valid.length === 0) return;
this.fileUploading = true;
this.fileProgress = 0;
let completed = 0;
for (const file of valid) {
this.fileUploadStatus = `${completed + 1}/${valid.length} 업로드 중...`;
try {
const result = await this.uploadSingle(file, (p) => {
this.fileProgress = Math.round(((completed + p / 100) / valid.length) * 100);
});
this.uploadedFiles.push(result);
completed++;
} catch (e) {
showToast(`${file.name} 업로드 실패`, 'error');
}
}
this.fileProgress = 100;
this.fileUploadStatus = '완료';
setTimeout(() => { this.fileUploading = false; this.fileProgress = 0; }, 800);
},
uploadSingle(file, onProgress) {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) onProgress(Math.round(e.loaded / e.total * 100));
});
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
const res = JSON.parse(xhr.responseText);
if (res.success) resolve(res.data);
else reject(new Error(res.message));
} else { reject(new Error('업로드 실패')); }
};
xhr.onerror = () => reject(new Error('네트워크 오류'));
xhr.open('POST', '/api/admin/approvals/upload-file');
xhr.setRequestHeader('X-CSRF-TOKEN', document.querySelector('meta[name="csrf-token"]').content);
xhr.setRequestHeader('Accept', 'application/json');
xhr.send(formData);
});
},
async removeFile(index) {
const file = this.uploadedFiles[index];
try {
await fetch(`/api/admin/approvals/files/${file.id}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
});
} catch (e) { /* 삭제 실패해도 목록에서는 제거 */ }
this.uploadedFiles.splice(index, 1);
},
getFormData() {
return {
purpose: this.formData.purpose,
write_date: this.formData.write_date,
desired_date: this.formData.desired_date,
department: this.formData.department,
writer_name: this.formData.writer_name,
items: this.formData.items.map(item => ({
description: item.description,
quantity: parseInt(item.quantity) || 0,
unit_price: parseInt(item.unit_price) || 0,
amount: parseInt(item.amount) || 0,
vendor: item.vendor,
vendor_id: item.vendor_id || null,
vendor_biz_no: item.vendor_biz_no || '',
remark: item.remark,
})),
total_amount: this.totalAmount,
attachment_memo: this.formData.attachment_memo,
};
},
getFileIds() {
return this.uploadedFiles.map(f => f.id);
},
};
}
function prVendorSearch(item) {
let debounceTimer = null;
let dropdown = null;
let results = [];
let highlighted = -1;
let selected = false;
function removeDropdown() {
if (dropdown) { dropdown.remove(); dropdown = null; }
results = [];
highlighted = -1;
}
function renderDropdown(inputEl) {
if (!dropdown) {
dropdown = document.createElement('div');
dropdown.className = 'pr-vendor-search-dropdown';
dropdown.style.cssText = 'position:fixed;z-index:99999;background:#fff;border:1px solid #e5e7eb;border-radius:8px;box-shadow:0 10px 25px rgba(0,0,0,0.15);max-height:220px;overflow-y:auto;min-width:220px;';
document.body.appendChild(dropdown);
}
const rect = inputEl.getBoundingClientRect();
dropdown.style.top = rect.bottom + 2 + 'px';
dropdown.style.left = rect.left + 'px';
dropdown.style.width = rect.width + 'px';
if (results.length === 0) {
dropdown.innerHTML = '<div style="padding:8px 12px;font-size:12px;color:#9ca3af;">검색 결과 없음</div>';
return;
}
dropdown.innerHTML = results.map((p, i) => {
const bg = i === highlighted ? 'background:#eff6ff;' : '';
const bizInfo = [p.biz_no, p.ceo ? '대표: ' + p.ceo : ''].filter(Boolean).join(' ');
const d = document.createElement('div');
d.textContent = p.name;
const safeName = d.innerHTML;
d.textContent = bizInfo;
const safeBiz = d.innerHTML;
return `<div data-idx="${i}" style="padding:6px 8px;cursor:pointer;border-bottom:1px solid #f9fafb;${bg}" onmouseenter="this.style.background='#eff6ff'" onmouseleave="this.style.background='${i === highlighted ? '#eff6ff' : ''}'">
<div style="font-size:12px;font-weight:500;color:#1f2937;">${safeName}</div>
${safeBiz ? '<div style="font-size:11px;color:#9ca3af;margin-top:1px;">' + safeBiz + '</div>' : ''}
</div>`;
}).join('');
dropdown.querySelectorAll('[data-idx]').forEach(el => {
el.addEventListener('mousedown', (e) => {
e.preventDefault();
const idx = parseInt(el.dataset.idx);
if (results[idx]) {
item.vendor = results[idx].name;
item.vendor_id = results[idx].id;
item.vendor_biz_no = results[idx].biz_no || '';
inputEl.value = results[idx].name;
selected = true;
}
removeDropdown();
inputEl.blur();
});
});
}
return {
onInput(value) {
if (selected) { selected = false; return; }
item.vendor = value;
item.vendor_id = null;
item.vendor_biz_no = '';
highlighted = -1;
clearTimeout(debounceTimer);
if (value.length < 1) { removeDropdown(); return; }
debounceTimer = setTimeout(() => this.search(value), 250);
},
onFocus() {
if (selected) return;
if (item.vendor && item.vendor.length >= 1 && !dropdown) {
this.search(item.vendor);
}
},
async search(keyword) {
try {
const res = await fetch(`/barobill/tax-invoice/search-partners?keyword=${encodeURIComponent(keyword)}`);
results = await res.json();
highlighted = -1;
renderDropdown(this.$refs.vinput);
} catch (e) { results = []; removeDropdown(); }
},
close() { removeDropdown(); },
moveDown() {
if (!dropdown || results.length === 0) return;
clearTimeout(debounceTimer);
highlighted = (highlighted + 1) % results.length;
renderDropdown(this.$refs.vinput);
},
moveUp() {
if (!dropdown || results.length === 0) return;
clearTimeout(debounceTimer);
highlighted = highlighted <= 0 ? results.length - 1 : highlighted - 1;
renderDropdown(this.$refs.vinput);
},
selectHighlighted() {
if (highlighted >= 0 && highlighted < results.length) {
const p = results[highlighted];
item.vendor = p.name;
item.vendor_id = p.id;
item.vendor_biz_no = p.biz_no || '';
this.$refs.vinput.value = p.name;
selected = true;
removeDropdown();
this.$refs.vinput.blur();
}
},
};
}
</script>

View File

@@ -0,0 +1,101 @@
{{--
품의서 읽기전용 렌더링
Props:
$content (array) - approvals.content JSON
--}}
<div class="space-y-4">
{{-- 구매 목적 --}}
@if(!empty($content['purpose']))
<div class="p-3 bg-blue-50 border border-blue-200 rounded-lg">
<span class="text-xs font-medium text-blue-700">구매 목적</span>
<div class="text-sm text-gray-800 mt-1 whitespace-pre-wrap">{{ $content['purpose'] }}</div>
</div>
@endif
{{-- 기본 정보 --}}
<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['write_date'] ?? '-' }}</div>
</div>
<div>
<span class="text-xs text-gray-500">희망 납기일</span>
<div class="text-sm font-medium mt-0.5">{{ $content['desired_date'] ?? '-' }}</div>
</div>
<div>
<span class="text-xs text-gray-500">요청부서</span>
<div class="text-sm font-medium mt-0.5">{{ $content['department'] ?? '-' }}</div>
</div>
<div>
<span class="text-xs text-gray-500">요청자</span>
<div class="text-sm font-medium mt-0.5">{{ $content['writer_name'] ?? '-' }}</div>
</div>
</div>
{{-- 내역 테이블 --}}
@if(!empty($content['items']))
<div class="overflow-x-auto border border-gray-200 rounded-lg">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-600">품명</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-600">수량</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-600">단가</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-600">금액</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-600">업체명</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-600">비고</th>
</tr>
</thead>
<tbody>
@foreach($content['items'] as $item)
<tr class="border-t border-gray-100">
<td class="px-3 py-2 text-xs text-gray-700">{{ $item['description'] ?? '' }}</td>
<td class="px-3 py-2 text-xs text-gray-700 text-right">{{ $item['quantity'] ?? '' }}</td>
<td class="px-3 py-2 text-xs text-gray-700 text-right whitespace-nowrap">{{ number_format($item['unit_price'] ?? 0) }}</td>
<td class="px-3 py-2 text-xs text-gray-700 text-right font-medium whitespace-nowrap">{{ number_format($item['amount'] ?? 0) }}</td>
<td class="px-3 py-2 text-xs text-gray-700">{{ $item['vendor'] ?? '' }}</td>
<td class="px-3 py-2 text-xs text-gray-700">{{ $item['remark'] ?? '' }}</td>
</tr>
@endforeach
</tbody>
<tfoot class="bg-gray-50 border-t border-gray-200">
<tr>
<td class="px-3 py-2 text-xs font-semibold text-gray-700 text-right" colspan="3">합계</td>
<td class="px-3 py-2 text-xs font-bold text-blue-700 text-right whitespace-nowrap">{{ number_format($content['total_amount'] ?? 0) }}</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</div>
@endif
{{-- 첨부서류 --}}
@if(!empty($content['attachment_memo']))
<div>
<span class="text-xs text-gray-500">첨부서류</span>
<div class="text-sm text-gray-700 mt-0.5 whitespace-pre-wrap">{{ $content['attachment_memo'] }}</div>
</div>
@endif
{{-- 첨부파일 --}}
@if(!empty($approval->attachments))
<div>
<span class="text-xs text-gray-500">첨부파일</span>
<div class="mt-1 space-y-1">
@foreach($approval->attachments as $file)
<div class="flex items-center gap-2 text-sm">
<svg class="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/>
</svg>
<a href="{{ route('api.admin.approvals.download-file', $file['id']) }}" class="text-blue-600 hover:underline" target="_blank">
{{ $file['name'] ?? '파일' }}
</a>
<span class="text-xs text-gray-400">
{{ isset($file['size']) ? number_format($file['size'] / 1024, 1) . 'KB' : '' }}
</span>
</div>
@endforeach
</div>
</div>
@endif
</div>

View File

@@ -80,7 +80,9 @@ class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition
<div class="border-t pt-4">
<h2 class="text-lg font-semibold text-gray-800 mb-2">{{ $approval->title }}</h2>
@if(!empty($approval->content) && $approval->form?->code === 'expense')
@if(!empty($approval->content) && $approval->form?->code === 'purchase_request')
@include('approvals.partials._purchase-request-show', ['content' => $approval->content])
@elseif(!empty($approval->content) && $approval->form?->code === 'expense')
@include('approvals.partials._expense-show', ['content' => $approval->content])
@elseif(!empty($approval->content) && $approval->form?->code === 'employment_cert')
@include('approvals.partials._certificate-show', ['content' => $approval->content])