feat:미지급금 관리 목업 데이터를 실제 DB CRUD로 전환

- 채무관리 메뉴명 → 미지급금 관리로 변경 시더 추가
- Payable 모델/컨트롤러 생성
- 지급 처리 API 추가
- React 프론트엔드 API 호출 전환

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-04 22:27:18 +09:00
parent 7c5f9addbe
commit 9c37b665e9
5 changed files with 368 additions and 41 deletions

View File

@@ -0,0 +1,181 @@
<?php
namespace App\Http\Controllers\Finance;
use App\Http\Controllers\Controller;
use App\Models\Finance\Payable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PayableController extends Controller
{
public function index(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$query = Payable::forTenant($tenantId);
if ($search = $request->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' => '미지급금이 삭제되었습니다.',
]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Models\Finance;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Payable extends Model
{
use SoftDeletes;
protected $table = 'payables';
protected $fillable = [
'tenant_id',
'vendor_name',
'invoice_no',
'issue_date',
'due_date',
'category',
'amount',
'paid_amount',
'status',
'description',
'memo',
];
protected $casts = [
'amount' => 'integer',
'paid_amount' => 'integer',
'issue_date' => 'date',
'due_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 PayableMenuRenameSeeder 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="payables-root"></div>
@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() {
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<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><CreditCard className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalAmount)}</p>
<p className="text-xs text-gray-400 mt-1">{payables.length}</p>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(stats.totalAmount)}</p>
<p className="text-xs text-gray-400 mt-1">{stats.count}</p>
</div>
<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">{formatCurrency(totalUnpaid)}</p>
<p className="text-2xl font-bold text-amber-600">{formatCurrency(stats.totalUnpaid)}</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><AlertTriangle className="w-5 h-5 text-red-500" /></div>
<p className="text-2xl font-bold text-red-600">{formatCurrency(overdueAmount)}</p>
<p className="text-2xl font-bold text-red-600">{formatCurrency(stats.overdueAmount)}</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">{formatCurrency(totalPaid)}</p>
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(stats.totalPaid)}</p>
</div>
</div>
@@ -248,7 +309,9 @@ function PayablesManagement() {
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredPayables.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>
) : filteredPayables.length === 0 ? (
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
) : filteredPayables.map(item => (
<tr key={item.id} onClick={() => handleEdit(item)} className={`hover:bg-gray-50 cursor-pointer ${item.status === 'overdue' ? 'bg-red-50/50' : ''}`}>
@@ -295,7 +358,7 @@ function PayablesManagement() {
<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">{saving ? '저장 중...' : (modalMode === 'add' ? '등록' : '저장')}</button>
</div>
</div>
</div>

View File

@@ -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')) {