diff --git a/app/Http/Controllers/Finance/PayableController.php b/app/Http/Controllers/Finance/PayableController.php new file mode 100644 index 00000000..f8dac82d --- /dev/null +++ b/app/Http/Controllers/Finance/PayableController.php @@ -0,0 +1,181 @@ +input('search')) { + $query->where(function ($q) use ($search) { + $q->where('vendor_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); + } + } + + $payables = $query->orderBy('created_at', 'desc') + ->get() + ->map(function ($item) { + return [ + 'id' => $item->id, + 'vendorName' => $item->vendor_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, + 'paidAmount' => $item->paid_amount, + 'status' => $item->status, + 'description' => $item->description, + 'memo' => $item->memo, + ]; + }); + + $all = Payable::forTenant($tenantId)->get(); + + $totalAmount = $all->sum('amount'); + $totalPaid = $all->sum('paid_amount'); + $overdueAmount = $all->where('status', 'overdue')->sum(function ($item) { + return $item->amount - $item->paid_amount; + }); + + $stats = [ + 'totalAmount' => $totalAmount, + 'totalPaid' => $totalPaid, + 'totalUnpaid' => $totalAmount - $totalPaid, + 'overdueAmount' => $overdueAmount, + 'count' => $all->count(), + ]; + + return response()->json([ + 'success' => true, + 'data' => $payables, + 'stats' => $stats, + ]); + } + + public function store(Request $request): JsonResponse + { + $request->validate([ + 'vendorName' => 'required|string|max:100', + 'invoiceNo' => 'required|string|max:50', + 'amount' => 'required|integer|min:0', + ]); + + $tenantId = session('selected_tenant_id', 1); + + Payable::create([ + 'tenant_id' => $tenantId, + 'vendor_name' => $request->input('vendorName'), + 'invoice_no' => $request->input('invoiceNo'), + 'issue_date' => $request->input('issueDate'), + 'due_date' => $request->input('dueDate'), + 'category' => $request->input('category', '사무용품'), + 'amount' => $request->input('amount', 0), + 'paid_amount' => 0, + 'status' => 'unpaid', + '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); + $payable = Payable::forTenant($tenantId)->findOrFail($id); + + $request->validate([ + 'vendorName' => 'required|string|max:100', + 'invoiceNo' => 'required|string|max:50', + 'amount' => 'required|integer|min:0', + ]); + + $payable->update([ + 'vendor_name' => $request->input('vendorName'), + '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', $payable->status), + 'description' => $request->input('description'), + 'memo' => $request->input('memo'), + ]); + + return response()->json([ + 'success' => true, + 'message' => '미지급금이 수정되었습니다.', + ]); + } + + public function pay(Request $request, int $id): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + $payable = Payable::forTenant($tenantId)->findOrFail($id); + + $request->validate([ + 'payAmount' => 'required|integer|min:1', + ]); + + $payAmount = $request->input('payAmount'); + $remaining = $payable->amount - $payable->paid_amount; + + if ($payAmount > $remaining) { + return response()->json([ + 'success' => false, + 'message' => '지급액이 잔액을 초과합니다.', + ], 422); + } + + $newPaid = $payable->paid_amount + $payAmount; + $newStatus = $newPaid >= $payable->amount ? 'paid' : 'partial'; + + $payable->update([ + 'paid_amount' => $newPaid, + 'status' => $newStatus, + ]); + + return response()->json([ + 'success' => true, + 'message' => '지급 처리되었습니다.', + ]); + } + + public function destroy(int $id): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + $payable = Payable::forTenant($tenantId)->findOrFail($id); + $payable->delete(); + + return response()->json([ + 'success' => true, + 'message' => '미지급금이 삭제되었습니다.', + ]); + } +} diff --git a/app/Models/Finance/Payable.php b/app/Models/Finance/Payable.php new file mode 100644 index 00000000..6338f334 --- /dev/null +++ b/app/Models/Finance/Payable.php @@ -0,0 +1,39 @@ + 'integer', + 'paid_amount' => 'integer', + 'issue_date' => 'date', + 'due_date' => 'date', + ]; + + public function scopeForTenant($query, $tenantId) + { + return $query->where('tenant_id', $tenantId); + } +} diff --git a/database/seeders/PayableMenuRenameSeeder.php b/database/seeders/PayableMenuRenameSeeder.php new file mode 100644 index 00000000..78c7db6e --- /dev/null +++ b/database/seeders/PayableMenuRenameSeeder.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/payables.blade.php b/resources/views/finance/payables.blade.php index d3018560..7c0c71bf 100644 --- a/resources/views/finance/payables.blade.php +++ b/resources/views/finance/payables.blade.php @@ -9,6 +9,7 @@ @endpush @section('content') +
@endsection @@ -48,13 +49,9 @@ const RefreshCw = createIcon('refresh-cw'); function PayablesManagement() { - const [payables, setPayables] = useState([ - { id: 1, vendorName: '(주)오피스월드', invoiceNo: 'PO-2026-0123', issueDate: '2026-01-10', dueDate: '2026-01-25', amount: 3500000, paidAmount: 0, status: 'unpaid', category: '사무용품', description: '1월 사무용품 구매' }, - { id: 2, vendorName: 'IT솔루션즈', invoiceNo: 'PO-2026-0118', issueDate: '2026-01-05', dueDate: '2026-01-20', amount: 12000000, paidAmount: 6000000, status: 'partial', category: '소프트웨어', description: 'ERP 라이선스' }, - { id: 3, vendorName: '클라우드서비스', invoiceNo: 'PO-2025-0956', issueDate: '2025-12-20', dueDate: '2026-01-05', amount: 8500000, paidAmount: 8500000, status: 'paid', category: '서비스', description: '12월 클라우드 서비스' }, - { id: 4, vendorName: '인테리어프로', invoiceNo: 'PO-2025-0912', issueDate: '2025-12-01', dueDate: '2025-12-20', amount: 25000000, paidAmount: 0, status: 'overdue', category: '시설', description: '사무실 리모델링' }, - { id: 5, vendorName: '보안시스템', invoiceNo: 'PO-2026-0128', issueDate: '2026-01-15', dueDate: '2026-01-30', amount: 4800000, paidAmount: 0, status: 'unpaid', category: '장비', description: '보안 장비 구매' }, - ]); + const [payables, setPayables] = useState([]); + const [stats, setStats] = useState({ totalAmount: 0, totalPaid: 0, totalUnpaid: 0, overdueAmount: 0, count: 0 }); + const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); const [filterStatus, setFilterStatus] = useState('all'); @@ -63,12 +60,15 @@ function PayablesManagement() { const [showModal, setShowModal] = useState(false); const [modalMode, setModalMode] = useState('add'); const [editingItem, setEditingItem] = useState(null); + const [saving, setSaving] = useState(false); const [showPayModal, setShowPayModal] = useState(false); const [payingItem, setPayingItem] = useState(null); const [payAmount, setPayAmount] = useState(''); const [payDate, setPayDate] = useState(''); + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const categories = ['사무용품', '소프트웨어', '서비스', '시설', '장비', '외주', '기타']; const initialFormState = { @@ -101,31 +101,84 @@ function PayablesManagement() { return diff > 0 ? diff : 0; }; + const fetchPayables = async () => { + setLoading(true); + try { + const res = await fetch('/finance/payables/list'); + const data = await res.json(); + if (data.success) { + setPayables(data.data); + setStats(data.stats); + } + } catch (err) { + console.error('미지급금 조회 실패:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { fetchPayables(); }, []); + const filteredPayables = payables.filter(item => { - const matchesSearch = item.vendorName.toLowerCase().includes(searchTerm.toLowerCase()) || - item.invoiceNo.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesSearch = (item.vendorName || '').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 = payables.reduce((sum, item) => sum + item.amount, 0); - const totalPaid = payables.reduce((sum, item) => sum + item.paidAmount, 0); - const totalUnpaid = totalAmount - totalPaid; - const overdueAmount = payables.filter(i => i.status === 'overdue').reduce((sum, item) => sum + (item.amount - item.paidAmount), 0); - const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); }; - const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); }; - const handleSave = () => { - if (!formData.vendorName || !formData.invoiceNo || !formData.amount) { alert('필수 항목을 입력해주세요.'); return; } - if (modalMode === 'add') { - setPayables(prev => [{ id: Date.now(), ...formData, amount: parseInt(formData.amount) || 0, paidAmount: 0 }, ...prev]); - } else { - setPayables(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.vendorName || !formData.invoiceNo || !formData.amount) { alert('필수 항목을 입력해주세요.'); return; } + setSaving(true); + try { + const url = modalMode === 'add' ? '/finance/payables/store' : `/finance/payables/${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); + fetchPayables(); + } catch (err) { + console.error('저장 실패:', err); + alert('저장에 실패했습니다.'); + } finally { + setSaving(false); + } + }; + const handleDelete = async (id) => { + if (!confirm('정말 삭제하시겠습니까?')) return; + try { + const res = await fetch(`/finance/payables/${id}`, { + method: 'DELETE', + headers: { 'X-CSRF-TOKEN': csrfToken }, + }); + if (res.ok) { + setShowModal(false); + fetchPayables(); + } + } catch (err) { + console.error('삭제 실패:', err); + alert('삭제에 실패했습니다.'); + } }; - const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setPayables(prev => prev.filter(item => item.id !== id)); setShowModal(false); } }; const handlePay = (item) => { setPayingItem(item); @@ -134,22 +187,30 @@ function PayablesManagement() { setShowPayModal(true); }; - const processPayment = () => { + const processPayment = async () => { const amount = parseInt(parseInputCurrency(payAmount)) || 0; if (amount <= 0) { alert('지급액을 입력해주세요.'); return; } const remaining = payingItem.amount - payingItem.paidAmount; if (amount > remaining) { alert('지급액이 잔액을 초과합니다.'); return; } - setPayables(prev => prev.map(item => { - if (item.id === payingItem.id) { - const newPaid = item.paidAmount + amount; - const newStatus = newPaid >= item.amount ? 'paid' : 'partial'; - return { ...item, paidAmount: newPaid, status: newStatus }; + try { + const res = await fetch(`/finance/payables/${payingItem.id}/pay`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, + body: JSON.stringify({ payAmount: amount }), + }); + const data = await res.json(); + if (!res.ok) { + alert(data.message || '지급 처리에 실패했습니다.'); + return; } - return item; - })); - setShowPayModal(false); - setPayingItem(null); + setShowPayModal(false); + setPayingItem(null); + fetchPayables(); + } catch (err) { + console.error('지급 처리 실패:', err); + alert('지급 처리에 실패했습니다.'); + } }; const handleDownload = () => { @@ -193,20 +254,20 @@ function PayablesManagement() {
총 채무액
-

{formatCurrency(totalAmount)}원

-

{payables.length}건

+

{formatCurrency(stats.totalAmount)}원

+

{stats.count}건

미지급잔액
-

{formatCurrency(totalUnpaid)}원

+

{formatCurrency(stats.totalUnpaid)}원

연체금액
-

{formatCurrency(overdueAmount)}원

+

{formatCurrency(stats.overdueAmount)}원

지급완료
-

{formatCurrency(totalPaid)}원

+

{formatCurrency(stats.totalPaid)}원

@@ -248,7 +309,9 @@ function PayablesManagement() { - {filteredPayables.length === 0 ? ( + {loading ? ( +
불러오는 중...
+ ) : filteredPayables.length === 0 ? ( 데이터가 없습니다. ) : filteredPayables.map(item => ( handleEdit(item)} className={`hover:bg-gray-50 cursor-pointer ${item.status === 'overdue' ? 'bg-red-50/50' : ''}`}> @@ -295,7 +358,7 @@ function PayablesManagement() {
{modalMode === 'edit' && } - +
diff --git a/routes/web.php b/routes/web.php index 207176a0..f76ca292 100644 --- a/routes/web.php +++ b/routes/web.php @@ -895,6 +895,15 @@ return view('finance.payables'); })->name('payables'); + // 미지급금 관리 API + Route::prefix('payables')->name('payables.')->group(function () { + Route::get('/list', [\App\Http\Controllers\Finance\PayableController::class, 'index'])->name('list'); + Route::post('/store', [\App\Http\Controllers\Finance\PayableController::class, 'store'])->name('store'); + Route::put('/{id}', [\App\Http\Controllers\Finance\PayableController::class, 'update'])->name('update'); + Route::post('/{id}/pay', [\App\Http\Controllers\Finance\PayableController::class, 'pay'])->name('pay'); + Route::delete('/{id}', [\App\Http\Controllers\Finance\PayableController::class, 'destroy'])->name('destroy'); + }); + // 기타 Route::get('/refunds', function () { if (request()->header('HX-Request')) {