feat:거래처 관리 목업 데이터를 실제 DB CRUD로 전환
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
178
app/Http/Controllers/Finance/TradingPartnerController.php
Normal file
178
app/Http/Controllers/Finance/TradingPartnerController.php
Normal 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' => '거래처가 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
38
app/Models/Finance/TradingPartner.php
Normal file
38
app/Models/Finance/TradingPartner.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<div id="partners-root"></div>
|
||||
@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() {
|
||||
<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><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 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>
|
||||
<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 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>
|
||||
<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 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>
|
||||
<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>
|
||||
|
||||
@@ -177,7 +222,9 @@ function PartnersManagement() {
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
) : filteredPartners.map(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">
|
||||
{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={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>
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
Reference in New Issue
Block a user