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:
356
frontend/src/app/admin/withdrawals/page.tsx
Normal file
356
frontend/src/app/admin/withdrawals/page.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { withdrawalApi, WithdrawalRequest } from '@/lib/api';
|
||||
|
||||
export default function AdminWithdrawalsPage() {
|
||||
const { t, language } = useTranslation();
|
||||
const { user, token } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [requests, setRequests] = useState<WithdrawalRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState<number | null>(null);
|
||||
const [processModal, setProcessModal] = useState<{
|
||||
id: number;
|
||||
status: string;
|
||||
note: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.is_admin) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [user, router, statusFilter]);
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const data = await withdrawalApi.adminGetAllRequests(statusFilter || undefined);
|
||||
setRequests(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProcess = async () => {
|
||||
if (!token || !processModal) return;
|
||||
setActionLoading(processModal.id);
|
||||
|
||||
try {
|
||||
await withdrawalApi.adminProcessRequest(processModal.id, {
|
||||
status: processModal.status,
|
||||
admin_note: processModal.note || undefined,
|
||||
});
|
||||
|
||||
setProcessModal(null);
|
||||
fetchData();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Failed to process');
|
||||
} 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">{t.withdrawalPending}</span>;
|
||||
case 'approved':
|
||||
return <span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-sm">{t.withdrawalApproved}</span>;
|
||||
case 'completed':
|
||||
return <span className="px-2 py-1 bg-green-100 text-green-700 rounded text-sm">{t.withdrawalCompleted}</span>;
|
||||
case 'rejected':
|
||||
return <span className="px-2 py-1 bg-red-100 text-red-700 rounded text-sm">{t.withdrawalRejected}</span>;
|
||||
default:
|
||||
return <span className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm">{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const pendingCount = requests.filter(r => r.status === 'pending').length;
|
||||
|
||||
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 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">
|
||||
{language === 'ko' ? '출금 관리' : 'Withdrawal Management'}
|
||||
</h1>
|
||||
{pendingCount > 0 && (
|
||||
<p className="text-orange-600 mt-1">
|
||||
{language === 'ko'
|
||||
? `${pendingCount}건의 대기 중인 출금 요청이 있습니다`
|
||||
: `${pendingCount} pending withdrawal request(s)`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex gap-2 mb-6 flex-wrap">
|
||||
<button
|
||||
onClick={() => setStatusFilter('')}
|
||||
className={`px-4 py-2 rounded-lg transition ${
|
||||
statusFilter === ''
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{language === 'ko' ? '전체' : 'All'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('pending')}
|
||||
className={`px-4 py-2 rounded-lg transition ${
|
||||
statusFilter === 'pending'
|
||||
? 'bg-yellow-500 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{t.withdrawalPending}
|
||||
{pendingCount > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-red-500 text-white text-xs rounded-full">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('approved')}
|
||||
className={`px-4 py-2 rounded-lg transition ${
|
||||
statusFilter === 'approved'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{t.withdrawalApproved}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('completed')}
|
||||
className={`px-4 py-2 rounded-lg transition ${
|
||||
statusFilter === 'completed'
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{t.withdrawalCompleted}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('rejected')}
|
||||
className={`px-4 py-2 rounded-lg transition ${
|
||||
statusFilter === 'rejected'
|
||||
? 'bg-red-500 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{t.withdrawalRejected}
|
||||
</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>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<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">
|
||||
{language === 'ko' ? '유저ID' : 'User ID'}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
{t.withdrawalAmount}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
{t.taxWithheld}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
{t.netAmount}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
{language === 'ko' ? '은행/계좌' : 'Bank/Account'}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
{language === 'ko' ? '신청일' : 'Requested'}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
{t.status}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
{language === 'ko' ? '액션' : 'Action'}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{requests.map((request) => (
|
||||
<tr key={request.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm">{request.id}</td>
|
||||
<td className="px-4 py-3 text-sm">{request.user_id}</td>
|
||||
<td className="px-4 py-3 text-sm font-medium">
|
||||
{formatCurrency(request.amount)}원
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-red-600">
|
||||
-{formatCurrency(request.tax_withheld)}원
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-bold text-primary-600">
|
||||
{formatCurrency(request.net_amount)}원
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<div className="font-medium">{request.bank_name}</div>
|
||||
<div className="text-gray-500 text-xs font-mono">{request.bank_account}</div>
|
||||
<div className="text-gray-500 text-xs">{request.account_holder}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(request.requested_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{getStatusBadge(request.status)}
|
||||
{request.admin_note && (
|
||||
<div className="text-xs text-gray-500 mt-1" title={request.admin_note}>
|
||||
{request.admin_note.substring(0, 20)}...
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{request.status === 'pending' && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setProcessModal({
|
||||
id: request.id,
|
||||
status: 'approved',
|
||||
note: '',
|
||||
})}
|
||||
className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
|
||||
>
|
||||
{language === 'ko' ? '승인' : 'Approve'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setProcessModal({
|
||||
id: request.id,
|
||||
status: 'rejected',
|
||||
note: '',
|
||||
})}
|
||||
className="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700"
|
||||
>
|
||||
{language === 'ko' ? '거부' : 'Reject'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{request.status === 'approved' && (
|
||||
<button
|
||||
onClick={() => setProcessModal({
|
||||
id: request.id,
|
||||
status: 'completed',
|
||||
note: '',
|
||||
})}
|
||||
className="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700"
|
||||
>
|
||||
{language === 'ko' ? '완료 처리' : 'Complete'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{requests.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-8 text-center text-gray-500">
|
||||
{t.noWithdrawalHistory}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Process Modal */}
|
||||
{processModal && (
|
||||
<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">
|
||||
{processModal.status === 'approved' && (language === 'ko' ? '출금 승인' : 'Approve Withdrawal')}
|
||||
{processModal.status === 'completed' && (language === 'ko' ? '출금 완료 처리' : 'Complete Withdrawal')}
|
||||
{processModal.status === 'rejected' && (language === 'ko' ? '출금 거부' : 'Reject Withdrawal')}
|
||||
</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
{language === 'ko' ? '관리자 메모 (선택사항)' : 'Admin note (optional)'}
|
||||
</p>
|
||||
<textarea
|
||||
value={processModal.note}
|
||||
onChange={(e) => setProcessModal({ ...processModal, note: e.target.value })}
|
||||
placeholder={
|
||||
processModal.status === 'rejected'
|
||||
? (language === 'ko' ? '거부 사유를 입력하세요...' : 'Enter rejection reason...')
|
||||
: (language === 'ko' ? '메모를 입력하세요...' : 'Enter note...')
|
||||
}
|
||||
className="w-full p-3 border border-gray-300 rounded-lg h-24 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{processModal.status === 'completed' && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-sm text-yellow-700">
|
||||
{language === 'ko'
|
||||
? '완료 처리 후에는 취소할 수 없습니다. 실제 입금 후 처리해주세요.'
|
||||
: 'This action cannot be undone. Please confirm payment has been made.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => setProcessModal(null)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
{language === 'ko' ? '취소' : 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleProcess}
|
||||
disabled={actionLoading === processModal.id}
|
||||
className={`px-4 py-2 text-white rounded-lg disabled:opacity-50 ${
|
||||
processModal.status === 'rejected'
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: processModal.status === 'completed'
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{actionLoading === processModal.id
|
||||
? '...'
|
||||
: processModal.status === 'approved'
|
||||
? (language === 'ko' ? '승인' : 'Approve')
|
||||
: processModal.status === 'completed'
|
||||
? (language === 'ko' ? '완료' : 'Complete')
|
||||
: (language === 'ko' ? '거부' : 'Reject')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user