Files
sam-manage/resources/views/approvals/partials/_expense-form.blade.php
김보곤 76aabebc6e fix: [approval] 거래처 검색 키보드 방향키 내비게이션 버그 수정
- moveDown/moveUp 시 debounce 타이머 클리어하여 search 재실행 방지
2026-03-05 11:16:21 +09:00

716 lines
39 KiB
PHP

{{--
지출결의서 전용 (Alpine.js)
Props:
$initialData (array|null) - 기존 content JSON (edit )
$initialFiles (array|null) - 기존 첨부파일 [{id, name, size, mime_type}] (edit )
$cards (Collection|null) - 법인카드 목록
$accounts (Collection|null) - 회사 계좌 목록
--}}
@php
$initialData = $initialData ?? [];
$initialFiles = $initialFiles ?? [];
$cards = $cards ?? collect();
$accounts = $accounts ?? collect();
$userName = auth()->user()->name ?? '';
@endphp
<div id="expense-form-container" style="display: none;"
x-data="expenseForm({{ json_encode($initialData) }}, '{{ $userName }}', {{ json_encode($initialFiles) }}, {{ $cards->toJson() }}, {{ $accounts->toJson() }})"
x-init="initAutoSelect()"
x-cloak>
{{-- 지출형식 --}}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">지출형식</label>
<div class="flex flex-wrap gap-4">
<template x-for="opt in expenseTypes" :key="opt.value">
<label class="inline-flex items-center gap-1.5 cursor-pointer">
<input type="radio" name="expense_type" :value="opt.value"
x-model="formData.expense_type"
@change="initAutoSelect()"
class="text-blue-600 focus:ring-blue-500">
<span class="text-sm text-gray-700" x-text="opt.label"></span>
</label>
</template>
</div>
</div>
{{-- 법인카드 선택 패널 --}}
<div x-show="formData.expense_type === 'corporate_card'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 -translate-y-2" x-transition:enter-end="opacity-100 translate-y-0" class="mb-4">
<div class="border border-blue-200 bg-blue-50/50 rounded-lg p-4">
<label class="block text-xs font-medium text-gray-600 mb-3">법인카드 선택</label>
<template x-if="cards.length === 0">
<div class="text-sm text-gray-500 text-center py-4">
등록된 법인카드가 없습니다.
<button type="button" @click="showManageModal('카드 관리', '{{ route('finance.corporate-cards') }}')" class="text-blue-600 hover:underline ml-1">카드 관리</button>
</div>
</template>
<div class="flex flex-wrap gap-3" x-show="cards.length > 0">
<template x-for="card in cards" :key="card.id">
<div @click="selectCard(card)"
:class="formData.selected_card?.id === card.id ? 'border-blue-500 bg-blue-50 ring-2 ring-blue-200' : 'border-gray-200 bg-white hover:border-gray-300'"
class="relative border rounded-lg p-3 cursor-pointer transition-all"
style="flex: 0 0 auto; min-width: 180px; max-width: 220px;">
{{-- 체크 아이콘 --}}
<div x-show="formData.selected_card?.id === card.id" class="absolute top-2 right-2">
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
</div>
<div class="text-xs text-gray-500" x-text="card.card_company"></div>
<div class="text-sm font-medium text-gray-800 mt-0.5" x-text="card.card_name"></div>
<div class="text-xs text-gray-500 mt-1 font-mono" x-text="'**** ' + card.card_number.slice(-4)"></div>
<div class="text-xs text-gray-400 mt-0.5" x-text="card.card_holder_name"></div>
</div>
</template>
</div>
</div>
</div>
{{-- 송금/자동이체 계좌 선택 패널 --}}
<div x-show="formData.expense_type === 'transfer' || formData.expense_type === 'auto_transfer'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 -translate-y-2" x-transition:enter-end="opacity-100 translate-y-0" class="mb-4">
<div class="border border-green-200 bg-green-50/50 rounded-lg p-4">
<label class="block text-xs font-medium text-gray-600 mb-3">출금 계좌 선택</label>
<template x-if="accounts.length === 0">
<div class="text-sm text-gray-500 text-center py-4">
등록된 계좌가 없습니다.
<button type="button" @click="showManageModal('계좌 관리', '{{ route('finance.accounts.index') }}')" class="text-blue-600 hover:underline ml-1">계좌 관리</button>
</div>
</template>
<div class="flex flex-wrap gap-3" x-show="accounts.length > 0">
<template x-for="account in accounts" :key="account.id">
<div @click="selectAccount(account)"
:class="formData.selected_account?.id === account.id ? 'border-blue-500 bg-blue-50 ring-2 ring-blue-200' : 'border-gray-200 bg-white hover:border-gray-300'"
class="relative border rounded-lg p-3 cursor-pointer transition-all"
style="flex: 0 0 auto; min-width: 180px; max-width: 220px;">
{{-- 체크 아이콘 --}}
<div x-show="formData.selected_account?.id === account.id" class="absolute top-2 right-2">
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
</div>
<div class="flex items-center gap-1">
<span class="text-sm font-medium text-gray-800" x-text="account.bank_name"></span>
<template x-if="account.is_primary">
<span class="text-amber-500 text-xs" title="대표계좌"></span>
</template>
</div>
<div class="text-xs text-gray-600 mt-1 font-mono" x-text="account.account_number"></div>
<div class="text-xs text-gray-400 mt-0.5" x-text="account.account_holder"></div>
</div>
</template>
</div>
</div>
</div>
{{-- 세금계산서 --}}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">세금계산서</label>
<div class="flex flex-wrap gap-4">
<template x-for="opt in taxInvoiceTypes" :key="opt.value">
<label class="inline-flex items-center gap-1.5 cursor-pointer">
<input type="radio" name="tax_invoice" :value="opt.value"
x-model="formData.tax_invoice"
class="text-blue-600 focus:ring-blue-500">
<span class="text-sm text-gray-700" x-text="opt.label"></span>
</label>
</template>
</div>
</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.approval_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 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: 160px;">내용</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 x-show="formData.expense_type === 'corporate_card'" class="px-2 py-2 text-left text-xs font-medium text-gray-600 whitespace-nowrap" style="min-width: 180px;">결제카드</th>
{{-- 송금/기타: 지급은행, 계좌번호, 예금주 --}}
<th x-show="formData.expense_type !== 'corporate_card'" class="px-2 py-2 text-left text-xs font-medium text-gray-600 whitespace-nowrap" style="min-width: 80px;">지급은행</th>
<th x-show="formData.expense_type !== 'corporate_card'" class="px-2 py-2 text-left text-xs font-medium text-gray-600" style="min-width: 120px;">계좌번호</th>
<th x-show="formData.expense_type !== 'corporate_card'" class="px-2 py-2 text-left text-xs font-medium text-gray-600 whitespace-nowrap" style="min-width: 80px;">예금주</th>
<th class="px-2 py-2 text-left text-xs font-medium text-gray-600 whitespace-nowrap" 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="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">
<div x-data="vendorSearch(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 x-show="formData.expense_type === 'corporate_card'" class="px-1 py-1">
<div x-show="formData.selected_card" class="px-2 py-1.5 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
<span x-text="formData.selected_card?.card_company"></span>
<span class="font-mono ml-1" x-text="'**** ' + (formData.selected_card?.card_number_last4 || '')"></span>
</div>
<div x-show="!formData.selected_card" class="px-2 py-1.5 text-xs text-gray-400">카드를 선택하세요</div>
</td>
{{-- 송금/자동이체: 선택 계좌 자동표시 / 기타: 수동입력 --}}
<td x-show="formData.expense_type !== 'corporate_card'" class="px-1 py-1">
<template x-if="(formData.expense_type === 'transfer' || formData.expense_type === 'auto_transfer') && formData.selected_account">
<div class="px-2 py-1.5 bg-green-50 border border-green-200 rounded text-xs text-green-700" x-text="formData.selected_account.bank_name"></div>
</template>
<template x-if="formData.expense_type !== 'transfer' && formData.expense_type !== 'auto_transfer' || !formData.selected_account">
<input type="text" x-model="item.bank" 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">
</template>
</td>
<td x-show="formData.expense_type !== 'corporate_card'" class="px-1 py-1">
<template x-if="(formData.expense_type === 'transfer' || formData.expense_type === 'auto_transfer') && formData.selected_account">
<div class="px-2 py-1.5 bg-green-50 border border-green-200 rounded text-xs text-green-700 font-mono" x-text="formData.selected_account.account_number"></div>
</template>
<template x-if="formData.expense_type !== 'transfer' && formData.expense_type !== 'auto_transfer' || !formData.selected_account">
<input type="text" x-model="item.account_no" 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">
</template>
</td>
<td x-show="formData.expense_type !== 'corporate_card'" class="px-1 py-1">
<template x-if="(formData.expense_type === 'transfer' || formData.expense_type === 'auto_transfer') && formData.selected_account">
<div class="px-2 py-1.5 bg-green-50 border border-green-200 rounded text-xs text-green-700" x-text="formData.selected_account.account_holder"></div>
</template>
<template x-if="formData.expense_type !== 'transfer' && formData.expense_type !== 'auto_transfer' || !formData.selected_account">
<input type="text" x-model="item.depositor" 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">
</template>
</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="2">합계</td>
<td class="px-2 py-2 text-xs font-bold text-blue-700 text-right" x-text="formatMoney(totalAmount)"></td>
<td :colspan="formData.expense_type === 'corporate_card' ? 4 : 6" 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.expenseFileInput.click()">
<input type="file" x-ref="expenseFileInput" @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 x-show="manageModal.open" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="manageModal.open = false">
<div class="bg-white rounded-xl shadow-xl p-6" style="width: 400px; max-width: 90vw;">
<h3 class="text-lg font-semibold text-gray-800 mb-2" x-text="manageModal.title"></h3>
<p class="text-sm text-gray-600 mb-4">
탭에서 등록/관리 페이지를 열고, 완료 페이지를 <strong>새로고침</strong>하면 반영됩니다.
</p>
<div class="flex gap-2 justify-end">
<button type="button" @click="manageModal.open = false"
class="px-4 py-2 text-sm text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
닫기
</button>
<a :href="manageModal.url" target="_blank" @click="manageModal.open = false"
class="px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition inline-flex items-center gap-1">
<span x-text="manageModal.title"></span>
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
</a>
</div>
</div>
</div>
</div>
<script>
function expenseForm(initialData, authUserName, initialFiles, cardsData, accountsData) {
let _keyCounter = 0;
function makeItem(data) {
return {
_key: ++_keyCounter,
date: data?.date || '',
description: data?.description || '',
amount: parseInt(data?.amount) || 0,
vendor: data?.vendor || '',
vendor_id: data?.vendor_id || null,
vendor_biz_no: data?.vendor_biz_no || '',
bank: data?.bank || '',
account_no: data?.account_no || '',
depositor: data?.depositor || '',
remark: data?.remark || '',
};
}
const today = new Date().toISOString().slice(0, 10);
const items = (initialData?.items && initialData.items.length > 0)
? initialData.items.map(makeItem)
: [makeItem({ date: today })];
return {
expenseTypes: [
{ value: 'corporate_card', label: '법인카드' },
{ value: 'transfer', label: '송금' },
{ value: 'auto_transfer', label: '자동이체 출금' },
{ value: 'cash_advance', label: '현금/가지급정산' },
],
taxInvoiceTypes: [
{ value: 'normal', label: '일반' },
{ value: 'deferred', label: '이월발행' },
{ value: 'none', label: '없음' },
],
cards: cardsData || [],
accounts: accountsData || [],
formData: {
expense_type: initialData?.expense_type || 'corporate_card',
tax_invoice: initialData?.tax_invoice || 'normal',
write_date: initialData?.write_date || today,
approval_date: initialData?.approval_date || today,
department: initialData?.department || '경리부',
writer_name: initialData?.writer_name || authUserName,
items: items,
attachment_memo: initialData?.attachment_memo || '',
selected_card: initialData?.selected_card || null,
selected_account: initialData?.selected_account || null,
},
// 관리 페이지 모달
manageModal: { open: false, title: '', url: '' },
// 파일 업로드 상태
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({ date: today }));
},
removeItem(index) {
if (this.formData.items.length > 1) {
this.formData.items.splice(index, 1);
}
},
selectCard(card) {
this.formData.selected_card = {
id: card.id,
card_name: card.card_name,
card_company: card.card_company,
card_number_last4: card.card_number.slice(-4),
card_holder_name: card.card_holder_name,
};
},
selectAccount(account) {
this.formData.selected_account = {
id: account.id,
bank_name: account.bank_name,
account_number: account.account_number,
account_holder: account.account_holder,
};
},
initAutoSelect() {
if ((this.formData.expense_type === 'transfer' || this.formData.expense_type === 'auto_transfer') && !this.formData.selected_account && this.accounts.length > 0) {
const primary = this.accounts.find(a => a.is_primary);
this.selectAccount(primary || this.accounts[0]);
}
if (this.formData.expense_type === 'corporate_card' && !this.formData.selected_card && this.cards.length === 1) {
this.selectCard(this.cards[0]);
}
},
showManageModal(title, url) {
this.manageModal = { open: true, title, url };
},
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() {
const isCard = this.formData.expense_type === 'corporate_card';
const isTransfer = this.formData.expense_type === 'transfer' || this.formData.expense_type === 'auto_transfer';
const acct = this.formData.selected_account;
const card = this.formData.selected_card;
return {
expense_type: this.formData.expense_type,
tax_invoice: this.formData.tax_invoice,
write_date: this.formData.write_date,
approval_date: this.formData.approval_date,
department: this.formData.department,
writer_name: this.formData.writer_name,
items: this.formData.items.map(item => ({
date: item.date,
description: item.description,
amount: parseInt(item.amount) || 0,
vendor: item.vendor,
vendor_id: item.vendor_id || null,
vendor_biz_no: item.vendor_biz_no || '',
bank: isTransfer && acct ? acct.bank_name : (isCard ? (card?.card_company || '') : item.bank),
account_no: isTransfer && acct ? acct.account_number : (isCard ? ('**** ' + (card?.card_number_last4 || '')) : item.account_no),
depositor: isTransfer && acct ? acct.account_holder : (isCard ? (card?.card_holder_name || '') : item.depositor),
remark: item.remark,
})),
total_amount: this.totalAmount,
attachment_memo: this.formData.attachment_memo,
selected_card: isCard ? this.formData.selected_card : null,
selected_account: isTransfer ? this.formData.selected_account : null,
};
},
getFileIds() {
return this.uploadedFiles.map(f => f.id);
},
};
}
function vendorSearch(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 = '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(' ');
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;">${escapeVendorHtml(p.name)}</div>
${bizInfo ? '<div style="font-size:11px;color:#9ca3af;margin-top:1px;">' + escapeVendorHtml(bizInfo) + '</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();
}
},
};
}
function escapeVendorHtml(str) {
if (!str) return '';
const d = document.createElement('div');
d.appendChild(document.createTextNode(str));
return d.innerHTML;
}
</script>