feat:거래처 매출/매입 구분 라디오버튼 추가

- 등록/수정 모달에 매출/매입 라디오버튼 (기본값: 매출)
- 통계 카드에 매출/매입 건수 표시
- 필터 바에 매출/매입 필터 버튼
- 테이블에 매출/매입 뱃지 표시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-13 11:27:40 +09:00
parent d43d5e9cd2
commit 416eea4401
3 changed files with 46 additions and 12 deletions

View File

@@ -48,6 +48,7 @@ public function index(Request $request): JsonResponse
return [
'id' => $partner->id,
'name' => $partner->name,
'tradeType' => $partner->trade_type,
'type' => $partner->type,
'category' => $partner->category,
'bizNo' => $partner->biz_no,
@@ -66,8 +67,9 @@ public function index(Request $request): JsonResponse
$allPartners = TradingPartner::forTenant($tenantId);
$stats = [
'total' => (clone $allPartners)->count(),
'sales' => (clone $allPartners)->where('trade_type', 'sales')->count(),
'purchase' => (clone $allPartners)->where('trade_type', 'purchase')->count(),
'active' => (clone $allPartners)->where('status', 'active')->count(),
'inactive' => (clone $allPartners)->where('status', 'inactive')->count(),
];
return response()->json([
@@ -90,6 +92,7 @@ public function store(Request $request): JsonResponse
$partner = TradingPartner::create([
'tenant_id' => $tenantId,
'name' => $request->input('name'),
'trade_type' => $request->input('tradeType', 'sales'),
'type' => $request->input('type'),
'category' => $request->input('category'),
'biz_no' => $request->input('bizNo'),
@@ -110,6 +113,7 @@ public function store(Request $request): JsonResponse
'data' => [
'id' => $partner->id,
'name' => $partner->name,
'tradeType' => $partner->trade_type,
'type' => $partner->type,
'category' => $partner->category,
'bizNo' => $partner->biz_no,
@@ -139,6 +143,7 @@ public function update(Request $request, int $id): JsonResponse
$partner->update([
'name' => $request->input('name'),
'trade_type' => $request->input('tradeType', $partner->trade_type),
'type' => $request->input('type'),
'category' => $request->input('category'),
'biz_no' => $request->input('bizNo'),
@@ -159,6 +164,7 @@ public function update(Request $request, int $id): JsonResponse
'data' => [
'id' => $partner->id,
'name' => $partner->name,
'tradeType' => $partner->trade_type,
'type' => $partner->type,
'category' => $partner->category,
'bizNo' => $partner->biz_no,

View File

@@ -14,6 +14,7 @@ class TradingPartner extends Model
protected $fillable = [
'tenant_id',
'name',
'trade_type',
'type',
'category',
'biz_no',

View File

@@ -50,10 +50,11 @@
function PartnersManagement() {
const [partners, setPartners] = useState([]);
const [stats, setStats] = useState({ total: 0, active: 0, inactive: 0 });
const [stats, setStats] = useState({ total: 0, sales: 0, purchase: 0, active: 0 });
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [filterTradeType, setFilterTradeType] = useState('all');
const [filterType, setFilterType] = useState('all');
const [filterCategory, setFilterCategory] = useState('all');
@@ -68,6 +69,7 @@ function PartnersManagement() {
const initialFormState = {
name: '',
tradeType: 'sales',
type: '',
category: '',
bizNo: '',
@@ -105,9 +107,10 @@ function PartnersManagement() {
const matchesSearch = item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(item.ceo || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
(item.manager || '').toLowerCase().includes(searchTerm.toLowerCase());
const matchesTradeType = filterTradeType === 'all' || item.tradeType === filterTradeType;
const matchesType = filterType === 'all' || item.type === filterType;
const matchesCategory = filterCategory === 'all' || item.category === filterCategory;
return matchesSearch && matchesType && matchesCategory;
return matchesSearch && matchesTradeType && matchesType && matchesCategory;
});
const handleAdd = () => { setModalMode('add'); setFormData(initialFormState); setShowModal(true); };
@@ -210,8 +213,8 @@ function PartnersManagement() {
};
const handleDownload = () => {
const rows = [['거래처 관리'], [], ['거래처명', '대표자', '업태', '종목', '사업자번호', '주소', '연락처', '이메일', '담당자', '상태'],
...filteredPartners.map(item => [item.name, item.ceo || '', item.type || '', item.category || '', item.bizNo, item.address || '', item.contact, item.email, item.manager, item.status === 'active' ? '활성' : '비활성'])];
const rows = [['거래처 관리'], [], ['거래구분', '거래처명', '대표자', '업태', '종목', '사업자번호', '주소', '연락처', '이메일', '담당자', '상태'],
...filteredPartners.map(item => [item.tradeType === 'purchase' ? '매입' : '매출', item.name, item.ceo || '', item.type || '', item.category || '', item.bizNo, item.address || '', item.contact, item.email, item.manager, item.status === 'active' ? '활성' : '비활성'])];
const csvContent = rows.map(row => row.join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = '거래처목록.csv'; link.click();
@@ -233,27 +236,36 @@ function PartnersManagement() {
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-3 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="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">{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></div>
<p className="text-2xl font-bold text-blue-600">{stats.sales}</p>
</div>
<div className="bg-white rounded-xl border border-amber-200 p-6">
<div className="flex items-center justify-between mb-2"><span className="text-sm text-amber-700">매입</span></div>
<p className="text-2xl font-bold text-amber-600">{stats.purchase}</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">{stats.active}</p>
</div>
<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></div>
<p className="text-2xl font-bold text-gray-900">{stats.inactive}</p>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="md:col-span-2 relative">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input type="text" placeholder="거래처명, 대표자, 담당자 검색..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500" />
</div>
<div className="flex gap-1">
{[{v:'all',l:'전체'},{v:'sales',l:'매출'},{v:'purchase',l:'매입'}].map(o => (
<button key={o.v} onClick={() => setFilterTradeType(o.v)} className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium ${filterTradeType === o.v ? (o.v === 'sales' ? 'bg-blue-600 text-white' : o.v === 'purchase' ? 'bg-amber-600 text-white' : 'bg-gray-800 text-white') : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>{o.l}</button>
))}
</div>
<select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg"><option value="all">전체 업태</option>{[...new Set(partners.map(p => p.type).filter(Boolean))].map(t => <option key={t} value={t}>{t}</option>)}</select>
<select value={filterCategory} onChange={(e) => setFilterCategory(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg"><option value="all">전체 종목</option>{[...new Set(partners.map(p => p.category).filter(Boolean))].map(c => <option key={c} value={c}>{c}</option>)}</select>
</div>
@@ -279,7 +291,7 @@ function PartnersManagement() {
<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)}>
<td className="px-6 py-4"><p className="text-sm font-medium text-gray-900">{item.name}</p>{item.bizNo && <p className="text-xs text-gray-400">{item.bizNo}</p>}</td>
<td className="px-6 py-4"><div className="flex items-center gap-2"><span className={`px-1.5 py-0.5 rounded text-[10px] font-bold ${item.tradeType === 'purchase' ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700'}`}>{item.tradeType === 'purchase' ? '매입' : '매출'}</span><p className="text-sm font-medium text-gray-900">{item.name}</p></div>{item.bizNo && <p className="text-xs text-gray-400 ml-9">{item.bizNo}</p>}</td>
<td className="px-6 py-4"><span className="text-sm text-gray-900">{item.ceo || '-'}</span></td>
<td className="px-6 py-4">{item.type && <span className="px-2 py-1 bg-blue-50 text-blue-700 rounded text-xs font-medium">{item.type}</span>}{item.category && <p className="text-xs text-gray-400 mt-1">{item.category}</p>}</td>
<td className="px-6 py-4"><p className="text-sm text-gray-600">{item.contact || item.email}</p></td>
@@ -325,6 +337,21 @@ className={`mb-4 border-2 border-dashed rounded-xl p-6 text-center cursor-pointe
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">거래 구분 *</label>
<div className="flex gap-4">
<label className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg border-2 cursor-pointer transition-all ${formData.tradeType === 'sales' ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-gray-200 bg-white text-gray-500 hover:border-gray-300'}`}>
<input type="radio" name="tradeType" value="sales" checked={formData.tradeType === 'sales'} onChange={(e) => setFormData(prev => ({ ...prev, tradeType: e.target.value }))} className="hidden" />
<span className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${formData.tradeType === 'sales' ? 'border-blue-500' : 'border-gray-300'}`}>{formData.tradeType === 'sales' && <span className="w-2 h-2 rounded-full bg-blue-500"></span>}</span>
<span className="text-sm font-medium">매출</span>
</label>
<label className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg border-2 cursor-pointer transition-all ${formData.tradeType === 'purchase' ? 'border-amber-500 bg-amber-50 text-amber-700' : 'border-gray-200 bg-white text-gray-500 hover:border-gray-300'}`}>
<input type="radio" name="tradeType" value="purchase" checked={formData.tradeType === 'purchase'} onChange={(e) => setFormData(prev => ({ ...prev, tradeType: e.target.value }))} className="hidden" />
<span className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${formData.tradeType === 'purchase' ? 'border-amber-500' : 'border-gray-300'}`}>{formData.tradeType === 'purchase' && <span className="w-2 h-2 rounded-full bg-amber-500"></span>}</span>
<span className="text-sm font-medium">매입</span>
</label>
</div>
</div>
<div><label className="block text-sm font-medium text-gray-700 mb-1">거래처명 *</label><input type="text" value={formData.name} onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))} placeholder="거래처명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-gray-700 mb-1">사업자번호</label><input type="text" value={formData.bizNo} onChange={(e) => setFormData(prev => ({ ...prev, bizNo: e.target.value }))} placeholder="123-45-67890" className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div>