feat: [approvals] 지출결의서 불러오기 기능 추가
- 기안 작성 시 '불러오기' 버튼으로 기존 지출결의서 불러오기 - 지출결의서 이력 API 엔드포인트 추가 (/expense-history) - 선택한 지출결의서의 내용을 새 폼에 복사 (날짜는 오늘로 초기화)
This commit is contained in:
@@ -444,6 +444,36 @@ public function destroyLine(int $id): JsonResponse
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지출결의서 이력 (불러오기용)
|
||||
*/
|
||||
public function expenseHistory(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$approvals = \App\Models\Approvals\Approval::where('tenant_id', $tenantId)
|
||||
->where('drafter_id', auth()->id())
|
||||
->whereHas('form', fn ($q) => $q->where('code', 'expense'))
|
||||
->whereIn('status', ['draft', 'pending', 'approved', 'rejected', 'cancelled'])
|
||||
->whereNotNull('content')
|
||||
->orderByDesc('created_at')
|
||||
->limit(30)
|
||||
->get(['id', 'title', 'content', 'status', 'created_at']);
|
||||
|
||||
$data = $approvals->map(fn ($a) => [
|
||||
'id' => $a->id,
|
||||
'title' => $a->title,
|
||||
'status' => $a->status,
|
||||
'status_label' => $a->status_label,
|
||||
'total_amount' => $a->content['total_amount'] ?? 0,
|
||||
'expense_type' => $a->content['expense_type'] ?? '',
|
||||
'created_at' => $a->created_at->format('Y-m-d'),
|
||||
'content' => $a->content,
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true, 'data' => $data]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 양식 목록
|
||||
*/
|
||||
|
||||
@@ -82,6 +82,16 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-
|
||||
</div>
|
||||
|
||||
{{-- 지출결의서 전용 폼 --}}
|
||||
<div id="expense-load-area" style="display: none;" class="mb-4 flex items-center gap-2">
|
||||
<label class="text-sm font-medium text-gray-700">지출결의서</label>
|
||||
<button type="button" onclick="openExpenseLoadModal()"
|
||||
class="px-3 py-1.5 bg-amber-50 text-amber-700 hover:bg-amber-100 border border-amber-200 rounded-lg text-xs font-medium transition inline-flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
|
||||
</svg>
|
||||
불러오기
|
||||
</button>
|
||||
</div>
|
||||
@include('approvals.partials._expense-form', [
|
||||
'initialData' => [],
|
||||
'cards' => $cards ?? collect(),
|
||||
@@ -132,6 +142,28 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm fon
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{-- 지출결의서 불러오기 모달 --}}
|
||||
<div id="expense-load-modal" style="display: none;" class="fixed inset-0 z-50">
|
||||
<div class="absolute inset-0 bg-black/50" onclick="closeExpenseLoadModal()"></div>
|
||||
<div class="relative flex items-center justify-center min-h-full p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full overflow-hidden relative" style="max-width: 640px;">
|
||||
<div class="px-5 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-800">지출결의서 불러오기</h3>
|
||||
<button type="button" onclick="closeExpenseLoadModal()"
|
||||
class="p-1 text-gray-400 hover:text-gray-600 transition">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto" style="max-height: 60vh;">
|
||||
<div id="expense-load-list" class="p-4">
|
||||
<div class="text-center text-sm text-gray-400 py-8">불러오는 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('styles')
|
||||
@@ -345,14 +377,17 @@ function switchFormMode(formId) {
|
||||
const code = formCodes[formId];
|
||||
const expenseContainer = document.getElementById('expense-form-container');
|
||||
const bodyArea = document.getElementById('body-area');
|
||||
const expenseLoadArea = document.getElementById('expense-load-area');
|
||||
|
||||
if (code === 'expense') {
|
||||
isExpenseForm = true;
|
||||
expenseContainer.style.display = '';
|
||||
expenseLoadArea.style.display = '';
|
||||
bodyArea.style.display = 'none';
|
||||
} else {
|
||||
isExpenseForm = false;
|
||||
expenseContainer.style.display = 'none';
|
||||
expenseLoadArea.style.display = 'none';
|
||||
bodyArea.style.display = '';
|
||||
}
|
||||
}
|
||||
@@ -401,6 +436,11 @@ function applyBodyTemplate(formId) {
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const expModal = document.getElementById('expense-load-modal');
|
||||
if (expModal && expModal.style.display !== 'none') {
|
||||
closeExpenseLoadModal();
|
||||
return;
|
||||
}
|
||||
const modal = document.getElementById('approval-line-modal');
|
||||
if (modal && modal.style.display !== 'none') {
|
||||
closeApprovalLineModal();
|
||||
@@ -499,5 +539,129 @@ function applyBodyTemplate(formId) {
|
||||
showToast('서버 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 지출결의서 불러오기
|
||||
// =========================================================================
|
||||
|
||||
const expenseTypeLabels = {
|
||||
corporate_card: '법인카드', transfer: '송금', auto_transfer: '자동이체 출금',
|
||||
cash_advance: '현금/가지급정산',
|
||||
};
|
||||
const statusColors = {
|
||||
draft: 'bg-gray-100 text-gray-600', pending: 'bg-blue-100 text-blue-600',
|
||||
approved: 'bg-green-100 text-green-600', rejected: 'bg-red-100 text-red-600',
|
||||
cancelled: 'bg-yellow-100 text-yellow-600',
|
||||
};
|
||||
|
||||
async function openExpenseLoadModal() {
|
||||
const modal = document.getElementById('expense-load-modal');
|
||||
const list = document.getElementById('expense-load-list');
|
||||
modal.style.display = '';
|
||||
document.body.style.overflow = 'hidden';
|
||||
list.innerHTML = '<div class="text-center text-sm text-gray-400 py-8">불러오는 중...</div>';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/approvals/expense-history', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (!json.success || !json.data.length) {
|
||||
list.innerHTML = '<div class="text-center text-sm text-gray-400 py-8">이전 지출결의서가 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = json.data.map(item => {
|
||||
const amount = parseInt(item.total_amount || 0).toLocaleString('ko-KR');
|
||||
const typeLabel = expenseTypeLabels[item.expense_type] || item.expense_type;
|
||||
const color = statusColors[item.status] || statusColors.draft;
|
||||
return `<div class="flex items-center justify-between p-3 border border-gray-200 rounded-lg hover:bg-blue-50 cursor-pointer transition mb-2" onclick="loadExpenseData(${item.id})">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-800 truncate">${escapeHtml(item.title)}</span>
|
||||
<span class="shrink-0 px-1.5 py-0.5 rounded text-xs font-medium ${color}">${escapeHtml(item.status_label)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-1 text-xs text-gray-500">
|
||||
<span>${escapeHtml(item.created_at)}</span>
|
||||
<span>${escapeHtml(typeLabel)}</span>
|
||||
<span class="font-medium text-gray-700">${amount}원</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-gray-400 shrink-0 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
list.innerHTML = '<div class="text-center text-sm text-red-400 py-8">불러오기 실패</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function closeExpenseLoadModal() {
|
||||
document.getElementById('expense-load-modal').style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
async function loadExpenseData(approvalId) {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/approvals/${approvalId}`, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (!json.success || !json.data?.content) {
|
||||
showToast('데이터를 불러올 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = json.data.content;
|
||||
const expenseEl = document.getElementById('expense-form-container');
|
||||
if (!expenseEl || !expenseEl._x_dataStack) return;
|
||||
|
||||
const alpine = expenseEl._x_dataStack[0];
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// 폼 데이터 복사 (날짜는 오늘로 초기화)
|
||||
alpine.formData.expense_type = content.expense_type || 'corporate_card';
|
||||
alpine.formData.tax_invoice = content.tax_invoice || 'normal';
|
||||
alpine.formData.write_date = today;
|
||||
alpine.formData.approval_date = today;
|
||||
alpine.formData.department = content.department || '경리부';
|
||||
alpine.formData.writer_name = content.writer_name || '';
|
||||
alpine.formData.attachment_memo = content.attachment_memo || '';
|
||||
alpine.formData.selected_card = content.selected_card || null;
|
||||
alpine.formData.selected_account = content.selected_account || null;
|
||||
|
||||
// 내역 항목 복사 (날짜는 오늘로)
|
||||
if (content.items && content.items.length > 0) {
|
||||
let keyCounter = alpine.formData.items.length + 100;
|
||||
alpine.formData.items = content.items.map(item => ({
|
||||
_key: ++keyCounter,
|
||||
date: today,
|
||||
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 || '',
|
||||
}));
|
||||
}
|
||||
|
||||
// 제목 설정
|
||||
const titleEl = document.getElementById('title');
|
||||
if (!titleEl.value.trim()) {
|
||||
titleEl.value = json.data.title || '지출결의서';
|
||||
}
|
||||
|
||||
closeExpenseLoadModal();
|
||||
showToast('지출결의서를 불러왔습니다. 내용을 확인 후 수정해주세요.', 'success');
|
||||
} catch (e) {
|
||||
showToast('불러오기 실패', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@@ -958,6 +958,7 @@
|
||||
Route::put('/lines/{id}', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'updateLine'])->name('lines.update');
|
||||
Route::delete('/lines/{id}', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'destroyLine'])->name('lines.destroy');
|
||||
Route::get('/forms', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'forms'])->name('forms');
|
||||
Route::get('/expense-history', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'expenseHistory'])->name('expense-history');
|
||||
Route::get('/badge-counts', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'badgeCounts'])->name('badge-counts');
|
||||
Route::post('/upload-file', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'uploadFile'])->name('upload-file');
|
||||
Route::delete('/files/{fileId}', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'deleteFile'])->name('delete-file');
|
||||
|
||||
Reference in New Issue
Block a user