From 770bd7e9d774b13d008cb2d9257956ff545b6902 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 4 Feb 2026 22:37:37 +0900 Subject: [PATCH] =?UTF-8?q?feat:=ED=99=98=EB=B6=88/=ED=95=B4=EC=A7=80=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EB=AA=A9=EC=97=85=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20DB=20CRUD=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- .../Controllers/Finance/RefundController.php | 176 ++++++++++++++++++ app/Models/Finance/Refund.php | 39 ++++ database/seeders/RefundMenuRenameSeeder.php | 35 ++++ resources/views/finance/refunds.blade.php | 149 ++++++++++----- routes/web.php | 10 + 5 files changed, 367 insertions(+), 42 deletions(-) create mode 100644 app/Http/Controllers/Finance/RefundController.php create mode 100644 app/Models/Finance/Refund.php create mode 100644 database/seeders/RefundMenuRenameSeeder.php diff --git a/app/Http/Controllers/Finance/RefundController.php b/app/Http/Controllers/Finance/RefundController.php new file mode 100644 index 00000000..83938748 --- /dev/null +++ b/app/Http/Controllers/Finance/RefundController.php @@ -0,0 +1,176 @@ +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' => '환불/해지 요청이 삭제되었습니다.', + ]); + } +} diff --git a/app/Models/Finance/Refund.php b/app/Models/Finance/Refund.php new file mode 100644 index 00000000..3ac0d404 --- /dev/null +++ b/app/Models/Finance/Refund.php @@ -0,0 +1,39 @@ + 'integer', + 'refund_amount' => 'integer', + 'request_date' => 'date', + 'process_date' => 'date', + ]; + + public function scopeForTenant($query, $tenantId) + { + return $query->where('tenant_id', $tenantId); + } +} diff --git a/database/seeders/RefundMenuRenameSeeder.php b/database/seeders/RefundMenuRenameSeeder.php new file mode 100644 index 00000000..334f9a0c --- /dev/null +++ b/database/seeders/RefundMenuRenameSeeder.php @@ -0,0 +1,35 @@ +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})")); + } + } +} diff --git a/resources/views/finance/refunds.blade.php b/resources/views/finance/refunds.blade.php index 1cd7ee06..e31aa5a0 100644 --- a/resources/views/finance/refunds.blade.php +++ b/resources/views/finance/refunds.blade.php @@ -9,6 +9,7 @@ @endpush @section('content') +
@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() {
처리 대기
-

{pendingCount}건

+

{stats.pending}건

처리 완료
-

{completedCount}건

+

{stats.completed}건

거절
-

{rejectedCount}건

+

{stats.rejected}건

환불 총액
-

{formatCurrency(totalRefunded)}원

+

{formatCurrency(stats.totalRefunded)}원

@@ -254,7 +317,9 @@ function RefundsManagement() { - {filteredRefunds.length === 0 ? ( + {loading ? ( +
데이터를 불러오는 중...
+ ) : filteredRefunds.length === 0 ? ( 데이터가 없습니다. ) : filteredRefunds.map(item => ( handleEdit(item)} className="hover:bg-gray-50 cursor-pointer"> @@ -308,7 +373,7 @@ function RefundsManagement() {
{modalMode === 'edit' && } - +
diff --git a/routes/web.php b/routes/web.php index f76ca292..cf10c478 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'));