Initial commit: AutonetSellCar platform with deployment system
- Frontend: Next.js 14 with TypeScript - Backend: FastAPI with SQLAlchemy - Agent: Carmodoo sync agent - Deployment: Docker Compose based staging/production setup - Scripts: Automated deployment with rollback support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
405
frontend/src/app/admin/dealers/page.tsx
Normal file
405
frontend/src/app/admin/dealers/page.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
interface DealerApplication {
|
||||
id: number;
|
||||
user_id: number;
|
||||
business_name: string;
|
||||
business_number: string | null;
|
||||
real_name: string;
|
||||
phone: string;
|
||||
bank_name: string;
|
||||
bank_account: string;
|
||||
account_holder: string;
|
||||
photo_url: string | null;
|
||||
status: string;
|
||||
rejected_reason: string | null;
|
||||
applied_at: string;
|
||||
approved_at: string | null;
|
||||
}
|
||||
|
||||
interface DealerInfo {
|
||||
id: number;
|
||||
user_id: number;
|
||||
dealer_code: string;
|
||||
business_name: string;
|
||||
real_name: string;
|
||||
phone: string;
|
||||
total_commission_earned: number;
|
||||
total_withdrawn: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function AdminDealersPage() {
|
||||
const { t, language } = useTranslation();
|
||||
const { user, token } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const [tab, setTab] = useState<'applications' | 'dealers'>('applications');
|
||||
const [applications, setApplications] = useState<DealerApplication[]>([]);
|
||||
const [dealers, setDealers] = useState<DealerInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState<number | null>(null);
|
||||
const [rejectModal, setRejectModal] = useState<{ id: number; reason: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.is_admin) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [user, router, tab]);
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (tab === 'applications') {
|
||||
const response = await fetch(`${API_BASE_URL}/api/dealer/admin/applications`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
setApplications(await response.json());
|
||||
}
|
||||
} else {
|
||||
const response = await fetch(`${API_BASE_URL}/api/dealer/admin/dealers`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
setDealers(await response.json());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async (applicationId: number) => {
|
||||
if (!token) return;
|
||||
setActionLoading(applicationId);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/dealer/admin/applications/${applicationId}/approve`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
fetchData();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.detail || 'Failed to approve');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Approve failed:', error);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!token || !rejectModal) return;
|
||||
setActionLoading(rejectModal.id);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/dealer/admin/applications/${rejectModal.id}/reject`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ reason: rejectModal.reason }),
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
setRejectModal(null);
|
||||
fetchData();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.detail || 'Failed to reject');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Reject failed:', error);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async (dealerId: number) => {
|
||||
if (!token) return;
|
||||
setActionLoading(dealerId);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/dealer/admin/dealers/${dealerId}/toggle-active`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
fetchData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Toggle failed:', error);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <span className="px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-sm">대기중</span>;
|
||||
case 'approved':
|
||||
return <span className="px-2 py-1 bg-green-100 text-green-700 rounded text-sm">승인됨</span>;
|
||||
case 'rejected':
|
||||
return <span className="px-2 py-1 bg-red-100 text-red-700 rounded text-sm">거부됨</span>;
|
||||
default:
|
||||
return <span className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm">{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
if (!user?.is_admin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 py-8">
|
||||
<div className="container mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">
|
||||
{language === 'ko' ? '딜러 관리' : 'Dealer Management'}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => setTab('applications')}
|
||||
className={`px-4 py-2 rounded-lg transition ${
|
||||
tab === 'applications'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{language === 'ko' ? '신청 목록' : 'Applications'}
|
||||
{applications.filter(a => a.status === 'pending').length > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-red-500 text-white text-xs rounded-full">
|
||||
{applications.filter(a => a.status === 'pending').length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('dealers')}
|
||||
className={`px-4 py-2 rounded-lg transition ${
|
||||
tab === 'dealers'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{language === 'ko' ? '딜러 목록' : 'Dealers'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
) : tab === 'applications' ? (
|
||||
/* Applications Table */
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<table className="min-w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">ID</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">상호명</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">실명</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">연락처</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">은행/계좌</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">신청일</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">상태</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{applications.map((app) => (
|
||||
<tr key={app.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm">{app.id}</td>
|
||||
<td className="px-4 py-3 text-sm font-medium">{app.business_name}</td>
|
||||
<td className="px-4 py-3 text-sm">{app.real_name}</td>
|
||||
<td className="px-4 py-3 text-sm">{app.phone}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<div>{app.bank_name}</div>
|
||||
<div className="text-gray-500 text-xs font-mono">{app.bank_account}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(app.applied_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{getStatusBadge(app.status)}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{app.status === 'pending' && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleApprove(app.id)}
|
||||
disabled={actionLoading === app.id}
|
||||
className="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === app.id ? '...' : '승인'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRejectModal({ id: app.id, reason: '' })}
|
||||
disabled={actionLoading === app.id}
|
||||
className="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
거부
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{app.status === 'rejected' && app.rejected_reason && (
|
||||
<span className="text-red-600 text-xs" title={app.rejected_reason}>
|
||||
사유: {app.rejected_reason.substring(0, 20)}...
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{applications.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-500">
|
||||
{language === 'ko' ? '신청이 없습니다' : 'No applications'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
/* Dealers Table */
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<table className="min-w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">딜러코드</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">상호명</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">실명</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">연락처</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">총 수수료</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">출금액</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">등록일</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">상태</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{dealers.map((dealer) => (
|
||||
<tr key={dealer.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm font-mono font-bold">{dealer.dealer_code}</td>
|
||||
<td className="px-4 py-3 text-sm font-medium">{dealer.business_name}</td>
|
||||
<td className="px-4 py-3 text-sm">{dealer.real_name}</td>
|
||||
<td className="px-4 py-3 text-sm">{dealer.phone}</td>
|
||||
<td className="px-4 py-3 text-sm text-green-600">
|
||||
{formatCurrency(dealer.total_commission_earned)}원
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-blue-600">
|
||||
{formatCurrency(dealer.total_withdrawn)}원
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(dealer.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{dealer.is_active ? (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 rounded text-sm">활성</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 bg-red-100 text-red-700 rounded text-sm">비활성</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<button
|
||||
onClick={() => handleToggleActive(dealer.id)}
|
||||
disabled={actionLoading === dealer.id}
|
||||
className={`px-3 py-1 text-white text-sm rounded disabled:opacity-50 ${
|
||||
dealer.is_active
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: 'bg-green-600 hover:bg-green-700'
|
||||
}`}
|
||||
>
|
||||
{actionLoading === dealer.id
|
||||
? '...'
|
||||
: dealer.is_active
|
||||
? '비활성화'
|
||||
: '활성화'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{dealers.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-8 text-center text-gray-500">
|
||||
{language === 'ko' ? '딜러가 없습니다' : 'No dealers'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reject Modal */}
|
||||
{rejectModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{language === 'ko' ? '신청 거부' : 'Reject Application'}
|
||||
</h3>
|
||||
<textarea
|
||||
value={rejectModal.reason}
|
||||
onChange={(e) => setRejectModal({ ...rejectModal, reason: e.target.value })}
|
||||
placeholder={language === 'ko' ? '거부 사유를 입력하세요...' : 'Enter rejection reason...'}
|
||||
className="w-full p-3 border border-gray-300 rounded-lg mb-4 h-32 resize-none"
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => setRejectModal(null)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
{language === 'ko' ? '취소' : 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReject}
|
||||
disabled={!rejectModal.reason || actionLoading === rejectModal.id}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === rejectModal.id ? '...' : language === 'ko' ? '거부' : 'Reject'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user