feat: [finance] 경조사비 관리 페이지 추가

- 거래처 경조사비 관리대장 CRUD (등록/수정/삭제)
- 축의/부조 구분, 부조금(현금/계좌이체/카드), 선물(종류/금액) 관리
- 연도별 필터, 구분별 필터, 거래처/내역 검색
- 통계 카드 (총건수, 총금액, 부조금 합계, 선물 합계, 축의/부조 비율)
- CSV 내보내기
- 라우트: /finance/condolence-expenses
This commit is contained in:
김보곤
2026-03-06 21:38:41 +09:00
parent 466aafdb01
commit 810c1f67dd
4 changed files with 739 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
<?php
namespace App\Http\Controllers\Finance;
use App\Http\Controllers\Controller;
use App\Models\Finance\CondolenceExpense;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class CondolenceExpenseController extends Controller
{
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.condolence-expenses'));
}
return view('finance.condolence-expenses');
}
public function list(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$query = CondolenceExpense::forTenant($tenantId);
if ($year = $request->input('year')) {
$query->whereYear('event_date', $year);
}
if ($category = $request->input('category')) {
if ($category !== 'all') {
$query->where('category', $category);
}
}
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) {
$q->where('partner_name', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%")
->orWhere('memo', 'like', "%{$search}%");
});
}
$records = $query->orderBy('event_date', 'desc')
->orderBy('id', 'desc')
->get()
->map(fn ($item) => [
'id' => $item->id,
'event_date' => $item->event_date?->format('Y-m-d'),
'expense_date' => $item->expense_date?->format('Y-m-d'),
'partner_name' => $item->partner_name,
'description' => $item->description,
'category' => $item->category,
'has_cash' => $item->has_cash,
'cash_method' => $item->cash_method,
'cash_amount' => $item->cash_amount,
'has_gift' => $item->has_gift,
'gift_type' => $item->gift_type,
'gift_amount' => $item->gift_amount,
'total_amount' => $item->total_amount,
'memo' => $item->memo,
]);
$all = CondolenceExpense::forTenant($tenantId);
if ($year) {
$all = $all->whereYear('event_date', $year);
}
$all = $all->get();
$stats = [
'totalCount' => $all->count(),
'totalAmount' => $all->sum('total_amount'),
'cashTotal' => $all->sum('cash_amount'),
'giftTotal' => $all->sum('gift_amount'),
'congratulationCount' => $all->where('category', 'congratulation')->count(),
'condolenceCount' => $all->where('category', 'condolence')->count(),
];
return response()->json([
'success' => true,
'data' => $records,
'stats' => $stats,
]);
}
public function store(Request $request): JsonResponse
{
$request->validate([
'partner_name' => 'required|string|max:100',
'category' => 'required|in:congratulation,condolence',
]);
$tenantId = session('selected_tenant_id', 1);
$cashAmount = (int) $request->input('cash_amount', 0);
$giftAmount = (int) $request->input('gift_amount', 0);
CondolenceExpense::create([
'tenant_id' => $tenantId,
'event_date' => $request->input('event_date'),
'expense_date' => $request->input('expense_date'),
'partner_name' => $request->input('partner_name'),
'description' => $request->input('description'),
'category' => $request->input('category'),
'has_cash' => $request->boolean('has_cash'),
'cash_method' => $request->input('cash_method'),
'cash_amount' => $cashAmount,
'has_gift' => $request->boolean('has_gift'),
'gift_type' => $request->input('gift_type'),
'gift_amount' => $giftAmount,
'total_amount' => $cashAmount + $giftAmount,
'memo' => $request->input('memo'),
'created_by' => auth()->id(),
]);
return response()->json([
'success' => true,
'message' => '경조사비가 등록되었습니다.',
]);
}
public function update(Request $request, int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$item = CondolenceExpense::forTenant($tenantId)->findOrFail($id);
$request->validate([
'partner_name' => 'required|string|max:100',
'category' => 'required|in:congratulation,condolence',
]);
$cashAmount = (int) $request->input('cash_amount', 0);
$giftAmount = (int) $request->input('gift_amount', 0);
$item->update([
'event_date' => $request->input('event_date'),
'expense_date' => $request->input('expense_date'),
'partner_name' => $request->input('partner_name'),
'description' => $request->input('description'),
'category' => $request->input('category'),
'has_cash' => $request->boolean('has_cash'),
'cash_method' => $request->input('cash_method'),
'cash_amount' => $cashAmount,
'has_gift' => $request->boolean('has_gift'),
'gift_type' => $request->input('gift_type'),
'gift_amount' => $giftAmount,
'total_amount' => $cashAmount + $giftAmount,
'memo' => $request->input('memo'),
]);
return response()->json([
'success' => true,
'message' => '경조사비가 수정되었습니다.',
]);
}
public function destroy(int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$item = CondolenceExpense::forTenant($tenantId)->findOrFail($id);
$item->delete();
return response()->json([
'success' => true,
'message' => '경조사비가 삭제되었습니다.',
]);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models\Finance;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class CondolenceExpense extends Model
{
use SoftDeletes;
protected $table = 'condolence_expenses';
protected $fillable = [
'tenant_id',
'event_date',
'expense_date',
'partner_name',
'description',
'category',
'has_cash',
'cash_method',
'cash_amount',
'has_gift',
'gift_type',
'gift_amount',
'total_amount',
'options',
'memo',
'created_by',
];
protected $casts = [
'event_date' => 'date',
'expense_date' => 'date',
'has_cash' => 'boolean',
'has_gift' => 'boolean',
'cash_amount' => 'integer',
'gift_amount' => 'integer',
'total_amount' => 'integer',
'options' => 'array',
];
public function scopeForTenant($query, $tenantId)
{
return $query->where('tenant_id', $tenantId);
}
}

View File

@@ -0,0 +1,512 @@
@extends('layouts.app')
@section('title', '경조사비 관리')
@push('styles')
<style>
@media print { .no-print { display: none !important; } }
</style>
@endpush
@section('content')
<div x-data="condolenceExpenseApp()" x-init="init()">
{{-- 헤더 --}}
<div class="flex items-center justify-between mb-4 no-print">
<div>
<h1 class="text-xl font-bold text-gray-800">경조사비 관리</h1>
<p class="text-xs text-gray-500 mt-0.5">거래처 경조사비 관리대장</p>
</div>
<div class="flex items-center gap-2">
<button @click="exportCsv()" class="px-3 py-1.5 text-xs border border-gray-300 rounded-lg hover:bg-gray-50 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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
CSV
</button>
<button @click="openModal('add')" class="px-3 py-1.5 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-700 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="M12 4v16m8-8H4"/></svg>
등록
</button>
</div>
</div>
{{-- 통계 카드 --}}
<div class="grid gap-3 mb-4 no-print" style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));">
<div class="bg-white rounded-lg border border-gray-200 p-3">
<div class="text-xs text-gray-500"> 건수</div>
<div class="text-lg font-bold text-gray-800 mt-0.5" x-text="stats.totalCount + '건'"></div>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-3">
<div class="text-xs text-gray-500"> 금액</div>
<div class="text-lg font-bold text-blue-600 mt-0.5" x-text="formatMoney(stats.totalAmount) + '원'"></div>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-3">
<div class="text-xs text-gray-500">부조금 합계</div>
<div class="text-lg font-bold text-indigo-600 mt-0.5" x-text="formatMoney(stats.cashTotal) + '원'"></div>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-3">
<div class="text-xs text-gray-500">선물 합계</div>
<div class="text-lg font-bold text-green-600 mt-0.5" x-text="formatMoney(stats.giftTotal) + '원'"></div>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-3">
<div class="flex items-center gap-2">
<div>
<div class="text-xs text-gray-500">축의</div>
<div class="text-lg font-bold text-red-500 mt-0.5" x-text="stats.congratulationCount + '건'"></div>
</div>
<div class="text-gray-300 text-lg">/</div>
<div>
<div class="text-xs text-gray-500">부조</div>
<div class="text-lg font-bold text-gray-700 mt-0.5" x-text="stats.condolenceCount + '건'"></div>
</div>
</div>
</div>
</div>
{{-- 필터 --}}
<div class="bg-white rounded-lg border border-gray-200 p-3 mb-4 no-print">
<div class="flex items-center gap-3 flex-wrap">
<div style="flex: 0 0 100px;">
<select x-model="filterYear" @change="loadData()" class="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-blue-500">
<template x-for="y in yearOptions" :key="y">
<option :value="y" x-text="y + '년'"></option>
</template>
</select>
</div>
<div style="flex: 0 0 100px;">
<select x-model="filterCategory" @change="loadData()" class="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="all">전체 구분</option>
<option value="congratulation">축의</option>
<option value="condolence">부조</option>
</select>
</div>
<div style="flex: 1 1 200px; max-width: 300px;">
<div class="relative">
<svg class="absolute left-2.5 top-2 w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input type="text" x-model="searchQuery" @input.debounce.300ms="loadData()" placeholder="거래처명, 내역 검색..."
class="w-full pl-8 pr-3 py-1.5 border border-gray-300 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
</div>
</div>
{{-- 테이블 --}}
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr class="bg-gray-50 border-b border-gray-200">
<th class="px-3 py-2 text-center text-gray-600 font-semibold" style="width: 40px;">No</th>
<th class="px-3 py-2 text-center text-gray-600 font-semibold" style="width: 100px;">경조사일자</th>
<th class="px-3 py-2 text-center text-gray-600 font-semibold" style="width: 100px;">지출일자</th>
<th class="px-3 py-2 text-left text-gray-600 font-semibold" style="min-width: 140px;">거래처명</th>
<th class="px-3 py-2 text-left text-gray-600 font-semibold" style="min-width: 140px;">내역</th>
<th class="px-3 py-2 text-center text-gray-600 font-semibold" style="width: 60px;">구분</th>
<th class="px-3 py-2 text-center text-gray-600 font-semibold" style="width: 50px;">부조금</th>
<th class="px-3 py-2 text-center text-gray-600 font-semibold" style="width: 80px;">지출방법</th>
<th class="px-3 py-2 text-right text-gray-600 font-semibold" style="width: 100px;">부조금액</th>
<th class="px-3 py-2 text-center text-gray-600 font-semibold" style="width: 50px;">선물</th>
<th class="px-3 py-2 text-center text-gray-600 font-semibold" style="width: 80px;">선물종류</th>
<th class="px-3 py-2 text-right text-gray-600 font-semibold" style="width: 100px;">선물금액</th>
<th class="px-3 py-2 text-right text-gray-600 font-semibold" style="width: 100px;">총금액</th>
<th class="px-3 py-2 text-left text-gray-600 font-semibold" style="min-width: 100px;">비고</th>
<th class="px-3 py-2 text-center text-gray-600 font-semibold no-print" style="width: 70px;">관리</th>
</tr>
</thead>
<tbody>
<template x-if="loading">
<tr><td colspan="15" class="px-3 py-8 text-center text-gray-400">로딩 ...</td></tr>
</template>
<template x-if="!loading && records.length === 0">
<tr><td colspan="15" class="px-3 py-8 text-center text-gray-400">등록된 경조사비가 없습니다.</td></tr>
</template>
<template x-for="(item, idx) in records" :key="item.id">
<tr class="border-b border-gray-100 hover:bg-gray-50 transition">
<td class="px-3 py-2 text-center text-gray-500" x-text="idx + 1"></td>
<td class="px-3 py-2 text-center" x-text="item.event_date || '-'"></td>
<td class="px-3 py-2 text-center" x-text="item.expense_date || '-'"></td>
<td class="px-3 py-2 font-medium text-gray-800" x-text="item.partner_name"></td>
<td class="px-3 py-2 text-gray-600" x-text="item.description || '-'"></td>
<td class="px-3 py-2 text-center">
<span class="inline-block px-1.5 py-0.5 rounded text-xs font-medium"
:class="item.category === 'congratulation' ? 'bg-red-50 text-red-600' : 'bg-gray-100 text-gray-600'"
x-text="item.category === 'congratulation' ? '축의' : '부조'"></span>
</td>
<td class="px-3 py-2 text-center" x-text="item.has_cash ? '여' : '부'"></td>
<td class="px-3 py-2 text-center text-gray-600" x-text="cashMethodLabel(item.cash_method)"></td>
<td class="px-3 py-2 text-right font-medium" x-text="item.cash_amount ? formatMoney(item.cash_amount) : '-'"></td>
<td class="px-3 py-2 text-center" x-text="item.has_gift ? '여' : '부'"></td>
<td class="px-3 py-2 text-center text-gray-600" x-text="item.gift_type || '-'"></td>
<td class="px-3 py-2 text-right font-medium" x-text="item.gift_amount ? formatMoney(item.gift_amount) : '-'"></td>
<td class="px-3 py-2 text-right font-bold text-blue-600" x-text="formatMoney(item.total_amount)"></td>
<td class="px-3 py-2 text-gray-500" x-text="item.memo || ''"></td>
<td class="px-3 py-2 text-center no-print">
<div class="flex items-center justify-center gap-1">
<button @click="openModal('edit', item)" class="p-1 text-gray-400 hover:text-blue-600 transition" title="수정">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button @click="deleteItem(item.id)" class="p-1 text-gray-400 hover:text-red-600 transition" title="삭제">
<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="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>
</div>
</td>
</tr>
</template>
</tbody>
{{-- 합계 --}}
<tfoot x-show="records.length > 0">
<tr class="bg-gray-50 border-t-2 border-gray-300 font-semibold text-xs">
<td colspan="8" class="px-3 py-2 text-right text-gray-600">합계</td>
<td class="px-3 py-2 text-right text-indigo-600" x-text="formatMoney(footerCashTotal)"></td>
<td colspan="2" class="px-3 py-2"></td>
<td class="px-3 py-2 text-right text-green-600" x-text="formatMoney(footerGiftTotal)"></td>
<td class="px-3 py-2 text-right text-blue-700" x-text="formatMoney(footerTotal)"></td>
<td colspan="2" class="px-3 py-2"></td>
</tr>
</tfoot>
</table>
</div>
</div>
{{-- 등록/수정 모달 --}}
<div x-show="showModal" x-cloak class="fixed inset-0 z-50" style="display: none;">
<div class="absolute inset-0 bg-black/50" @click="showModal = false"></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" style="max-width: 600px;" @click.stop>
<div class="flex items-center justify-between px-5 py-3 border-b border-gray-200 bg-gray-50">
<h3 class="text-sm font-semibold text-gray-800" x-text="modalMode === 'add' ? '경조사비 등록' : '경조사비 수정'"></h3>
<button @click="showModal = false" 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="p-5 space-y-4 overflow-y-auto" style="max-height: 70vh;">
{{-- 날짜 --}}
<div class="flex gap-3">
<div style="flex: 1;">
<label class="block text-xs font-medium text-gray-600 mb-1">경조사일자</label>
<input type="date" x-model="form.event_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 style="flex: 1;">
<label class="block text-xs font-medium text-gray-600 mb-1">지출일자</label>
<input type="date" x-model="form.expense_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>
<label class="block text-xs font-medium text-gray-600 mb-1">거래처명 / 대상자 <span class="text-red-500">*</span></label>
<input type="text" x-model="form.partner_name" placeholder="예: A회사 홍길동 상무" 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 class="flex gap-3">
<div style="flex: 2;">
<label class="block text-xs font-medium text-gray-600 mb-1">내역</label>
<input type="text" x-model="form.description" 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 style="flex: 0 0 120px;">
<label class="block text-xs font-medium text-gray-600 mb-1">구분 <span class="text-red-500">*</span></label>
<select x-model="form.category" 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">
<option value="congratulation">축의</option>
<option value="condolence">부조</option>
</select>
</div>
</div>
{{-- 부조금 --}}
<div class="border border-gray-200 rounded-lg p-3">
<div class="flex items-center gap-2 mb-2">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" x-model="form.has_cash" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="text-xs font-semibold text-gray-700">부조금</span>
</label>
</div>
<div x-show="form.has_cash" class="flex gap-3">
<div style="flex: 1;">
<label class="block text-xs text-gray-500 mb-1">지출방법</label>
<select x-model="form.cash_method" class="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="cash">현금</option>
<option value="transfer">계좌이체</option>
<option value="card">카드</option>
</select>
</div>
<div style="flex: 1;">
<label class="block text-xs text-gray-500 mb-1">금액</label>
<input type="text" inputmode="numeric" x-model="form.cash_amount_display"
@input="form.cash_amount_display = formatMoneyInput($event.target.value); updateTotal()"
@focus="form.cash_amount_display = unformat(form.cash_amount_display)"
@blur="form.cash_amount_display = formatMoneyInput(form.cash_amount_display)"
class="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs text-right focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
</div>
{{-- 선물 --}}
<div class="border border-gray-200 rounded-lg p-3">
<div class="flex items-center gap-2 mb-2">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" x-model="form.has_gift" class="rounded border-gray-300 text-green-600 focus:ring-green-500">
<span class="text-xs font-semibold text-gray-700">선물</span>
</label>
</div>
<div x-show="form.has_gift" class="flex gap-3">
<div style="flex: 1;">
<label class="block text-xs text-gray-500 mb-1">종류</label>
<input type="text" x-model="form.gift_type" placeholder="예: 화환, 과일바구니" class="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div style="flex: 1;">
<label class="block text-xs text-gray-500 mb-1">금액</label>
<input type="text" inputmode="numeric" x-model="form.gift_amount_display"
@input="form.gift_amount_display = formatMoneyInput($event.target.value); updateTotal()"
@focus="form.gift_amount_display = unformat(form.gift_amount_display)"
@blur="form.gift_amount_display = formatMoneyInput(form.gift_amount_display)"
class="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs text-right focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
</div>
{{-- 총금액 --}}
<div class="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
<span class="text-sm font-semibold text-gray-700"> 금액</span>
<span class="text-lg font-bold text-blue-600" x-text="formatMoney(form.total_amount) + '원'"></span>
</div>
{{-- 비고 --}}
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">비고</label>
<input type="text" x-model="form.memo" 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="flex justify-end gap-2 px-5 py-3 border-t border-gray-200 bg-gray-50">
<button @click="showModal = false" class="px-4 py-2 text-xs border border-gray-300 rounded-lg hover:bg-gray-100 transition">취소</button>
<button @click="saveItem()" :disabled="saving" class="px-4 py-2 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50">
<span x-text="saving ? '저장 중...' : (modalMode === 'add' ? '등록' : '수정')"></span>
</button>
</div>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
function condolenceExpenseApp() {
const now = new Date();
const currentYear = now.getFullYear();
return {
records: [],
stats: { totalCount: 0, totalAmount: 0, cashTotal: 0, giftTotal: 0, congratulationCount: 0, condolenceCount: 0 },
loading: true,
saving: false,
showModal: false,
modalMode: 'add',
filterYear: currentYear,
filterCategory: 'all',
searchQuery: '',
form: {},
get yearOptions() {
const years = [];
for (let y = currentYear; y >= currentYear - 5; y--) years.push(y);
return years;
},
get footerCashTotal() {
return this.records.reduce((sum, r) => sum + (r.cash_amount || 0), 0);
},
get footerGiftTotal() {
return this.records.reduce((sum, r) => sum + (r.gift_amount || 0), 0);
},
get footerTotal() {
return this.records.reduce((sum, r) => sum + (r.total_amount || 0), 0);
},
async init() {
await this.loadData();
},
async loadData() {
this.loading = true;
try {
const params = new URLSearchParams({ year: this.filterYear, category: this.filterCategory });
if (this.searchQuery) params.append('search', this.searchQuery);
const res = await fetch('/finance/condolence-expenses/list?' + params);
const data = await res.json();
if (data.success) {
this.records = data.data;
this.stats = data.stats;
}
} catch (e) {
console.error('데이터 로딩 실패:', e);
}
this.loading = false;
},
resetForm() {
const today = new Date().toISOString().slice(0, 10);
this.form = {
id: null,
event_date: today,
expense_date: today,
partner_name: '',
description: '',
category: 'congratulation',
has_cash: true,
cash_method: 'transfer',
cash_amount: 0,
cash_amount_display: '',
has_gift: false,
gift_type: '',
gift_amount: 0,
gift_amount_display: '',
total_amount: 0,
memo: '',
};
},
openModal(mode, item = null) {
this.modalMode = mode;
if (mode === 'edit' && item) {
this.form = {
id: item.id,
event_date: item.event_date || '',
expense_date: item.expense_date || '',
partner_name: item.partner_name,
description: item.description || '',
category: item.category,
has_cash: item.has_cash,
cash_method: item.cash_method || 'transfer',
cash_amount: item.cash_amount || 0,
cash_amount_display: item.cash_amount ? this.formatMoney(item.cash_amount) : '',
has_gift: item.has_gift,
gift_type: item.gift_type || '',
gift_amount: item.gift_amount || 0,
gift_amount_display: item.gift_amount ? this.formatMoney(item.gift_amount) : '',
total_amount: item.total_amount || 0,
memo: item.memo || '',
};
} else {
this.resetForm();
}
this.showModal = true;
},
updateTotal() {
const cash = this.form.has_cash ? this.parseNumber(this.form.cash_amount_display) : 0;
const gift = this.form.has_gift ? this.parseNumber(this.form.gift_amount_display) : 0;
this.form.cash_amount = cash;
this.form.gift_amount = gift;
this.form.total_amount = cash + gift;
},
async saveItem() {
if (!this.form.partner_name.trim()) {
if (typeof showToast === 'function') showToast('거래처명을 입력해주세요.', 'warning');
return;
}
this.updateTotal();
this.saving = true;
const payload = {
event_date: this.form.event_date,
expense_date: this.form.expense_date,
partner_name: this.form.partner_name,
description: this.form.description,
category: this.form.category,
has_cash: this.form.has_cash,
cash_method: this.form.has_cash ? this.form.cash_method : null,
cash_amount: this.form.has_cash ? this.form.cash_amount : 0,
has_gift: this.form.has_gift,
gift_type: this.form.has_gift ? this.form.gift_type : null,
gift_amount: this.form.has_gift ? this.form.gift_amount : 0,
memo: this.form.memo,
};
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
const isEdit = this.modalMode === 'edit' && this.form.id;
const url = isEdit ? `/finance/condolence-expenses/${this.form.id}` : '/finance/condolence-expenses/store';
const method = isEdit ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify(payload),
});
const data = await res.json();
if (data.success) {
if (typeof showToast === 'function') showToast(data.message, 'success');
this.showModal = false;
await this.loadData();
} else {
if (typeof showToast === 'function') showToast(data.message || '저장 실패', 'error');
}
} catch (e) {
console.error('저장 실패:', e);
if (typeof showToast === 'function') showToast('저장 중 오류가 발생했습니다.', 'error');
}
this.saving = false;
},
async deleteItem(id) {
if (!confirm('삭제하시겠습니까?')) return;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
const res = await fetch(`/finance/condolence-expenses/${id}`, {
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': csrfToken },
});
const data = await res.json();
if (data.success) {
if (typeof showToast === 'function') showToast(data.message, 'success');
await this.loadData();
}
} catch (e) {
console.error('삭제 실패:', e);
}
},
exportCsv() {
const headers = ['No', '경조사일자', '지출일자', '거래처명', '내역', '구분', '부조금여부', '지출방법', '부조금액', '선물여부', '선물종류', '선물금액', '총금액', '비고'];
const rows = this.records.map((r, i) => [
i + 1, r.event_date || '', r.expense_date || '', r.partner_name, r.description || '',
r.category === 'congratulation' ? '축의' : '부조',
r.has_cash ? '여' : '부', this.cashMethodLabel(r.cash_method), r.cash_amount || 0,
r.has_gift ? '여' : '부', r.gift_type || '', r.gift_amount || 0,
r.total_amount || 0, r.memo || '',
]);
const BOM = '\uFEFF';
const csv = BOM + [headers, ...rows].map(r => r.map(c => '"' + String(c).replace(/"/g, '""') + '"').join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `경조사비_관리대장_${this.filterYear}.csv`;
a.click();
},
cashMethodLabel(method) {
const map = { cash: '현금', transfer: '계좌이체', card: '카드' };
return map[method] || method || '-';
},
formatMoney(val) {
if (!val && val !== 0) return '-';
return Number(val).toLocaleString('ko-KR');
},
formatMoneyInput(val) {
const num = String(val).replace(/[^\d]/g, '');
return num ? Number(num).toLocaleString('ko-KR') : '';
},
unformat(val) {
return String(val).replace(/[^\d]/g, '');
},
parseNumber(val) {
return parseInt(String(val).replace(/[^\d]/g, ''), 10) || 0;
},
};
}
</script>
@endpush

View File

@@ -1449,6 +1449,15 @@
Route::put('/{id}', [\App\Http\Controllers\Finance\VatRecordController::class, 'update'])->name('update');
Route::delete('/{id}', [\App\Http\Controllers\Finance\VatRecordController::class, 'destroy'])->name('destroy');
});
// 경조사비 관리
Route::get('/condolence-expenses', [\App\Http\Controllers\Finance\CondolenceExpenseController::class, 'index'])->name('condolence-expenses');
Route::prefix('condolence-expenses')->name('condolence-expenses.')->group(function () {
Route::get('/list', [\App\Http\Controllers\Finance\CondolenceExpenseController::class, 'list'])->name('list');
Route::post('/store', [\App\Http\Controllers\Finance\CondolenceExpenseController::class, 'store'])->name('store');
Route::put('/{id}', [\App\Http\Controllers\Finance\CondolenceExpenseController::class, 'update'])->name('update');
Route::delete('/{id}', [\App\Http\Controllers\Finance\CondolenceExpenseController::class, 'destroy'])->name('destroy');
});
});
/*