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:
AutonetSellCar Deploy
2025-12-30 13:24:39 +09:00
commit 1f0dcb1ddb
224 changed files with 55119 additions and 0 deletions

View 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>
);
}