From 0657932bbd5046fbbb3e9a8a9d03a3cfca91cc1e 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:13:31 +0900 Subject: [PATCH] =?UTF-8?q?feat:=EA=B1=B0=EB=9E=98=EC=B2=98=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 Co-Authored-By: Claude Opus 4.5 --- .../Finance/TradingPartnerController.php | 178 ++++++++++++++++++ app/Models/Finance/TradingPartner.php | 38 ++++ resources/views/finance/partners.blade.php | 99 +++++++--- routes/web.php | 8 + 4 files changed, 297 insertions(+), 26 deletions(-) create mode 100644 app/Http/Controllers/Finance/TradingPartnerController.php create mode 100644 app/Models/Finance/TradingPartner.php diff --git a/app/Http/Controllers/Finance/TradingPartnerController.php b/app/Http/Controllers/Finance/TradingPartnerController.php new file mode 100644 index 00000000..8641b078 --- /dev/null +++ b/app/Http/Controllers/Finance/TradingPartnerController.php @@ -0,0 +1,178 @@ +input('search')) { + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('manager', 'like', "%{$search}%"); + }); + } + + if ($type = $request->input('type')) { + if ($type !== 'all') { + $query->where('type', $type); + } + } + + if ($category = $request->input('category')) { + if ($category !== 'all') { + $query->where('category', $category); + } + } + + if ($status = $request->input('status')) { + if ($status !== 'all') { + $query->where('status', $status); + } + } + + $partners = $query->orderBy('created_at', 'desc') + ->get() + ->map(function ($partner) { + return [ + 'id' => $partner->id, + 'name' => $partner->name, + 'type' => $partner->type, + 'category' => $partner->category, + 'bizNo' => $partner->biz_no, + 'bankAccount' => $partner->bank_account, + 'contact' => $partner->contact, + 'email' => $partner->email, + 'manager' => $partner->manager, + 'managerPhone' => $partner->manager_phone, + 'status' => $partner->status, + 'memo' => $partner->memo, + ]; + }); + + $allPartners = TradingPartner::forTenant($tenantId); + $stats = [ + 'total' => (clone $allPartners)->count(), + 'vendor' => (clone $allPartners)->where('type', 'vendor')->count(), + 'freelancer' => (clone $allPartners)->where('type', 'freelancer')->count(), + 'active' => (clone $allPartners)->where('status', 'active')->count(), + ]; + + return response()->json([ + 'success' => true, + 'data' => $partners, + 'stats' => $stats, + ]); + } + + public function store(Request $request): JsonResponse + { + $request->validate([ + 'name' => 'required|string|max:100', + 'type' => 'required|in:vendor,freelancer', + 'category' => 'required|string|max:50', + ]); + + $tenantId = session('selected_tenant_id', 1); + + $partner = TradingPartner::create([ + 'tenant_id' => $tenantId, + 'name' => $request->input('name'), + 'type' => $request->input('type', 'vendor'), + 'category' => $request->input('category', '기타'), + 'biz_no' => $request->input('bizNo'), + 'bank_account' => $request->input('bankAccount'), + 'contact' => $request->input('contact'), + 'email' => $request->input('email'), + 'manager' => $request->input('manager'), + 'manager_phone' => $request->input('managerPhone'), + 'status' => $request->input('status', 'active'), + 'memo' => $request->input('memo'), + ]); + + return response()->json([ + 'success' => true, + 'message' => '거래처가 등록되었습니다.', + 'data' => [ + 'id' => $partner->id, + 'name' => $partner->name, + 'type' => $partner->type, + 'category' => $partner->category, + 'bizNo' => $partner->biz_no, + 'bankAccount' => $partner->bank_account, + 'contact' => $partner->contact, + 'email' => $partner->email, + 'manager' => $partner->manager, + 'managerPhone' => $partner->manager_phone, + 'status' => $partner->status, + 'memo' => $partner->memo, + ], + ]); + } + + public function update(Request $request, int $id): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + $partner = TradingPartner::forTenant($tenantId)->findOrFail($id); + + $request->validate([ + 'name' => 'required|string|max:100', + 'type' => 'required|in:vendor,freelancer', + 'category' => 'required|string|max:50', + ]); + + $partner->update([ + 'name' => $request->input('name'), + 'type' => $request->input('type'), + 'category' => $request->input('category'), + 'biz_no' => $request->input('bizNo'), + 'bank_account' => $request->input('bankAccount'), + 'contact' => $request->input('contact'), + 'email' => $request->input('email'), + 'manager' => $request->input('manager'), + 'manager_phone' => $request->input('managerPhone'), + 'status' => $request->input('status', 'active'), + 'memo' => $request->input('memo'), + ]); + + return response()->json([ + 'success' => true, + 'message' => '거래처가 수정되었습니다.', + 'data' => [ + 'id' => $partner->id, + 'name' => $partner->name, + 'type' => $partner->type, + 'category' => $partner->category, + 'bizNo' => $partner->biz_no, + 'bankAccount' => $partner->bank_account, + 'contact' => $partner->contact, + 'email' => $partner->email, + 'manager' => $partner->manager, + 'managerPhone' => $partner->manager_phone, + 'status' => $partner->status, + 'memo' => $partner->memo, + ], + ]); + } + + public function destroy(int $id): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + $partner = TradingPartner::forTenant($tenantId)->findOrFail($id); + $partner->delete(); + + return response()->json([ + 'success' => true, + 'message' => '거래처가 삭제되었습니다.', + ]); + } +} diff --git a/app/Models/Finance/TradingPartner.php b/app/Models/Finance/TradingPartner.php new file mode 100644 index 00000000..b9c11c81 --- /dev/null +++ b/app/Models/Finance/TradingPartner.php @@ -0,0 +1,38 @@ +where('status', 'active'); + } + + public function scopeForTenant($query, $tenantId) + { + return $query->where('tenant_id', $tenantId); + } +} diff --git a/resources/views/finance/partners.blade.php b/resources/views/finance/partners.blade.php index 50f3c8b1..481cc46b 100644 --- a/resources/views/finance/partners.blade.php +++ b/resources/views/finance/partners.blade.php @@ -9,6 +9,7 @@ @endpush @section('content') +
@endsection @@ -47,13 +48,9 @@ const Hammer = createIcon('hammer'); function PartnersManagement() { - const [partners, setPartners] = useState([ - { id: 1, name: 'AWS Korea', type: 'vendor', category: '클라우드', bizNo: '111-22-33333', contact: '1544-1234', email: 'support@aws.amazon.com', manager: '김AWS', managerPhone: '', status: 'active', memo: '클라우드 인프라' }, - { id: 2, name: '(주)외주개발', type: 'vendor', category: '외주개발', bizNo: '222-33-44444', contact: '02-5555-6666', email: 'contact@outsource.co.kr', manager: '박개발', managerPhone: '010-5555-6666', status: 'active', memo: '프론트엔드 전문' }, - { id: 3, name: '한국타이어', type: 'vendor', category: '차량관리', bizNo: '333-44-55555', contact: '1588-0000', email: 'service@hankook.com', manager: '', managerPhone: '', status: 'active', memo: '' }, - { id: 4, name: '삼성화재', type: 'vendor', category: '보험', bizNo: '444-55-66666', contact: '1588-5114', email: 'insurance@samsung.com', manager: '이보험', managerPhone: '010-7777-8888', status: 'active', memo: '법인차량 보험' }, - { id: 5, name: '김개발 프리랜서', type: 'freelancer', category: '외주개발', bizNo: '', contact: '', email: 'kim.dev@gmail.com', manager: '김개발', managerPhone: '010-1111-2222', status: 'active', memo: '백엔드 개발자' }, - ]); + const [partners, setPartners] = useState([]); + const [stats, setStats] = useState({ total: 0, vendor: 0, freelancer: 0, active: 0 }); + const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); const [filterType, setFilterType] = useState('all'); @@ -62,6 +59,9 @@ function PartnersManagement() { const [showModal, setShowModal] = useState(false); const [modalMode, setModalMode] = useState('add'); const [editingItem, setEditingItem] = useState(null); + const [saving, setSaving] = useState(false); + + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); const types = [{ value: 'vendor', label: '공급업체' }, { value: 'freelancer', label: '프리랜서' }]; const categories = ['클라우드', '외주개발', '차량관리', '보험', '사무용품', '마케팅', '법률/회계', '기타']; @@ -81,31 +81,76 @@ function PartnersManagement() { }; const [formData, setFormData] = useState(initialFormState); + const fetchPartners = async () => { + setLoading(true); + try { + const res = await fetch('/finance/partners/list'); + const data = await res.json(); + if (data.success) { + setPartners(data.data); + setStats(data.stats); + } + } catch (err) { + console.error('거래처 조회 실패:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { fetchPartners(); }, []); + const filteredPartners = partners.filter(item => { const matchesSearch = item.name.toLowerCase().includes(searchTerm.toLowerCase()) || - item.manager.toLowerCase().includes(searchTerm.toLowerCase()); + (item.manager || '').toLowerCase().includes(searchTerm.toLowerCase()); const matchesType = filterType === 'all' || item.type === filterType; const matchesCategory = filterCategory === 'all' || item.category === filterCategory; return matchesSearch && matchesType && matchesCategory; }); - const totalPartners = partners.length; - const vendorCount = partners.filter(p => p.type === 'vendor').length; - const freelancerCount = partners.filter(p => p.type === 'freelancer').length; - const activeCount = partners.filter(p => p.status === 'active').length; - const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); }; const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); }; - const handleSave = () => { + const handleSave = async () => { if (!formData.name) { alert('거래처명을 입력해주세요.'); return; } - if (modalMode === 'add') { - setPartners(prev => [{ id: Date.now(), ...formData }, ...prev]); - } else { - setPartners(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData } : item)); + setSaving(true); + try { + const url = modalMode === 'add' ? '/finance/partners/store' : `/finance/partners/${editingItem.id}`; + const res = await fetch(url, { + method: modalMode === 'add' ? 'POST' : 'PUT', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, + body: JSON.stringify(formData), + }); + 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); + fetchPartners(); + } catch (err) { + console.error('저장 실패:', err); + alert('저장에 실패했습니다.'); + } finally { + setSaving(false); + } + }; + const handleDelete = async (id) => { + if (!confirm('정말 삭제하시겠습니까?')) return; + try { + const res = await fetch(`/finance/partners/${id}`, { + method: 'DELETE', + headers: { 'X-CSRF-TOKEN': csrfToken }, + }); + if (res.ok) { + setShowModal(false); + fetchPartners(); + } + } catch (err) { + console.error('삭제 실패:', err); + alert('삭제에 실패했습니다.'); } - setShowModal(false); setEditingItem(null); }; - const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setPartners(prev => prev.filter(item => item.id !== id)); setShowModal(false); } }; const handleDownload = () => { const rows = [['거래처 관리'], [], ['거래처명', '유형', '분류', '사업자번호', '연락처', '이메일', '담당자', '상태'], @@ -136,19 +181,19 @@ function PartnersManagement() {
총 거래처
-

{totalPartners}개

+

{stats.total}개

공급업체
-

{vendorCount}개

+

{stats.vendor}개

프리랜서
-

{freelancerCount}개

+

{stats.freelancer}개

활성
-

{activeCount}개

+

{stats.active}개

@@ -177,7 +222,9 @@ function PartnersManagement() { - {filteredPartners.length === 0 ? ( + {loading ? ( +
불러오는 중...
+ ) : filteredPartners.length === 0 ? ( 데이터가 없습니다. ) : filteredPartners.map(item => ( handleEdit(item)}> @@ -230,7 +277,7 @@ function PartnersManagement() {
{modalMode === 'edit' && } - +
diff --git a/routes/web.php b/routes/web.php index 52d7c953..2eae98e9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -861,6 +861,14 @@ return view('finance.partners'); })->name('partners'); + // 거래처 관리 API + Route::prefix('partners')->name('partners.')->group(function () { + Route::get('/list', [\App\Http\Controllers\Finance\TradingPartnerController::class, 'index'])->name('list'); + Route::post('/store', [\App\Http\Controllers\Finance\TradingPartnerController::class, 'store'])->name('store'); + Route::put('/{id}', [\App\Http\Controllers\Finance\TradingPartnerController::class, 'update'])->name('update'); + Route::delete('/{id}', [\App\Http\Controllers\Finance\TradingPartnerController::class, 'destroy'])->name('destroy'); + }); + // 채권/채무 Route::get('/receivables', function () { if (request()->header('HX-Request')) {