From 7c5f9addbed0c40bce6e7484d6c3aac2c3803e15 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:22:28 +0900 Subject: [PATCH] =?UTF-8?q?feat:=EB=AF=B8=EC=88=98=EA=B8=88=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=AA=A9=EC=97=85=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20DB=20CRUD=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 채권관리 메뉴명 → 미수금 관리로 변경 시더 추가 - Receivable 모델/컨트롤러 생성 - 수금 처리 API 추가 - React 프론트엔드 API 호출 전환 Co-Authored-By: Claude Opus 4.5 --- .../Finance/ReceivableController.php | 182 ++++++++++++++++++ app/Models/Finance/Receivable.php | 39 ++++ .../seeders/ReceivableMenuRenameSeeder.php | 35 ++++ resources/views/finance/receivables.blade.php | 145 ++++++++++---- routes/web.php | 10 + 5 files changed, 370 insertions(+), 41 deletions(-) create mode 100644 app/Http/Controllers/Finance/ReceivableController.php create mode 100644 app/Models/Finance/Receivable.php create mode 100644 database/seeders/ReceivableMenuRenameSeeder.php diff --git a/app/Http/Controllers/Finance/ReceivableController.php b/app/Http/Controllers/Finance/ReceivableController.php new file mode 100644 index 00000000..e836ffb2 --- /dev/null +++ b/app/Http/Controllers/Finance/ReceivableController.php @@ -0,0 +1,182 @@ +input('search')) { + $query->where(function ($q) use ($search) { + $q->where('customer_name', 'like', "%{$search}%") + ->orWhere('invoice_no', 'like', "%{$search}%"); + }); + } + + if ($status = $request->input('status')) { + if ($status !== 'all') { + $query->where('status', $status); + } + } + + if ($category = $request->input('category')) { + if ($category !== 'all') { + $query->where('category', $category); + } + } + + $receivables = $query->orderBy('created_at', 'desc') + ->get() + ->map(function ($item) { + return [ + 'id' => $item->id, + 'customerName' => $item->customer_name, + 'invoiceNo' => $item->invoice_no, + 'issueDate' => $item->issue_date?->format('Y-m-d'), + 'dueDate' => $item->due_date?->format('Y-m-d'), + 'category' => $item->category, + 'amount' => $item->amount, + 'collectedAmount' => $item->collected_amount, + 'status' => $item->status, + 'description' => $item->description, + 'memo' => $item->memo, + ]; + }); + + $allQuery = Receivable::forTenant($tenantId); + $all = (clone $allQuery)->get(); + + $totalAmount = $all->sum('amount'); + $totalCollected = $all->sum('collected_amount'); + $overdueAmount = $all->where('status', 'overdue')->sum(function ($item) { + return $item->amount - $item->collected_amount; + }); + + $stats = [ + 'totalAmount' => $totalAmount, + 'totalCollected' => $totalCollected, + 'totalOutstanding' => $totalAmount - $totalCollected, + 'overdueAmount' => $overdueAmount, + 'count' => $all->count(), + ]; + + return response()->json([ + 'success' => true, + 'data' => $receivables, + 'stats' => $stats, + ]); + } + + public function store(Request $request): JsonResponse + { + $request->validate([ + 'customerName' => 'required|string|max:100', + 'invoiceNo' => 'required|string|max:50', + 'amount' => 'required|integer|min:0', + ]); + + $tenantId = session('selected_tenant_id', 1); + + $receivable = Receivable::create([ + 'tenant_id' => $tenantId, + 'customer_name' => $request->input('customerName'), + 'invoice_no' => $request->input('invoiceNo'), + 'issue_date' => $request->input('issueDate'), + 'due_date' => $request->input('dueDate'), + 'category' => $request->input('category', '서비스'), + 'amount' => $request->input('amount', 0), + 'collected_amount' => 0, + 'status' => 'outstanding', + 'description' => $request->input('description'), + 'memo' => $request->input('memo'), + ]); + + return response()->json([ + 'success' => true, + 'message' => '미수금이 등록되었습니다.', + ]); + } + + public function update(Request $request, int $id): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + $receivable = Receivable::forTenant($tenantId)->findOrFail($id); + + $request->validate([ + 'customerName' => 'required|string|max:100', + 'invoiceNo' => 'required|string|max:50', + 'amount' => 'required|integer|min:0', + ]); + + $receivable->update([ + 'customer_name' => $request->input('customerName'), + 'invoice_no' => $request->input('invoiceNo'), + 'issue_date' => $request->input('issueDate'), + 'due_date' => $request->input('dueDate'), + 'category' => $request->input('category'), + 'amount' => $request->input('amount'), + 'status' => $request->input('status', $receivable->status), + 'description' => $request->input('description'), + 'memo' => $request->input('memo'), + ]); + + return response()->json([ + 'success' => true, + 'message' => '미수금이 수정되었습니다.', + ]); + } + + public function collect(Request $request, int $id): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + $receivable = Receivable::forTenant($tenantId)->findOrFail($id); + + $request->validate([ + 'collectAmount' => 'required|integer|min:1', + ]); + + $collectAmount = $request->input('collectAmount'); + $remaining = $receivable->amount - $receivable->collected_amount; + + if ($collectAmount > $remaining) { + return response()->json([ + 'success' => false, + 'message' => '수금액이 잔액을 초과합니다.', + ], 422); + } + + $newCollected = $receivable->collected_amount + $collectAmount; + $newStatus = $newCollected >= $receivable->amount ? 'collected' : 'partial'; + + $receivable->update([ + 'collected_amount' => $newCollected, + 'status' => $newStatus, + ]); + + return response()->json([ + 'success' => true, + 'message' => '수금 처리되었습니다.', + ]); + } + + public function destroy(int $id): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + $receivable = Receivable::forTenant($tenantId)->findOrFail($id); + $receivable->delete(); + + return response()->json([ + 'success' => true, + 'message' => '미수금이 삭제되었습니다.', + ]); + } +} diff --git a/app/Models/Finance/Receivable.php b/app/Models/Finance/Receivable.php new file mode 100644 index 00000000..bb449af0 --- /dev/null +++ b/app/Models/Finance/Receivable.php @@ -0,0 +1,39 @@ + 'integer', + 'collected_amount' => 'integer', + 'issue_date' => 'date', + 'due_date' => 'date', + ]; + + public function scopeForTenant($query, $tenantId) + { + return $query->where('tenant_id', $tenantId); + } +} diff --git a/database/seeders/ReceivableMenuRenameSeeder.php b/database/seeders/ReceivableMenuRenameSeeder.php new file mode 100644 index 00000000..35c452c3 --- /dev/null +++ b/database/seeders/ReceivableMenuRenameSeeder.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/receivables.blade.php b/resources/views/finance/receivables.blade.php index f9d4b2aa..d6d8690a 100644 --- a/resources/views/finance/receivables.blade.php +++ b/resources/views/finance/receivables.blade.php @@ -9,6 +9,7 @@ @endpush @section('content') +
@endsection @@ -48,13 +49,9 @@ const RefreshCw = createIcon('refresh-cw'); function ReceivablesManagement() { - const [receivables, setReceivables] = useState([ - { id: 1, customerName: '(주)한국테크', invoiceNo: 'INV-2026-0051', issueDate: '2026-01-05', dueDate: '2026-01-20', amount: 15000000, collectedAmount: 0, status: 'outstanding', category: '서비스', description: '1월 서비스 이용료' }, - { id: 2, customerName: '글로벌솔루션', invoiceNo: 'INV-2026-0048', issueDate: '2026-01-02', dueDate: '2026-01-17', amount: 8500000, collectedAmount: 5000000, status: 'partial', category: '상품', description: '소프트웨어 라이선스' }, - { id: 3, customerName: '스마트시스템', invoiceNo: 'INV-2025-0892', issueDate: '2025-12-15', dueDate: '2025-12-30', amount: 12000000, collectedAmount: 12000000, status: 'collected', category: '서비스', description: '12월 유지보수' }, - { id: 4, customerName: '테크파워', invoiceNo: 'INV-2025-0875', issueDate: '2025-12-01', dueDate: '2025-12-15', amount: 6500000, collectedAmount: 0, status: 'overdue', category: '서비스', description: '컨설팅 비용' }, - { id: 5, customerName: '디지털웍스', invoiceNo: 'INV-2026-0055', issueDate: '2026-01-10', dueDate: '2026-01-25', amount: 9800000, collectedAmount: 0, status: 'outstanding', category: '상품', description: '장비 판매대금' }, - ]); + const [receivables, setReceivables] = useState([]); + const [stats, setStats] = useState({ totalAmount: 0, totalCollected: 0, totalOutstanding: 0, overdueAmount: 0, count: 0 }); + const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); const [filterStatus, setFilterStatus] = useState('all'); @@ -63,12 +60,15 @@ function ReceivablesManagement() { const [showModal, setShowModal] = useState(false); const [modalMode, setModalMode] = useState('add'); const [editingItem, setEditingItem] = useState(null); + const [saving, setSaving] = useState(false); const [showCollectModal, setShowCollectModal] = useState(false); const [collectingItem, setCollectingItem] = useState(null); const [collectAmount, setCollectAmount] = useState(''); const [collectDate, setCollectDate] = useState(''); + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const categories = ['서비스', '상품', '컨설팅', '기타']; const initialFormState = { @@ -100,31 +100,84 @@ function ReceivablesManagement() { return diff > 0 ? diff : 0; }; + const fetchReceivables = async () => { + setLoading(true); + try { + const res = await fetch('/finance/receivables/list'); + const data = await res.json(); + if (data.success) { + setReceivables(data.data); + setStats(data.stats); + } + } catch (err) { + console.error('미수금 조회 실패:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { fetchReceivables(); }, []); + const filteredReceivables = receivables.filter(item => { - const matchesSearch = item.customerName.toLowerCase().includes(searchTerm.toLowerCase()) || - item.invoiceNo.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesSearch = (item.customerName || '').toLowerCase().includes(searchTerm.toLowerCase()) || + (item.invoiceNo || '').toLowerCase().includes(searchTerm.toLowerCase()); const matchesStatus = filterStatus === 'all' || item.status === filterStatus; const matchesCategory = filterCategory === 'all' || item.category === filterCategory; return matchesSearch && matchesStatus && matchesCategory; }); - const totalAmount = receivables.reduce((sum, item) => sum + item.amount, 0); - const totalCollected = receivables.reduce((sum, item) => sum + item.collectedAmount, 0); - const totalOutstanding = totalAmount - totalCollected; - const overdueAmount = receivables.filter(i => i.status === 'overdue').reduce((sum, item) => sum + (item.amount - item.collectedAmount), 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.invoiceNo || !formData.amount) { alert('필수 항목을 입력해주세요.'); return; } - if (modalMode === 'add') { - setReceivables(prev => [{ id: Date.now(), ...formData, amount: parseInt(formData.amount) || 0, collectedAmount: 0 }, ...prev]); - } else { - setReceivables(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData, amount: parseInt(formData.amount) || 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.invoiceNo || !formData.amount) { alert('필수 항목을 입력해주세요.'); return; } + setSaving(true); + try { + const url = modalMode === 'add' ? '/finance/receivables/store' : `/finance/receivables/${editingItem.id}`; + const body = { ...formData, amount: parseInt(formData.amount) || 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); + fetchReceivables(); + } catch (err) { + console.error('저장 실패:', err); + alert('저장에 실패했습니다.'); + } finally { + setSaving(false); + } + }; + const handleDelete = async (id) => { + if (!confirm('정말 삭제하시겠습니까?')) return; + try { + const res = await fetch(`/finance/receivables/${id}`, { + method: 'DELETE', + headers: { 'X-CSRF-TOKEN': csrfToken }, + }); + if (res.ok) { + setShowModal(false); + fetchReceivables(); + } + } catch (err) { + console.error('삭제 실패:', err); + alert('삭제에 실패했습니다.'); + } }; - const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setReceivables(prev => prev.filter(item => item.id !== id)); setShowModal(false); } }; const handleCollect = (item) => { setCollectingItem(item); @@ -133,22 +186,30 @@ function ReceivablesManagement() { setShowCollectModal(true); }; - const processCollection = () => { + const processCollection = async () => { const amount = parseInt(parseInputCurrency(collectAmount)) || 0; if (amount <= 0) { alert('수금액을 입력해주세요.'); return; } const remaining = collectingItem.amount - collectingItem.collectedAmount; if (amount > remaining) { alert('수금액이 잔액을 초과합니다.'); return; } - setReceivables(prev => prev.map(item => { - if (item.id === collectingItem.id) { - const newCollected = item.collectedAmount + amount; - const newStatus = newCollected >= item.amount ? 'collected' : 'partial'; - return { ...item, collectedAmount: newCollected, status: newStatus }; + try { + const res = await fetch(`/finance/receivables/${collectingItem.id}/collect`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, + body: JSON.stringify({ collectAmount: amount }), + }); + const data = await res.json(); + if (!res.ok) { + alert(data.message || '수금 처리에 실패했습니다.'); + return; } - return item; - })); - setShowCollectModal(false); - setCollectingItem(null); + setShowCollectModal(false); + setCollectingItem(null); + fetchReceivables(); + } catch (err) { + console.error('수금 처리 실패:', err); + alert('수금 처리에 실패했습니다.'); + } }; const handleDownload = () => { @@ -192,20 +253,20 @@ function ReceivablesManagement() {
총 채권액
-

{formatCurrency(totalAmount)}원

-

{receivables.length}건

+

{formatCurrency(stats.totalAmount)}원

+

{stats.count}건

미수잔액
-

{formatCurrency(totalOutstanding)}원

+

{formatCurrency(stats.totalOutstanding)}원

연체금액
-

{formatCurrency(overdueAmount)}원

+

{formatCurrency(stats.overdueAmount)}원

수금완료
-

{formatCurrency(totalCollected)}원

+

{formatCurrency(stats.totalCollected)}원

@@ -247,7 +308,9 @@ function ReceivablesManagement() { - {filteredReceivables.length === 0 ? ( + {loading ? ( +
불러오는 중...
+ ) : filteredReceivables.length === 0 ? ( 데이터가 없습니다. ) : filteredReceivables.map(item => ( handleEdit(item)} className={`hover:bg-gray-50 cursor-pointer ${item.status === 'overdue' ? 'bg-red-50/50' : ''}`}> @@ -293,7 +356,7 @@ function ReceivablesManagement() {
{modalMode === 'edit' && } - +
diff --git a/routes/web.php b/routes/web.php index 2eae98e9..207176a0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -877,6 +877,16 @@ return view('finance.receivables'); })->name('receivables'); + + // 미수금 관리 API + Route::prefix('receivables')->name('receivables.')->group(function () { + Route::get('/list', [\App\Http\Controllers\Finance\ReceivableController::class, 'index'])->name('list'); + Route::post('/store', [\App\Http\Controllers\Finance\ReceivableController::class, 'store'])->name('store'); + Route::put('/{id}', [\App\Http\Controllers\Finance\ReceivableController::class, 'update'])->name('update'); + Route::post('/{id}/collect', [\App\Http\Controllers\Finance\ReceivableController::class, 'collect'])->name('collect'); + Route::delete('/{id}', [\App\Http\Controllers\Finance\ReceivableController::class, 'destroy'])->name('destroy'); + }); + Route::get('/payables', function () { if (request()->header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('finance.payables'));