feat: [approvals] 품의서 5종 분리 (지출/계약체결/구매/출장/비용정산)

- 기존 단일 품의서(purchase_request)를 5가지 전문 양식으로 분리
- pr_expense: 지출품의서 (항목/금액/비고)
- pr_contract: 계약체결품의서 (계약상대방/기간/금액/조건)
- pr_purchase: 구매품의서 (품목/수량/단가/납품정보)
- pr_trip: 출장품의서 (일정표/경비내역)
- pr_settlement: 비용정산품의서 (사용일자/항목/지급방법)
- Alpine.js 단일 컴포넌트로 5종 동적 전환
- show/create/edit 모두 pr_ prefix 코드 자동 감지
This commit is contained in:
김보곤
2026-03-06 11:40:50 +09:00
parent cba91713ee
commit 406d47bc93
5 changed files with 964 additions and 225 deletions

View File

@@ -521,9 +521,13 @@ function switchFormMode(formId) {
isExpenseForm = true;
expenseContainer.style.display = '';
expenseLoadBtn.style.display = '';
} else if (code === 'purchase_request') {
} else if (code && code.startsWith('pr_')) {
isPurchaseRequestForm = true;
purchaseRequestContainer.style.display = '';
setTimeout(() => {
const prData = purchaseRequestContainer._x_dataStack?.[0];
if (prData) prData.setPrType(code);
}, 50);
} else if (leaveFormCodes.includes(code)) {
isLeaveForm = true;
leaveContainer.style.display = '';
@@ -564,7 +568,7 @@ function applyBodyTemplate(formId) {
switchFormMode(formId);
// 전용 폼이면 제목을 양식명으로 설정하고 body template 적용 건너뜀
if (isExpenseForm || isLeaveForm || isCertForm || isCareerCertForm || isAppointmentCertForm || isResignationForm) {
if (isExpenseForm || isPurchaseRequestForm || isLeaveForm || isCertForm || isCareerCertForm || isAppointmentCertForm || isResignationForm) {
const titleEl = document.getElementById('title');
const formSelect = document.getElementById('form_id');
titleEl.value = formSelect.options[formSelect.selectedIndex].text;

View File

@@ -446,9 +446,13 @@ function switchFormMode(formId) {
if (code === 'expense') {
isExpenseForm = true;
expenseContainer.style.display = '';
} else if (code === 'purchase_request') {
} else if (code && code.startsWith('pr_')) {
isPurchaseRequestForm = true;
purchaseRequestContainer.style.display = '';
setTimeout(() => {
const prData = purchaseRequestContainer._x_dataStack?.[0];
if (prData) prData.setPrType(code);
}, 50);
} else if (code === 'employment_cert') {
isCertForm = true;
certContainer.style.display = '';

View File

@@ -1,6 +1,6 @@
{{--
품의서 전용 (Alpine.js)
지출결의서에서 지출방법(카드/계좌) 관련 필드를 제거하고, 구매 목적 필드를 추가한 양식.
품의서 5 통합 (Alpine.js)
Types: pr_expense(지출), pr_contract(계약체결), pr_purchase(구매), pr_trip(출장), pr_settlement(비용정산)
Props:
$initialData (array|null) - 기존 content JSON (edit )
$initialFiles (array|null) - 기존 첨부파일 [{id, name, size, mime_type}] (edit )
@@ -15,25 +15,22 @@
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="mb-4 px-3 py-2 rounded-lg text-sm font-medium" :class="{
'bg-orange-50 text-orange-700 border border-orange-200': prType === 'pr_expense',
'bg-purple-50 text-purple-700 border border-purple-200': prType === 'pr_contract',
'bg-blue-50 text-blue-700 border border-blue-200': prType === 'pr_purchase',
'bg-green-50 text-green-700 border border-green-200': prType === 'pr_trip',
'bg-teal-50 text-teal-700 border border-teal-200': prType === 'pr_settlement',
}" x-text="prTypeLabels[prType] || '품의서'"></div>
{{-- 기본 정보 --}}
<div class="grid gap-4 mb-4" style="grid-template-columns: repeat(4, 1fr);">
{{-- 공통: 기본 정보 --}}
<div class="grid gap-4 mb-4" style="grid-template-columns: repeat(auto-fit, minmax(150px, 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="부서명"
@@ -46,108 +43,489 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-
</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>
{{-- ═══════════════════════════════════════════ --}}
{{-- 지출품의서 (pr_expense) --}}
{{-- ═══════════════════════════════════════════ --}}
<template x-if="prType === 'pr_expense'">
<div>
<div class="grid gap-4 mb-4" style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">지출항목</label>
<input type="text" x-model="formData.expense_category" 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="date" x-model="formData.usage_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>
<div class="mb-4">
<label class="block text-xs font-medium text-gray-600 mb-1">사용목적 <span class="text-red-500">*</span></label>
<textarea x-model="formData.purpose" 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-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: 200px;">항목</th>
<th class="px-2 py-2 text-right 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: 140px;">비고</th>
<th class="px-2 py-2" 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="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">
<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" 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">합계</td>
<td class="px-2 py-2 text-xs font-bold text-blue-700 text-right" x-text="formatMoney(totalAmount)"></td>
<td colspan="2" class="px-2 py-2">
<button type="button" @click="addItem()" class="text-xs text-blue-600 hover:text-blue-800 font-medium">+ 추가</button>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</template>
{{-- ═══════════════════════════════════════════ --}}
{{-- 계약체결품의서 (pr_contract) --}}
{{-- ═══════════════════════════════════════════ --}}
<template x-if="prType === 'pr_contract'">
<div>
<div class="grid gap-4 mb-4" style="grid-template-columns: 1fr 1fr;">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">계약상대방 <span class="text-red-500">*</span></label>
<div x-data="prVendorSearch(formData, 'contract_party')">
<input type="text" x-ref="vinput" :value="formData.contract_party"
@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-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">계약금액</label>
<input type="text" inputmode="numeric"
:value="formatMoney(formData.contract_amount)"
@input="formData.contract_amount = parseMoney($event.target.value); $event.target.value = formatMoney(formData.contract_amount)"
@focus="if(formData.contract_amount === 0) $event.target.value = ''"
@blur="if($event.target.value.trim() === '') { formData.contract_amount = 0; $event.target.value = '0'; }"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-right focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<div class="grid gap-4 mb-4" style="grid-template-columns: 1fr 1fr;">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">계약기간 시작</label>
<input type="date" x-model="formData.contract_start"
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.contract_end"
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-xs font-medium text-gray-600 mb-1">계약내용 <span class="text-red-500">*</span></label>
<textarea x-model="formData.contract_content" 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="mb-4">
<label class="block text-xs font-medium text-gray-600 mb-1">계약범위</label>
<textarea x-model="formData.contract_scope" 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-4">
<label class="block text-xs font-medium text-gray-600 mb-1">납품/서비스 내용</label>
<textarea x-model="formData.delivery_service" 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-4">
<label class="block text-xs font-medium text-gray-600 mb-1">대금지급조건</label>
<textarea x-model="formData.payment_terms" 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-4">
<label class="block text-xs font-medium text-gray-600 mb-1">기타 특약사항</label>
<textarea x-model="formData.special_terms" 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>
</template>
{{-- ═══════════════════════════════════════════ --}}
{{-- 구매품의서 (pr_purchase) --}}
{{-- ═══════════════════════════════════════════ --}}
<template x-if="prType === 'pr_purchase'">
<div>
<div class="mb-4">
<label class="block text-xs font-medium text-gray-600 mb-1">구매 목적 <span class="text-red-500">*</span></label>
<textarea x-model="formData.purpose" 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-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-right text-xs font-medium text-gray-600" style="min-width: 60px;">수량</th>
<th class="px-2 py-2 text-right text-xs font-medium text-gray-600" style="min-width: 100px;">단가</th>
<th class="px-2 py-2 text-right 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" 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">
<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" 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="2" class="px-2 py-2">
<button type="button" @click="addItem()" class="text-xs text-blue-600 hover:text-blue-800 font-medium">+ 추가</button>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
{{-- 납품 정보 --}}
<div class="grid gap-4 mb-4" style="grid-template-columns: 1fr 1fr 1fr;">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">납품업체</label>
<div x-data="prVendorSearch(formData, 'vendor')">
<input type="text" x-ref="vinput" :value="formData.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-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">납품예정일</label>
<input type="date" x-model="formData.delivery_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.delivery_place" 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>
</template>
{{-- ═══════════════════════════════════════════ --}}
{{-- 출장품의서 (pr_trip) --}}
{{-- ═══════════════════════════════════════════ --}}
<template x-if="prType === 'pr_trip'">
<div>
<div class="grid gap-4 mb-4" style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">출장자 <span class="text-red-500">*</span></label>
<input type="text" x-model="formData.traveler" 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">출장지 <span class="text-red-500">*</span></label>
<input type="text" x-model="formData.destination" 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="date" x-model="formData.trip_start"
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.trip_end"
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-xs font-medium text-gray-600 mb-1">업무내용</label>
<textarea x-model="formData.purpose" 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-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: 110px;">일자</th>
<th class="px-2 py-2 text-left text-xs font-medium text-gray-600" style="min-width: 140px;">행선지</th>
<th class="px-2 py-2 text-left text-xs font-medium text-gray-600" style="min-width: 200px;">업무내용</th>
<th class="px-2 py-2" style="width: 40px;"></th>
</tr>
</thead>
<tbody>
<template x-for="(sch, idx) in formData.schedules" :key="sch._key">
<tr class="border-t border-gray-100">
<td class="px-1 py-1">
<input type="date" x-model="sch.date"
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" x-model="sch.destination" 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" x-model="sch.task" 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="removeSchedule(idx)" class="text-red-400 hover:text-red-600 transition" x-show="formData.schedules.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 colspan="4" class="px-2 py-2">
<button type="button" @click="addSchedule()" class="text-xs text-blue-600 hover:text-blue-800 font-medium">+ 일정 추가</button>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
{{-- 출장 경비 --}}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">출장 경비</label>
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));">
<div>
<label class="block text-xs text-gray-500 mb-1">교통비</label>
<input type="text" inputmode="numeric"
:value="formatMoney(formData.trip_expenses.transport)"
@input="formData.trip_expenses.transport = parseMoney($event.target.value); $event.target.value = formatMoney(formData.trip_expenses.transport)"
@focus="if(formData.trip_expenses.transport === 0) $event.target.value = ''"
@blur="if($event.target.value.trim() === '') { formData.trip_expenses.transport = 0; $event.target.value = '0'; }"
class="w-full px-2 py-1.5 border border-gray-200 rounded text-sm text-right focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">숙박비</label>
<input type="text" inputmode="numeric"
:value="formatMoney(formData.trip_expenses.accommodation)"
@input="formData.trip_expenses.accommodation = parseMoney($event.target.value); $event.target.value = formatMoney(formData.trip_expenses.accommodation)"
@focus="if(formData.trip_expenses.accommodation === 0) $event.target.value = ''"
@blur="if($event.target.value.trim() === '') { formData.trip_expenses.accommodation = 0; $event.target.value = '0'; }"
class="w-full px-2 py-1.5 border border-gray-200 rounded text-sm text-right focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">식비</label>
<input type="text" inputmode="numeric"
:value="formatMoney(formData.trip_expenses.meal)"
@input="formData.trip_expenses.meal = parseMoney($event.target.value); $event.target.value = formatMoney(formData.trip_expenses.meal)"
@focus="if(formData.trip_expenses.meal === 0) $event.target.value = ''"
@blur="if($event.target.value.trim() === '') { formData.trip_expenses.meal = 0; $event.target.value = '0'; }"
class="w-full px-2 py-1.5 border border-gray-200 rounded text-sm text-right focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">기타</label>
<input type="text" inputmode="numeric"
:value="formatMoney(formData.trip_expenses.other)"
@input="formData.trip_expenses.other = parseMoney($event.target.value); $event.target.value = formatMoney(formData.trip_expenses.other)"
@focus="if(formData.trip_expenses.other === 0) $event.target.value = ''"
@blur="if($event.target.value.trim() === '') { formData.trip_expenses.other = 0; $event.target.value = '0'; }"
class="w-full px-2 py-1.5 border border-gray-200 rounded text-sm text-right focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1 font-semibold">합계</label>
<div class="px-2 py-1.5 bg-blue-50 border border-blue-200 rounded text-sm text-right font-bold text-blue-700" x-text="formatMoney(tripExpenseTotal)"></div>
</div>
</div>
</div>
</div>
</template>
{{-- ═══════════════════════════════════════════ --}}
{{-- 비용정산품의서 (pr_settlement) --}}
{{-- ═══════════════════════════════════════════ --}}
<template x-if="prType === 'pr_settlement'">
<div>
<div class="mb-4">
<label class="block text-xs font-medium text-gray-600 mb-1">정산 사유</label>
<textarea x-model="formData.purpose" 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-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: 110px;">사용일자</th>
<th class="px-2 py-2 text-left text-xs font-medium text-gray-600" style="min-width: 180px;">항목</th>
<th class="px-2 py-2 text-right 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: 120px;">비고</th>
<th class="px-2 py-2" 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="date" x-model="item.date"
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" 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="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">
<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" 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="2">합계</td>
<td class="px-2 py-2 text-xs font-bold text-blue-700 text-right" x-text="formatMoney(totalAmount)"></td>
<td colspan="2" class="px-2 py-2">
<button type="button" @click="addItem()" class="text-xs text-blue-600 hover:text-blue-800 font-medium">+ 추가</button>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
{{-- 지급방법 --}}
<div class="mb-4">
<label class="block text-xs font-medium text-gray-600 mb-2">지급방법</label>
<div class="flex gap-4">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="radio" x-model="formData.payment_method" value="corporate_card"
class="rounded-full border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="text-sm text-gray-700">법인카드 사용</span>
</label>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="radio" x-model="formData.payment_method" value="personal_advance"
class="rounded-full border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="text-sm text-gray-700">개인 선지출 정산</span>
</label>
</div>
</div>
</div>
</template>
{{-- 공통: 첨부서류 메모 --}}
<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)"
@@ -199,32 +577,94 @@ function purchaseRequestForm(initialData, authUserName, initialFiles) {
function makeItem(data) {
return {
_key: ++_keyCounter,
date: data?.date || '',
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 || '',
};
}
function makeSchedule(data) {
return {
_key: ++_keyCounter,
date: data?.date || '',
destination: data?.destination || '',
task: data?.task || '',
};
}
const items = (initialData?.items && initialData.items.length > 0)
? initialData.items.map(makeItem)
: [makeItem({})];
const schedules = (initialData?.schedules && initialData.schedules.length > 0)
? initialData.schedules.map(makeSchedule)
: [makeSchedule({})];
const today = new Date().toISOString().slice(0, 10);
return {
prType: initialData?.pr_type || 'pr_purchase',
prTypeLabels: {
pr_expense: '지출품의서',
pr_contract: '계약체결품의서',
pr_purchase: '구매품의서',
pr_trip: '출장품의서',
pr_settlement: '비용정산품의서',
},
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,
purpose: initialData?.purpose || '',
attachment_memo: initialData?.attachment_memo || '',
// pr_expense
expense_category: initialData?.expense_category || '',
usage_date: initialData?.usage_date || '',
// pr_contract
contract_party: initialData?.contract_party || '',
contract_party_id: initialData?.contract_party_id || null,
contract_party_biz_no: initialData?.contract_party_biz_no || '',
contract_content: initialData?.contract_content || '',
contract_start: initialData?.contract_start || '',
contract_end: initialData?.contract_end || '',
contract_amount: parseInt(initialData?.contract_amount) || 0,
contract_scope: initialData?.contract_scope || '',
delivery_service: initialData?.delivery_service || '',
payment_terms: initialData?.payment_terms || '',
special_terms: initialData?.special_terms || '',
// pr_purchase
vendor: initialData?.vendor || '',
vendor_id: initialData?.vendor_id || null,
vendor_biz_no: initialData?.vendor_biz_no || '',
delivery_date: initialData?.delivery_date || '',
delivery_place: initialData?.delivery_place || '',
// pr_trip
traveler: initialData?.traveler || authUserName,
destination: initialData?.destination || '',
trip_start: initialData?.trip_start || '',
trip_end: initialData?.trip_end || '',
schedules: schedules,
trip_expenses: {
transport: parseInt(initialData?.trip_expenses?.transport) || 0,
accommodation: parseInt(initialData?.trip_expenses?.accommodation) || 0,
meal: parseInt(initialData?.trip_expenses?.meal) || 0,
other: parseInt(initialData?.trip_expenses?.other) || 0,
},
// pr_settlement
payment_method: initialData?.payment_method || '',
// shared items
items: items,
},
isDragging: false,
@@ -237,6 +677,20 @@ function makeItem(data) {
return this.formData.items.reduce((sum, item) => sum + (parseInt(item.amount) || 0), 0);
},
get tripExpenseTotal() {
const e = this.formData.trip_expenses;
return (parseInt(e.transport) || 0) + (parseInt(e.accommodation) || 0) + (parseInt(e.meal) || 0) + (parseInt(e.other) || 0);
},
setPrType(type) {
this.prType = type;
// 타입 변경 시 items/schedules 초기화 (신규 작성 시만)
if (!initialData?.pr_type) {
this.formData.items = [makeItem({})];
this.formData.schedules = [makeSchedule({})];
}
},
addItem() {
this.formData.items.push(makeItem({}));
},
@@ -247,6 +701,16 @@ function makeItem(data) {
}
},
addSchedule() {
this.formData.schedules.push(makeSchedule({}));
},
removeSchedule(index) {
if (this.formData.schedules.length > 1) {
this.formData.schedules.splice(index, 1);
}
},
recalcAmount(item) {
const qty = parseInt(item.quantity) || 0;
const price = parseInt(item.unit_price) || 0;
@@ -272,25 +736,19 @@ function makeItem(data) {
},
handleDrop(event) {
const files = Array.from(event.dataTransfer.files);
this.uploadFiles(files);
this.uploadFiles(Array.from(event.dataTransfer.files));
},
handleFileSelect(event) {
const files = Array.from(event.target.files);
this.uploadFiles(files);
this.uploadFiles(Array.from(event.target.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;
}
if (f.size > maxSize) { showToast(`${f.name}: 20MB를 초과합니다.`, 'warning'); return false; }
return true;
});
if (valid.length === 0) return;
@@ -298,7 +756,6 @@ function makeItem(data) {
this.fileUploading = true;
this.fileProgress = 0;
let completed = 0;
for (const file of valid) {
this.fileUploadStatus = `${completed + 1}/${valid.length} 업로드 중...`;
try {
@@ -307,11 +764,8 @@ function makeItem(data) {
});
this.uploadedFiles.push(result);
completed++;
} catch (e) {
showToast(`${file.name} 업로드 실패`, 'error');
}
} catch (e) { showToast(`${file.name} 업로드 실패`, 'error'); }
}
this.fileProgress = 100;
this.fileUploadStatus = '완료';
setTimeout(() => { this.fileUploading = false; this.fileProgress = 0; }, 800);
@@ -319,9 +773,8 @@ function makeItem(data) {
uploadSingle(file, onProgress) {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('file', file);
const fd = new FormData();
fd.append('file', file);
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) onProgress(Math.round(e.loaded / e.total * 100));
@@ -329,15 +782,14 @@ function makeItem(data) {
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));
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);
xhr.send(fd);
});
},
@@ -346,35 +798,47 @@ function makeItem(data) {
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',
},
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, 'Accept': 'application/json' },
});
} catch (e) { /* 삭제 실패해도 목록에서는 제거 */ }
} catch (e) {}
this.uploadedFiles.splice(index, 1);
},
getFormData() {
return {
purpose: this.formData.purpose,
const base = {
pr_type: this.prType,
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,
purpose: this.formData.purpose,
attachment_memo: this.formData.attachment_memo,
};
const cleanItems = () => this.formData.items.map(item => ({
date: item.date || '',
description: item.description,
quantity: parseInt(item.quantity) || 0,
unit_price: parseInt(item.unit_price) || 0,
amount: parseInt(item.amount) || 0,
remark: item.remark,
}));
if (this.prType === 'pr_expense') {
return { ...base, expense_category: this.formData.expense_category, usage_date: this.formData.usage_date, items: cleanItems(), total_amount: this.totalAmount };
}
if (this.prType === 'pr_contract') {
return { ...base, contract_party: this.formData.contract_party, contract_party_id: this.formData.contract_party_id, contract_party_biz_no: this.formData.contract_party_biz_no, contract_content: this.formData.contract_content, contract_start: this.formData.contract_start, contract_end: this.formData.contract_end, contract_amount: parseInt(this.formData.contract_amount) || 0, contract_scope: this.formData.contract_scope, delivery_service: this.formData.delivery_service, payment_terms: this.formData.payment_terms, special_terms: this.formData.special_terms };
}
if (this.prType === 'pr_purchase') {
return { ...base, items: cleanItems(), total_amount: this.totalAmount, vendor: this.formData.vendor, vendor_id: this.formData.vendor_id, vendor_biz_no: this.formData.vendor_biz_no, delivery_date: this.formData.delivery_date, delivery_place: this.formData.delivery_place };
}
if (this.prType === 'pr_trip') {
return { ...base, traveler: this.formData.traveler, destination: this.formData.destination, trip_start: this.formData.trip_start, trip_end: this.formData.trip_end, schedules: this.formData.schedules.map(s => ({ date: s.date, destination: s.destination, task: s.task })), trip_expenses: { ...this.formData.trip_expenses }, trip_expense_total: this.tripExpenseTotal };
}
if (this.prType === 'pr_settlement') {
return { ...base, items: cleanItems(), total_amount: this.totalAmount, payment_method: this.formData.payment_method };
}
return base;
},
getFileIds() {
@@ -383,7 +847,7 @@ function makeItem(data) {
};
}
function prVendorSearch(item) {
function prVendorSearch(target, fieldName) {
let debounceTimer = null;
let dropdown = null;
let results = [];
@@ -399,7 +863,6 @@ function removeDropdown() {
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);
}
@@ -414,13 +877,13 @@ function renderDropdown(inputEl) {
}
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;
const bizInfo = [p.biz_no, p.ceo ? '대표: ' + p.ceo : ''].filter(Boolean).join(' ');
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' : ''}'">
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=''">
<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>`;
@@ -431,9 +894,9 @@ function renderDropdown(inputEl) {
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 || '';
target[fieldName] = results[idx].name;
target[fieldName + '_id'] = results[idx].id;
target[fieldName + '_biz_no'] = results[idx].biz_no || '';
inputEl.value = results[idx].name;
selected = true;
}
@@ -446,22 +909,19 @@ function renderDropdown(inputEl) {
return {
onInput(value) {
if (selected) { selected = false; return; }
item.vendor = value;
item.vendor_id = null;
item.vendor_biz_no = '';
target[fieldName] = value;
target[fieldName + '_id'] = null;
target[fieldName + '_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);
}
const val = target[fieldName];
if (val && val.length >= 1 && !dropdown) this.search(val);
},
async search(keyword) {
try {
const res = await fetch(`/barobill/tax-invoice/search-partners?keyword=${encodeURIComponent(keyword)}`);
@@ -470,9 +930,7 @@ function renderDropdown(inputEl) {
renderDropdown(this.$refs.vinput);
} catch (e) { results = []; removeDropdown(); }
},
close() { removeDropdown(); },
moveDown() {
if (!dropdown || results.length === 0) return;
clearTimeout(debounceTimer);
@@ -488,9 +946,9 @@ function renderDropdown(inputEl) {
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 || '';
target[fieldName] = p.name;
target[fieldName + '_id'] = p.id;
target[fieldName + '_biz_no'] = p.biz_no || '';
this.$refs.vinput.value = p.name;
selected = true;
removeDropdown();

View File

@@ -1,16 +1,34 @@
{{--
품의서 읽기전용 렌더링
품의서 5 읽기전용 렌더링
Props:
$content (array) - approvals.content JSON
$content (array) - approvals.content JSON (pr_type 포함)
--}}
@php
$prType = $content['pr_type'] ?? 'pr_purchase';
$prTypeLabels = [
'pr_expense' => '지출품의서',
'pr_contract' => '계약체결품의서',
'pr_purchase' => '구매품의서',
'pr_trip' => '출장품의서',
'pr_settlement' => '비용정산품의서',
];
@endphp
<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
{{-- 유형 뱃지 --}}
@php
$badgeClass = match($prType) {
'pr_expense' => 'bg-orange-50 text-orange-700 border-orange-200',
'pr_contract' => 'bg-purple-50 text-purple-700 border-purple-200',
'pr_purchase' => 'bg-blue-50 text-blue-700 border-blue-200',
'pr_trip' => 'bg-green-50 text-green-700 border-green-200',
'pr_settlement' => 'bg-teal-50 text-teal-700 border-teal-200',
default => 'bg-gray-50 text-gray-700 border-gray-200',
};
@endphp
<div class="px-3 py-1.5 rounded-lg text-sm font-medium border {{ $badgeClass }}" style="display: inline-block;">
{{ $prTypeLabels[$prType] ?? '품의서' }}
</div>
{{-- 기본 정보 --}}
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));">
@@ -18,10 +36,6 @@
<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>
@@ -32,44 +46,303 @@
</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>
{{-- ═══ 지출품의서 ═══ --}}
@if($prType === 'pr_expense')
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));">
@if(!empty($content['expense_category']))
<div>
<span class="text-xs text-gray-500">지출항목</span>
<div class="text-sm font-medium mt-0.5">{{ $content['expense_category'] }}</div>
</div>
@endif
@if(!empty($content['usage_date']))
<div>
<span class="text-xs text-gray-500">사용일자</span>
<div class="text-sm font-medium mt-0.5">{{ $content['usage_date'] }}</div>
</div>
@endif
</div>
@if(!empty($content['purpose']))
<div class="p-3 bg-orange-50 border border-orange-200 rounded-lg">
<span class="text-xs font-medium text-orange-700">사용목적</span>
<div class="text-sm text-gray-800 mt-1 whitespace-pre-wrap">{{ $content['purpose'] }}</div>
</div>
@endif
@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-left text-xs font-medium text-gray-600">비고</th>
</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>
</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 font-medium whitespace-nowrap">{{ number_format($item['amount'] ?? 0) }}</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">합계</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></td>
</tr>
</tfoot>
</table>
</div>
@endif
@endif
{{-- ═══ 계약체결품의서 ═══ --}}
@if($prType === 'pr_contract')
<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['contract_party'] ?? '-' }}</div>
</div>
<div>
<span class="text-xs text-gray-500">계약금액</span>
<div class="text-sm font-medium mt-0.5">{{ number_format($content['contract_amount'] ?? 0) }}</div>
</div>
@if(!empty($content['contract_start']) || !empty($content['contract_end']))
<div>
<span class="text-xs text-gray-500">계약기간</span>
<div class="text-sm font-medium mt-0.5">{{ $content['contract_start'] ?? '' }} ~ {{ $content['contract_end'] ?? '' }}</div>
</div>
@endif
</div>
@if(!empty($content['contract_content']))
<div class="p-3 bg-purple-50 border border-purple-200 rounded-lg">
<span class="text-xs font-medium text-purple-700">계약내용</span>
<div class="text-sm text-gray-800 mt-1 whitespace-pre-wrap">{{ $content['contract_content'] }}</div>
</div>
@endif
@php
$contractSections = [
['key' => 'contract_scope', 'label' => '계약범위'],
['key' => 'delivery_service', 'label' => '납품/서비스 내용'],
['key' => 'payment_terms', 'label' => '대금지급조건'],
['key' => 'special_terms', 'label' => '기타 특약사항'],
];
@endphp
@foreach($contractSections as $sec)
@if(!empty($content[$sec['key']]))
<div>
<span class="text-xs text-gray-500">{{ $sec['label'] }}</span>
<div class="text-sm text-gray-700 mt-0.5 whitespace-pre-wrap">{{ $content[$sec['key']] }}</div>
</div>
@endif
@endforeach
@endif
{{-- ═══ 구매품의서 ═══ --}}
@if($prType === 'pr_purchase')
@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
@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>
</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['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></td>
</tr>
</tfoot>
</table>
</div>
@endif
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));">
@if(!empty($content['vendor']))
<div>
<span class="text-xs text-gray-500">납품업체</span>
<div class="text-sm font-medium mt-0.5">{{ $content['vendor'] }}</div>
</div>
@endif
@if(!empty($content['delivery_date']))
<div>
<span class="text-xs text-gray-500">납품예정일</span>
<div class="text-sm font-medium mt-0.5">{{ $content['delivery_date'] }}</div>
</div>
@endif
@if(!empty($content['delivery_place']))
<div>
<span class="text-xs text-gray-500">납품장소</span>
<div class="text-sm font-medium mt-0.5">{{ $content['delivery_place'] }}</div>
</div>
@endif
</div>
@endif
{{-- 첨부서류 --}}
{{-- ═══ 출장품의서 ═══ --}}
@if($prType === 'pr_trip')
<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['traveler'] ?? '-' }}</div>
</div>
<div>
<span class="text-xs text-gray-500">출장지</span>
<div class="text-sm font-medium mt-0.5">{{ $content['destination'] ?? '-' }}</div>
</div>
@if(!empty($content['trip_start']) || !empty($content['trip_end']))
<div>
<span class="text-xs text-gray-500">출장기간</span>
<div class="text-sm font-medium mt-0.5">{{ $content['trip_start'] ?? '' }} ~ {{ $content['trip_end'] ?? '' }}</div>
</div>
@endif
</div>
@if(!empty($content['purpose']))
<div class="p-3 bg-green-50 border border-green-200 rounded-lg">
<span class="text-xs font-medium text-green-700">업무내용</span>
<div class="text-sm text-gray-800 mt-1 whitespace-pre-wrap">{{ $content['purpose'] }}</div>
</div>
@endif
@if(!empty($content['schedules']))
<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-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['schedules'] as $sch)
<tr class="border-t border-gray-100">
<td class="px-3 py-2 text-xs text-gray-700 whitespace-nowrap">{{ $sch['date'] ?? '' }}</td>
<td class="px-3 py-2 text-xs text-gray-700">{{ $sch['destination'] ?? '' }}</td>
<td class="px-3 py-2 text-xs text-gray-700">{{ $sch['task'] ?? '' }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@if(!empty($content['trip_expenses']))
@php $te = $content['trip_expenses']; @endphp
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));">
<div class="text-center p-2 bg-gray-50 rounded-lg">
<span class="text-xs text-gray-500">교통비</span>
<div class="text-sm font-medium mt-0.5">{{ number_format($te['transport'] ?? 0) }}</div>
</div>
<div class="text-center p-2 bg-gray-50 rounded-lg">
<span class="text-xs text-gray-500">숙박비</span>
<div class="text-sm font-medium mt-0.5">{{ number_format($te['accommodation'] ?? 0) }}</div>
</div>
<div class="text-center p-2 bg-gray-50 rounded-lg">
<span class="text-xs text-gray-500">식비</span>
<div class="text-sm font-medium mt-0.5">{{ number_format($te['meal'] ?? 0) }}</div>
</div>
<div class="text-center p-2 bg-gray-50 rounded-lg">
<span class="text-xs text-gray-500">기타</span>
<div class="text-sm font-medium mt-0.5">{{ number_format($te['other'] ?? 0) }}</div>
</div>
<div class="text-center p-2 bg-blue-50 border border-blue-200 rounded-lg">
<span class="text-xs text-blue-600 font-medium">합계</span>
<div class="text-sm font-bold text-blue-700 mt-0.5">{{ number_format($content['trip_expense_total'] ?? 0) }}</div>
</div>
</div>
@endif
@endif
{{-- ═══ 비용정산품의서 ═══ --}}
@if($prType === 'pr_settlement')
@if(!empty($content['purpose']))
<div class="p-3 bg-teal-50 border border-teal-200 rounded-lg">
<span class="text-xs font-medium text-teal-700">정산 사유</span>
<div class="text-sm text-gray-800 mt-1 whitespace-pre-wrap">{{ $content['purpose'] }}</div>
</div>
@endif
@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-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-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 whitespace-nowrap">{{ $item['date'] ?? '' }}</td>
<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 font-medium whitespace-nowrap">{{ number_format($item['amount'] ?? 0) }}</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="2">합계</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></td>
</tr>
</tfoot>
</table>
</div>
@endif
@if(!empty($content['payment_method']))
<div>
<span class="text-xs text-gray-500">지급방법</span>
<div class="text-sm font-medium mt-0.5">
@if($content['payment_method'] === 'corporate_card')
법인카드 사용
@elseif($content['payment_method'] === 'personal_advance')
개인 선지출 정산
@else
{{ $content['payment_method'] }}
@endif
</div>
</div>
@endif
@endif
{{-- 공통: 첨부서류 메모 --}}
@if(!empty($content['attachment_memo']))
<div>
<span class="text-xs text-gray-500">첨부서류</span>
@@ -77,7 +350,7 @@
</div>
@endif
{{-- 첨부파일 --}}
{{-- 공통: 첨부파일 --}}
@if(!empty($approval->attachments))
<div>
<span class="text-xs text-gray-500">첨부파일</span>

View File

@@ -80,7 +80,7 @@ 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 === 'purchase_request')
@if(!empty($approval->content) && str_starts_with($approval->form?->code ?? '', 'pr_'))
@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])