- 첨부파일 업로드 API (GCS 연동, 20MB 제한) - 첨부파일 삭제/다운로드 API 추가 - 지출결의서 폼에 드래그&드롭 멀티 파일 업로드 UI 추가 - ApprovalService에 linkAttachments 메서드 추가 (is_temp 플래그 관리) - show 페이지에 첨부파일 목록 표시 및 다운로드 링크 - 지출부서 기본값 '본사', 로그인 사용자 이름 자동입력, 제목 필드 제거
402 lines
20 KiB
PHP
402 lines
20 KiB
PHP
{{--
|
|
지출결의서 전용 폼 (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="expense-form-container" style="display: none;"
|
|
x-data="expenseForm({{ json_encode($initialData) }}, '{{ $userName }}', {{ json_encode($initialFiles) }})"
|
|
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"
|
|
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="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(3, 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="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 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" style="min-width: 120px;">계좌번호</th>
|
|
<th 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">
|
|
<input type="text" x-model="item.vendor" 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="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">
|
|
</td>
|
|
<td class="px-1 py-1">
|
|
<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">
|
|
</td>
|
|
<td class="px-1 py-1">
|
|
<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">
|
|
</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="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>
|
|
|
|
<script>
|
|
function expenseForm(initialData, authUserName, initialFiles) {
|
|
let _keyCounter = 0;
|
|
|
|
function makeItem(data) {
|
|
return {
|
|
_key: ++_keyCounter,
|
|
date: data?.date || '',
|
|
description: data?.description || '',
|
|
amount: parseInt(data?.amount) || 0,
|
|
vendor: data?.vendor || '',
|
|
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: 'cash_advance', label: '현금/가지급정산' },
|
|
{ value: 'welfare_card', label: '복지카드' },
|
|
],
|
|
taxInvoiceTypes: [
|
|
{ value: 'normal', label: '일반' },
|
|
{ value: 'deferred', label: '이월발행' },
|
|
],
|
|
formData: {
|
|
expense_type: initialData?.expense_type || 'corporate_card',
|
|
tax_invoice: initialData?.tax_invoice || 'normal',
|
|
write_date: initialData?.write_date || today,
|
|
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({ date: today }));
|
|
},
|
|
|
|
removeItem(index) {
|
|
if (this.formData.items.length > 1) {
|
|
this.formData.items.splice(index, 1);
|
|
}
|
|
},
|
|
|
|
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 {
|
|
expense_type: this.formData.expense_type,
|
|
tax_invoice: this.formData.tax_invoice,
|
|
write_date: this.formData.write_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,
|
|
bank: item.bank,
|
|
account_no: item.account_no,
|
|
depositor: item.depositor,
|
|
remark: item.remark,
|
|
})),
|
|
total_amount: this.totalAmount,
|
|
attachment_memo: this.formData.attachment_memo,
|
|
};
|
|
},
|
|
|
|
getFileIds() {
|
|
return this.uploadedFiles.map(f => f.id);
|
|
},
|
|
};
|
|
}
|
|
</script>
|