feat:거래처 관리 목업 데이터를 실제 DB CRUD로 전환

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-04 22:13:31 +09:00
parent 84db8bc626
commit 0657932bbd
4 changed files with 297 additions and 26 deletions

View File

@@ -0,0 +1,178 @@
<?php
namespace App\Http\Controllers\Finance;
use App\Http\Controllers\Controller;
use App\Models\Finance\TradingPartner;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TradingPartnerController extends Controller
{
public function index(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$query = TradingPartner::forTenant($tenantId);
if ($search = $request->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' => '거래처가 삭제되었습니다.',
]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Models\Finance;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class TradingPartner extends Model
{
use SoftDeletes;
protected $table = 'trading_partners';
protected $fillable = [
'tenant_id',
'name',
'type',
'category',
'biz_no',
'bank_account',
'contact',
'email',
'manager',
'manager_phone',
'status',
'memo',
];
public function scopeActive($query)
{
return $query->where('status', 'active');
}
public function scopeForTenant($query, $tenantId)
{
return $query->where('tenant_id', $tenantId);
}
}

View File

@@ -9,6 +9,7 @@
@endpush @endpush
@section('content') @section('content')
<meta name="csrf-token" content="{{ csrf_token() }}">
<div id="partners-root"></div> <div id="partners-root"></div>
@endsection @endsection
@@ -47,13 +48,9 @@
const Hammer = createIcon('hammer'); const Hammer = createIcon('hammer');
function PartnersManagement() { function PartnersManagement() {
const [partners, setPartners] = useState([ 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: '클라우드 인프라' }, const [stats, setStats] = useState({ total: 0, vendor: 0, freelancer: 0, active: 0 });
{ 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: '프론트엔드 전문' }, const [loading, setLoading] = useState(true);
{ 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 [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState('all'); const [filterType, setFilterType] = useState('all');
@@ -62,6 +59,9 @@ function PartnersManagement() {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState('add'); const [modalMode, setModalMode] = useState('add');
const [editingItem, setEditingItem] = useState(null); 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 types = [{ value: 'vendor', label: '공급업체' }, { value: 'freelancer', label: '프리랜서' }];
const categories = ['클라우드', '외주개발', '차량관리', '보험', '사무용품', '마케팅', '법률/회계', '기타']; const categories = ['클라우드', '외주개발', '차량관리', '보험', '사무용품', '마케팅', '법률/회계', '기타'];
@@ -81,31 +81,76 @@ function PartnersManagement() {
}; };
const [formData, setFormData] = useState(initialFormState); 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 filteredPartners = partners.filter(item => {
const matchesSearch = item.name.toLowerCase().includes(searchTerm.toLowerCase()) || 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 matchesType = filterType === 'all' || item.type === filterType;
const matchesCategory = filterCategory === 'all' || item.category === filterCategory; const matchesCategory = filterCategory === 'all' || item.category === filterCategory;
return matchesSearch && matchesType && matchesCategory; 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 handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); }; const handleEdit = (item) => { setModalMode('edit'); setEditingItem(item); setFormData({ ...item }); setShowModal(true); };
const handleSave = () => { const handleSave = async () => {
if (!formData.name) { alert('거래처명을 입력해주세요.'); return; } if (!formData.name) { alert('거래처명을 입력해주세요.'); return; }
if (modalMode === 'add') { setSaving(true);
setPartners(prev => [{ id: Date.now(), ...formData }, ...prev]); try {
} else { const url = modalMode === 'add' ? '/finance/partners/store' : `/finance/partners/${editingItem.id}`;
setPartners(prev => prev.map(item => item.id === editingItem.id ? { ...item, ...formData } : item)); 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 handleDownload = () => {
const rows = [['거래처 관리'], [], ['거래처명', '유형', '분류', '사업자번호', '연락처', '이메일', '담당자', '상태'], const rows = [['거래처 관리'], [], ['거래처명', '유형', '분류', '사업자번호', '연락처', '이메일', '담당자', '상태'],
@@ -136,19 +181,19 @@ function PartnersManagement() {
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6"> <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="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><Building2 className="w-5 h-5 text-gray-400" /></div> <div className="flex items-center justify-between mb-2"><span className="text-sm text-gray-500"> 거래처</span><Building2 className="w-5 h-5 text-gray-400" /></div>
<p className="text-2xl font-bold text-gray-900">{totalPartners}</p> <p className="text-2xl font-bold text-gray-900">{stats.total}</p>
</div> </div>
<div className="bg-white rounded-xl border border-blue-200 p-6"> <div className="bg-white rounded-xl border border-blue-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-blue-700">공급업체</span><Truck className="w-5 h-5 text-blue-500" /></div> <div className="flex items-center justify-between mb-2"><span className="text-sm text-blue-700">공급업체</span><Truck className="w-5 h-5 text-blue-500" /></div>
<p className="text-2xl font-bold text-blue-600">{vendorCount}</p> <p className="text-2xl font-bold text-blue-600">{stats.vendor}</p>
</div> </div>
<div className="bg-white rounded-xl border border-purple-200 p-6"> <div className="bg-white rounded-xl border border-purple-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-purple-700">프리랜서</span><Hammer className="w-5 h-5 text-purple-500" /></div> <div className="flex items-center justify-between mb-2"><span className="text-sm text-purple-700">프리랜서</span><Hammer className="w-5 h-5 text-purple-500" /></div>
<p className="text-2xl font-bold text-purple-600">{freelancerCount}</p> <p className="text-2xl font-bold text-purple-600">{stats.freelancer}</p>
</div> </div>
<div className="bg-white rounded-xl border border-emerald-200 p-6"> <div className="bg-white rounded-xl border border-emerald-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-emerald-700">활성</span></div> <div className="flex items-center justify-between mb-2"><span className="text-sm text-emerald-700">활성</span></div>
<p className="text-2xl font-bold text-emerald-600">{activeCount}</p> <p className="text-2xl font-bold text-emerald-600">{stats.active}</p>
</div> </div>
</div> </div>
@@ -177,7 +222,9 @@ function PartnersManagement() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-gray-100">
{filteredPartners.length === 0 ? ( {loading ? (
<tr><td colSpan="7" 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>
) : filteredPartners.length === 0 ? (
<tr><td colSpan="7" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr> <tr><td colSpan="7" className="px-6 py-12 text-center text-gray-400">데이터가 없습니다.</td></tr>
) : filteredPartners.map(item => ( ) : filteredPartners.map(item => (
<tr key={item.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleEdit(item)}> <tr key={item.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleEdit(item)}>
@@ -230,7 +277,7 @@ function PartnersManagement() {
<div className="flex gap-3 mt-6"> <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 flex items-center gap-2"><span>🗑️</span> 삭제</button>} {modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"><span>🗑️</span> 삭제</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={() => 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> </div>
</div> </div>

View File

@@ -861,6 +861,14 @@
return view('finance.partners'); return view('finance.partners');
})->name('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 () { Route::get('/receivables', function () {
if (request()->header('HX-Request')) { if (request()->header('HX-Request')) {