feat:환불/해지 관리 목업 데이터를 실제 DB CRUD로 전환

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-04 22:37:37 +09:00
parent 9c37b665e9
commit 770bd7e9d7
5 changed files with 367 additions and 42 deletions

View File

@@ -0,0 +1,176 @@
<?php
namespace App\Http\Controllers\Finance;
use App\Http\Controllers\Controller;
use App\Models\Finance\Refund;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class RefundController extends Controller
{
public function index(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$query = Refund::forTenant($tenantId);
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) {
$q->where('customer_name', 'like', "%{$search}%")
->orWhere('product_name', 'like', "%{$search}%");
});
}
if ($status = $request->input('status')) {
if ($status !== 'all') {
$query->where('status', $status);
}
}
if ($type = $request->input('type')) {
if ($type !== 'all') {
$query->where('type', $type);
}
}
$refunds = $query->orderBy('created_at', 'desc')
->get()
->map(function ($item) {
return [
'id' => $item->id,
'type' => $item->type,
'customerName' => $item->customer_name,
'requestDate' => $item->request_date?->format('Y-m-d'),
'productName' => $item->product_name,
'originalAmount' => $item->original_amount,
'refundAmount' => $item->refund_amount,
'reason' => $item->reason,
'status' => $item->status,
'processDate' => $item->process_date?->format('Y-m-d'),
'note' => $item->note,
];
});
$all = Refund::forTenant($tenantId)->get();
$stats = [
'pending' => $all->where('status', 'pending')->count(),
'completed' => $all->where('status', 'completed')->count(),
'rejected' => $all->where('status', 'rejected')->count(),
'totalRefunded' => $all->whereIn('status', ['completed', 'approved'])->sum('refund_amount'),
];
return response()->json([
'success' => true,
'data' => $refunds,
'stats' => $stats,
]);
}
public function store(Request $request): JsonResponse
{
$request->validate([
'customerName' => 'required|string|max:100',
'productName' => 'required|string|max:100',
'originalAmount' => 'required|integer|min:0',
'type' => 'required|in:refund,cancel',
]);
$tenantId = session('selected_tenant_id', 1);
Refund::create([
'tenant_id' => $tenantId,
'type' => $request->input('type', 'refund'),
'customer_name' => $request->input('customerName'),
'request_date' => $request->input('requestDate'),
'product_name' => $request->input('productName'),
'original_amount' => $request->input('originalAmount', 0),
'refund_amount' => 0,
'reason' => $request->input('reason'),
'status' => 'pending',
'note' => $request->input('note'),
]);
return response()->json([
'success' => true,
'message' => '환불/해지 요청이 등록되었습니다.',
]);
}
public function update(Request $request, int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$refund = Refund::forTenant($tenantId)->findOrFail($id);
$request->validate([
'customerName' => 'required|string|max:100',
'productName' => 'required|string|max:100',
'originalAmount' => 'required|integer|min:0',
]);
$refund->update([
'type' => $request->input('type', $refund->type),
'customer_name' => $request->input('customerName'),
'request_date' => $request->input('requestDate'),
'product_name' => $request->input('productName'),
'original_amount' => $request->input('originalAmount'),
'refund_amount' => $request->input('refundAmount', $refund->refund_amount),
'reason' => $request->input('reason'),
'status' => $request->input('status', $refund->status),
'process_date' => $request->input('processDate'),
'note' => $request->input('note'),
]);
return response()->json([
'success' => true,
'message' => '환불/해지 요청이 수정되었습니다.',
]);
}
public function process(Request $request, int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$refund = Refund::forTenant($tenantId)->findOrFail($id);
$request->validate([
'action' => 'required|in:approved,completed,rejected',
]);
$action = $request->input('action');
$today = now()->format('Y-m-d');
if ($action === 'rejected') {
$refund->update([
'status' => 'rejected',
'refund_amount' => 0,
'process_date' => $today,
'note' => $request->input('note'),
]);
} else {
$refund->update([
'status' => $action,
'refund_amount' => $request->input('refundAmount', 0),
'process_date' => $today,
'note' => $request->input('note'),
]);
}
return response()->json([
'success' => true,
'message' => '처리되었습니다.',
]);
}
public function destroy(int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$refund = Refund::forTenant($tenantId)->findOrFail($id);
$refund->delete();
return response()->json([
'success' => true,
'message' => '환불/해지 요청이 삭제되었습니다.',
]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Models\Finance;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Refund extends Model
{
use SoftDeletes;
protected $table = 'refunds';
protected $fillable = [
'tenant_id',
'type',
'customer_name',
'request_date',
'product_name',
'original_amount',
'refund_amount',
'reason',
'status',
'process_date',
'note',
];
protected $casts = [
'original_amount' => 'integer',
'refund_amount' => 'integer',
'request_date' => 'date',
'process_date' => 'date',
];
public function scopeForTenant($query, $tenantId)
{
return $query->where('tenant_id', $tenantId);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Database\Seeders;
use App\Models\Commons\Menu;
use Illuminate\Database\Seeder;
class RefundMenuRenameSeeder extends Seeder
{
public function run(): void
{
$tenantId = 1;
$menu = Menu::where('tenant_id', $tenantId)
->where(function ($q) {
$q->where('name', '환불관리')
->orWhere('name', '환불 관리');
})
->first();
if ($menu) {
$oldName = $menu->name;
$menu->name = '환불/해지 관리';
$menu->save();
$this->command->info("메뉴 이름 변경: {$oldName} → 환불/해지 관리");
} else {
$this->command->warn('환불관리 메뉴를 찾을 수 없습니다.');
Menu::where('tenant_id', $tenantId)
->whereNull('parent_id')
->orderBy('sort_order')
->get(['id', 'name', 'url'])
->each(fn ($m) => $this->command->line(" - [{$m->id}] {$m->name} ({$m->url})"));
}
}
}

View File

@@ -9,6 +9,7 @@
@endpush
@section('content')
<meta name="csrf-token" content="{{ csrf_token() }}">
<div id="refunds-root"></div>
@endsection
@@ -48,13 +49,9 @@
const PlayCircle = createIcon('play-circle');
function RefundsManagement() {
const [refunds, setRefunds] = useState([
{ id: 1, type: 'refund', customerName: '김철수', requestDate: '2026-01-18', productName: '프리미엄 구독', originalAmount: 99000, refundAmount: 49500, reason: '서비스 불만족', status: 'approved', processDate: '2026-01-19', note: '월정액 50% 환불' },
{ id: 2, type: 'cancel', customerName: '이영희', requestDate: '2026-01-17', productName: '엔터프라이즈 플랜', originalAmount: 500000, refundAmount: 300000, reason: '사업 종료', status: 'completed', processDate: '2026-01-18', note: '잔여 기간 환불' },
{ id: 3, type: 'refund', customerName: '박민수', requestDate: '2026-01-15', productName: '베이직 플랜', originalAmount: 29000, refundAmount: 29000, reason: '결제 오류', status: 'completed', processDate: '2026-01-16', note: '전액 환불' },
{ id: 4, type: 'cancel', customerName: '정수연', requestDate: '2026-01-20', productName: '프로 플랜', originalAmount: 199000, refundAmount: 0, reason: '경쟁사 이전', status: 'pending', processDate: '', note: '' },
{ id: 5, type: 'refund', customerName: '최지훈', requestDate: '2026-01-12', productName: '추가 스토리지', originalAmount: 50000, refundAmount: 0, reason: '중복 결제', status: 'rejected', processDate: '2026-01-14', note: '이미 사용한 서비스' },
]);
const [refunds, setRefunds] = useState([]);
const [stats, setStats] = useState({ pending: 0, completed: 0, rejected: 0, totalRefunded: 0 });
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
@@ -63,6 +60,7 @@ function RefundsManagement() {
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState('add');
const [editingItem, setEditingItem] = useState(null);
const [saving, setSaving] = useState(false);
const [showProcessModal, setShowProcessModal] = useState(false);
const [processingItem, setProcessingItem] = useState(null);
@@ -70,6 +68,8 @@ function RefundsManagement() {
const [processRefundAmount, setProcessRefundAmount] = useState('');
const [processNote, setProcessNote] = useState('');
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const types = ['refund', 'cancel'];
const reasons = ['서비스 불만족', '결제 오류', '사업 종료', '경쟁사 이전', '중복 결제', '기타'];
@@ -95,31 +95,84 @@ function RefundsManagement() {
};
const parseInputCurrency = (value) => String(value).replace(/[^\d]/g, '');
const fetchRefunds = async () => {
setLoading(true);
try {
const res = await fetch('/finance/refunds/list');
const data = await res.json();
if (data.success) {
setRefunds(data.data);
setStats(data.stats);
}
} catch (err) {
console.error('환불/해지 조회 실패:', err);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchRefunds(); }, []);
const filteredRefunds = refunds.filter(item => {
const matchesSearch = item.customerName.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.productName.toLowerCase().includes(searchTerm.toLowerCase());
const matchesSearch = (item.customerName || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
(item.productName || '').toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = filterStatus === 'all' || item.status === filterStatus;
const matchesType = filterType === 'all' || item.type === filterType;
return matchesSearch && matchesStatus && matchesType;
});
const pendingCount = refunds.filter(i => i.status === 'pending').length;
const completedCount = refunds.filter(i => i.status === 'completed').length;
const rejectedCount = refunds.filter(i => i.status === 'rejected').length;
const totalRefunded = refunds.filter(i => i.status === 'completed' || i.status === 'approved').reduce((sum, item) => sum + item.refundAmount, 0);
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
const handleSave = () => {
if (!formData.customerName || !formData.productName || !formData.originalAmount) { alert('필수 항목을 입력해주세요.'); return; }
if (modalMode === 'add') {
setRefunds(prev => [{ id: Date.now(), ...formData, originalAmount: parseInt(formData.originalAmount) || 0, refundAmount: 0 }, ...prev]);
} else {
setRefunds(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, originalAmount: parseInt(formData.originalAmount) || 0, refundAmount: parseInt(formData.refundAmount) || 0 } : item));
}
setShowModal(false); setEditingItem(null);
const handleEdit = (item) => {
setModalMode('edit');
setEditingItem(item);
const safeItem = {};
Object.keys(initialFormState).forEach(key => { safeItem[key] = item[key] ?? ''; });
setFormData(safeItem);
setShowModal(true);
};
const handleSave = async () => {
if (!formData.customerName || !formData.productName || !formData.originalAmount) { alert('필수 항목을 입력해주세요.'); return; }
setSaving(true);
try {
const url = modalMode === 'add' ? '/finance/refunds/store' : `/finance/refunds/${editingItem.id}`;
const body = { ...formData, originalAmount: parseInt(formData.originalAmount) || 0, refundAmount: parseInt(formData.refundAmount) || 0 };
const res = await fetch(url, {
method: modalMode === 'add' ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
const errors = data.errors ? Object.values(data.errors).flat().join('\n') : data.message;
alert(errors || '저장에 실패했습니다.');
return;
}
setShowModal(false);
setEditingItem(null);
fetchRefunds();
} catch (err) {
console.error('저장 실패:', err);
alert('저장에 실패했습니다.');
} finally {
setSaving(false);
}
};
const handleDelete = async (id) => {
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const res = await fetch(`/finance/refunds/${id}`, {
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': csrfToken },
});
if (res.ok) {
setShowModal(false);
fetchRefunds();
}
} catch (err) {
console.error('삭제 실패:', err);
alert('삭제에 실패했습니다.');
}
};
const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setRefunds(prev => prev.filter(item => item.id !== id)); setShowModal(false); } };
const handleProcess = (item) => {
setProcessingItem(item);
@@ -129,20 +182,30 @@ function RefundsManagement() {
setShowProcessModal(true);
};
const executeProcess = () => {
const executeProcess = async () => {
const refundAmt = parseInt(parseInputCurrency(processRefundAmount)) || 0;
setRefunds(prev => prev.map(item => {
if (item.id === processingItem.id) {
if (processAction === 'rejected') {
return { ...item, status: 'rejected', refundAmount: 0, processDate: new Date().toISOString().split('T')[0], note: processNote };
} else {
return { ...item, status: processAction, refundAmount: refundAmt, processDate: new Date().toISOString().split('T')[0], note: processNote };
}
try {
const res = await fetch(`/finance/refunds/${processingItem.id}/process`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify({
action: processAction,
refundAmount: processAction === 'rejected' ? 0 : refundAmt,
note: processNote,
}),
});
const data = await res.json();
if (!res.ok) {
alert(data.message || '처리에 실패했습니다.');
return;
}
return item;
}));
setShowProcessModal(false);
setProcessingItem(null);
setShowProcessModal(false);
setProcessingItem(null);
fetchRefunds();
} catch (err) {
console.error('처리 실패:', err);
alert('처리에 실패했습니다.');
}
};
const handleDownload = () => {
@@ -199,19 +262,19 @@ function RefundsManagement() {
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-amber-200 p-6 bg-amber-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-amber-700">처리 대기</span><Clock className="w-5 h-5 text-amber-500" /></div>
<p className="text-2xl font-bold text-amber-600">{pendingCount}</p>
<p className="text-2xl font-bold text-amber-600">{stats.pending}</p>
</div>
<div className="bg-white rounded-xl border border-emerald-200 p-6 bg-emerald-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-emerald-700">처리 완료</span><CheckCircle className="w-5 h-5 text-emerald-500" /></div>
<p className="text-2xl font-bold text-emerald-600">{completedCount}</p>
<p className="text-2xl font-bold text-emerald-600">{stats.completed}</p>
</div>
<div className="bg-white rounded-xl border border-red-200 p-6 bg-red-50/30">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-red-700">거절</span><XCircle className="w-5 h-5 text-red-500" /></div>
<p className="text-2xl font-bold text-red-600">{rejectedCount}</p>
<p className="text-2xl font-bold text-red-600">{stats.rejected}</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500">환불 총액</span><RotateCcw className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalRefunded)}</p>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(stats.totalRefunded)}</p>
</div>
</div>
@@ -254,7 +317,9 @@ function RefundsManagement() {
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredRefunds.length === 0 ? (
{loading ? (
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400"><div className="flex items-center justify-center gap-2"><svg className="animate-spin h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>데이터를 불러오는 ...</div></td></tr>
) : filteredRefunds.length === 0 ? (
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
) : filteredRefunds.map(item => (
<tr key={item.id} onClick={() => handleEdit(item)} className="hover:bg-gray-50 cursor-pointer">
@@ -308,7 +373,7 @@ function RefundsManagement() {
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg">삭제</button>}
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
<button onClick={handleSave} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">{modalMode === 'add' ? '등록' : '저장'}</button>
<button onClick={handleSave} disabled={saving} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed">{saving ? '저장 중...' : (modalMode === 'add' ? '등록' : '저장')}</button>
</div>
</div>
</div>

View File

@@ -912,6 +912,16 @@
return view('finance.refunds');
})->name('refunds');
// 환불/해지 관리 API
Route::prefix('refunds')->name('refunds.')->group(function () {
Route::get('/list', [\App\Http\Controllers\Finance\RefundController::class, 'index'])->name('list');
Route::post('/store', [\App\Http\Controllers\Finance\RefundController::class, 'store'])->name('store');
Route::put('/{id}', [\App\Http\Controllers\Finance\RefundController::class, 'update'])->name('update');
Route::post('/{id}/process', [\App\Http\Controllers\Finance\RefundController::class, 'process'])->name('process');
Route::delete('/{id}', [\App\Http\Controllers\Finance\RefundController::class, 'destroy'])->name('destroy');
});
Route::get('/vat', function () {
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.vat'));