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:
2330
frontend/src/app/admin/cars/page.tsx
Normal file
2330
frontend/src/app/admin/cars/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
396
frontend/src/app/admin/dealer-translations/page.tsx
Normal file
396
frontend/src/app/admin/dealer-translations/page.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { carmodooApi } from '@/lib/api';
|
||||
|
||||
interface CarTranslation {
|
||||
id: number;
|
||||
car_name: string;
|
||||
dealer_description: string;
|
||||
has_en: boolean;
|
||||
has_mn: boolean;
|
||||
has_ru: boolean;
|
||||
}
|
||||
|
||||
interface CarTranslationDetail {
|
||||
car_id: number;
|
||||
car_name: string;
|
||||
dealer_description: string | null;
|
||||
translations: {
|
||||
en: string | null;
|
||||
mn: string | null;
|
||||
ru: string | null;
|
||||
};
|
||||
has_translations: boolean;
|
||||
papago_configured: boolean;
|
||||
}
|
||||
|
||||
export default function DealerTranslationsPage() {
|
||||
const [untranslatedCars, setUntranslatedCars] = useState<CarTranslation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedCar, setSelectedCar] = useState<CarTranslationDetail | null>(null);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editData, setEditData] = useState({
|
||||
dealer_description: '', // 한국어 원문
|
||||
dealer_description_en: '',
|
||||
dealer_description_mn: '',
|
||||
dealer_description_ru: '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
const [batchTranslating, setBatchTranslating] = useState(false);
|
||||
const [batchResult, setBatchResult] = useState<{
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadUntranslatedCars();
|
||||
}, []);
|
||||
|
||||
const loadUntranslatedCars = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await carmodooApi.getUntranslatedCars(50);
|
||||
setUntranslatedCars(data.cars);
|
||||
} catch (err) {
|
||||
console.error('Failed to load untranslated cars:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCarTranslations = async (carId: number) => {
|
||||
try {
|
||||
const data = await carmodooApi.getCarTranslations(carId);
|
||||
setSelectedCar(data);
|
||||
setEditData({
|
||||
dealer_description: data.dealer_description || '',
|
||||
dealer_description_en: data.translations.en || '',
|
||||
dealer_description_mn: data.translations.mn || '',
|
||||
dealer_description_ru: data.translations.ru || '',
|
||||
});
|
||||
setEditMode(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to load car translations:', err);
|
||||
alert('Failed to load translations');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedCar) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await carmodooApi.updateCarTranslations(selectedCar.car_id, editData);
|
||||
await loadCarTranslations(selectedCar.car_id);
|
||||
await loadUntranslatedCars();
|
||||
setEditMode(false);
|
||||
alert('Translations saved successfully');
|
||||
} catch (err) {
|
||||
console.error('Failed to save translations:', err);
|
||||
alert('Failed to save translations');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
if (!selectedCar) return;
|
||||
if (!confirm('Regenerate translations using Papago API? This will overwrite existing translations.')) return;
|
||||
|
||||
setRegenerating(true);
|
||||
try {
|
||||
const result = await carmodooApi.regenerateTranslations(selectedCar.car_id);
|
||||
setSelectedCar({
|
||||
...selectedCar,
|
||||
translations: result.translations,
|
||||
has_translations: true,
|
||||
});
|
||||
setEditData({
|
||||
dealer_description: selectedCar.dealer_description || '',
|
||||
dealer_description_en: result.translations.en || '',
|
||||
dealer_description_mn: result.translations.mn || '',
|
||||
dealer_description_ru: result.translations.ru || '',
|
||||
});
|
||||
await loadUntranslatedCars();
|
||||
alert('Translations regenerated successfully');
|
||||
} catch (err: any) {
|
||||
console.error('Failed to regenerate translations:', err);
|
||||
alert(err.response?.data?.detail || 'Failed to regenerate translations');
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchTranslate = async () => {
|
||||
if (!confirm('Translate all pending cars? This may take a while.')) return;
|
||||
|
||||
setBatchTranslating(true);
|
||||
setBatchResult(null);
|
||||
try {
|
||||
const result = await carmodooApi.translateAllPending();
|
||||
setBatchResult({
|
||||
total: result.total,
|
||||
success: result.success,
|
||||
failed: result.failed,
|
||||
});
|
||||
await loadUntranslatedCars();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to batch translate:', err);
|
||||
alert(err.response?.data?.detail || 'Failed to batch translate');
|
||||
} finally {
|
||||
setBatchTranslating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">Dealer Description Translations</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Manage translations for dealer descriptions before displaying to users
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleBatchTranslate}
|
||||
disabled={batchTranslating || untranslatedCars.length === 0}
|
||||
className="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{batchTranslating ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Translating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
|
||||
</svg>
|
||||
Translate All Pending
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Batch Result */}
|
||||
{batchResult && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<h3 className="font-medium text-green-800 mb-2">Batch Translation Complete</h3>
|
||||
<div className="flex gap-6">
|
||||
<div>
|
||||
<span className="text-gray-600">Total:</span> <span className="font-bold">{batchResult.total}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Success:</span> <span className="font-bold text-green-600">{batchResult.success}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Failed:</span> <span className="font-bold text-red-600">{batchResult.failed}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Untranslated Cars List */}
|
||||
<div className="bg-white rounded-xl shadow-sm">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h2 className="font-semibold text-gray-800">
|
||||
Cars Without Translations ({untranslatedCars.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="max-h-[600px] overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="w-8 h-8 border-4 border-primary-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
) : untranslatedCars.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
All cars have translations
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{untranslatedCars.map((car) => (
|
||||
<button
|
||||
key={car.id}
|
||||
onClick={() => loadCarTranslations(car.id)}
|
||||
className={`w-full p-4 text-left hover:bg-gray-50 transition ${
|
||||
selectedCar?.car_id === car.id ? 'bg-blue-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-800 truncate">{car.car_name}</div>
|
||||
<div className="text-sm text-gray-500 truncate mt-1">{car.dealer_description}</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${car.has_en ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
EN {car.has_en ? '✓' : '✗'}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${car.has_mn ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
MN {car.has_mn ? '✓' : '✗'}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${car.has_ru ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
RU {car.has_ru ? '✓' : '✗'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Translation Editor */}
|
||||
<div className="bg-white rounded-xl shadow-sm">
|
||||
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<h2 className="font-semibold text-gray-800">Translation Editor</h2>
|
||||
{selectedCar && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleRegenerate}
|
||||
disabled={regenerating || !selectedCar.dealer_description}
|
||||
className="px-3 py-1.5 bg-purple-100 text-purple-700 rounded hover:bg-purple-200 disabled:opacity-50 text-sm flex items-center gap-1"
|
||||
>
|
||||
{regenerating ? (
|
||||
<div className="w-4 h-4 border-2 border-purple-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
)}
|
||||
Regenerate
|
||||
</button>
|
||||
{!editMode ? (
|
||||
<button
|
||||
onClick={() => setEditMode(true)}
|
||||
className="px-3 py-1.5 bg-blue-100 text-blue-700 rounded hover:bg-blue-200 text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 text-sm"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditMode(false);
|
||||
if (selectedCar) {
|
||||
setEditData({
|
||||
dealer_description: selectedCar.dealer_description || '',
|
||||
dealer_description_en: selectedCar.translations.en || '',
|
||||
dealer_description_mn: selectedCar.translations.mn || '',
|
||||
dealer_description_ru: selectedCar.translations.ru || '',
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1.5 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedCar ? (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="text-sm text-gray-500 mb-2">
|
||||
Car: <span className="font-medium text-gray-800">{selectedCar.car_name}</span>
|
||||
{!selectedCar.papago_configured && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded text-xs">
|
||||
Papago API not configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Korean Original */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Korean Original (한국어 원문)
|
||||
</label>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editData.dealer_description}
|
||||
onChange={(e) => setEditData({ ...editData, dealer_description: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg p-3 text-sm min-h-[100px]"
|
||||
placeholder="Enter Korean description..."
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-sm text-gray-700 whitespace-pre-wrap max-h-40 overflow-y-auto">
|
||||
{selectedCar.dealer_description || <span className="text-gray-400 italic">No description</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* English */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
English Translation
|
||||
</label>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editData.dealer_description_en}
|
||||
onChange={(e) => setEditData({ ...editData, dealer_description_en: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg p-3 text-sm min-h-[100px]"
|
||||
placeholder="Enter English translation..."
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-blue-50 rounded-lg p-3 text-sm text-gray-700 whitespace-pre-wrap min-h-[60px]">
|
||||
{selectedCar.translations.en || <span className="text-gray-400 italic">Not translated</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mongolian */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Mongolian Translation (Монгол)
|
||||
</label>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editData.dealer_description_mn}
|
||||
onChange={(e) => setEditData({ ...editData, dealer_description_mn: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg p-3 text-sm min-h-[100px]"
|
||||
placeholder="Enter Mongolian translation..."
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-green-50 rounded-lg p-3 text-sm text-gray-700 whitespace-pre-wrap min-h-[60px]">
|
||||
{selectedCar.translations.mn || <span className="text-gray-400 italic">Not translated (Using English)</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Russian */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Russian Translation (Русский)
|
||||
</label>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editData.dealer_description_ru}
|
||||
onChange={(e) => setEditData({ ...editData, dealer_description_ru: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg p-3 text-sm min-h-[100px]"
|
||||
placeholder="Enter Russian translation..."
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-red-50 rounded-lg p-3 text-sm text-gray-700 whitespace-pre-wrap min-h-[60px]">
|
||||
{selectedCar.translations.ru || <span className="text-gray-400 italic">Not translated</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
Select a car from the list to view and edit translations
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
543
frontend/src/app/admin/hero-banners/page.tsx
Normal file
543
frontend/src/app/admin/hero-banners/page.tsx
Normal file
@@ -0,0 +1,543 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { heroBannersApi, adminPdfApi } from '@/lib/api';
|
||||
import { HeroBannerSettings } from '@/types';
|
||||
|
||||
interface BannerFormData {
|
||||
title_ko: string;
|
||||
title_en: string;
|
||||
title_mn: string;
|
||||
subtitle_ko: string;
|
||||
subtitle_en: string;
|
||||
subtitle_mn: string;
|
||||
image_url: string;
|
||||
link_url: string;
|
||||
is_active: boolean;
|
||||
display_order: number;
|
||||
car_id?: number | null; // 연결된 차량 ID (샘플 차량용)
|
||||
}
|
||||
|
||||
const defaultFormData: BannerFormData = {
|
||||
title_ko: '',
|
||||
title_en: '',
|
||||
title_mn: '',
|
||||
subtitle_ko: '',
|
||||
subtitle_en: '',
|
||||
subtitle_mn: '',
|
||||
image_url: '',
|
||||
link_url: '',
|
||||
is_active: true,
|
||||
display_order: 0,
|
||||
car_id: null,
|
||||
};
|
||||
|
||||
// 이미지 URL 변환 (로컬 경로는 백엔드 URL 추가)
|
||||
const getImageUrl = (url: string | undefined): string => {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
return `http://localhost:8000${url}`;
|
||||
};
|
||||
|
||||
export default function HeroBannersPage() {
|
||||
const [banners, setBanners] = useState<any[]>([]);
|
||||
const [settings, setSettings] = useState<HeroBannerSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState<BannerFormData>(defaultFormData);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [retryingPdfs, setRetryingPdfs] = useState(false);
|
||||
const [pdfStatus, setPdfStatus] = useState<Record<number, boolean>>({});
|
||||
|
||||
// PDF 재시도 함수
|
||||
const handleRetryFailedPdfs = async () => {
|
||||
if (!confirm('PDF가 없는 모든 차량에 대해 PDF 생성을 재시도합니다. 계속하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRetryingPdfs(true);
|
||||
try {
|
||||
const result = await adminPdfApi.retryAllFailed();
|
||||
if (result.total === 0) {
|
||||
alert('PDF가 없는 차량이 없습니다.');
|
||||
} else {
|
||||
alert(`PDF 재시도 완료!\n\n총: ${result.total}개\n성공: ${result.success}개\n실패: ${result.failed}개`);
|
||||
// Reload data to update PDF status
|
||||
await loadData();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('PDF retry failed:', error);
|
||||
alert('PDF 재시도 중 오류가 발생했습니다: ' + (error.response?.data?.detail || error.message));
|
||||
} finally {
|
||||
setRetryingPdfs(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [bannersData, settingsData] = await Promise.all([
|
||||
heroBannersApi.adminGetList(),
|
||||
heroBannersApi.getSettings(),
|
||||
]);
|
||||
setBanners(bannersData);
|
||||
setSettings(settingsData);
|
||||
|
||||
// Fetch PDF status for all banner cars
|
||||
const carIds = bannersData
|
||||
.filter((b: any) => b.car_id)
|
||||
.map((b: any) => b.car_id);
|
||||
if (carIds.length > 0) {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/api/carmodoo/pdf-status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(carIds),
|
||||
});
|
||||
if (response.ok) {
|
||||
const status = await response.json();
|
||||
setPdfStatus(status);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch PDF status:', err);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingId(null);
|
||||
setFormData(defaultFormData);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = async (id: number) => {
|
||||
try {
|
||||
const banner = await heroBannersApi.adminGetById(id);
|
||||
setFormData({
|
||||
title_ko: banner.title_ko || '',
|
||||
title_en: banner.title_en || '',
|
||||
title_mn: banner.title_mn || '',
|
||||
subtitle_ko: banner.subtitle_ko || '',
|
||||
subtitle_en: banner.subtitle_en || '',
|
||||
subtitle_mn: banner.subtitle_mn || '',
|
||||
image_url: banner.image_url || '',
|
||||
link_url: banner.link_url || '',
|
||||
is_active: banner.is_active ?? true,
|
||||
display_order: banner.display_order || 0,
|
||||
});
|
||||
setEditingId(id);
|
||||
setShowModal(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to load banner:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Are you sure you want to delete this banner?')) return;
|
||||
|
||||
try {
|
||||
await heroBannersApi.adminDelete(id);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete banner:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
if (editingId) {
|
||||
await heroBannersApi.adminUpdate(editingId, formData);
|
||||
} else {
|
||||
await heroBannersApi.adminCreate(formData);
|
||||
}
|
||||
setShowModal(false);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to save banner:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await heroBannersApi.adminUploadImage(file);
|
||||
setFormData({ ...formData, image_url: result.image_url });
|
||||
} catch (error) {
|
||||
console.error('Failed to upload image:', error);
|
||||
alert('Failed to upload image');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSettingsUpdate = async (newSettings: Partial<HeroBannerSettings>) => {
|
||||
try {
|
||||
const updated = await heroBannersApi.adminUpdateSettings(newSettings);
|
||||
setSettings(updated);
|
||||
} catch (error) {
|
||||
console.error('Failed to update settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Hero Banners</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleRetryFailedPdfs}
|
||||
disabled={retryingPdfs}
|
||||
className="bg-orange-500 text-white px-4 py-2 rounded-lg hover:bg-orange-600 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||
title="PDF가 없는 차량들의 PDF를 재생성합니다"
|
||||
>
|
||||
{retryingPdfs ? (
|
||||
<>
|
||||
<span className="animate-spin">⏳</span>
|
||||
<span>PDF 생성중...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>📄</span>
|
||||
<span>PDF 재시도</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Link
|
||||
href="/admin/cars"
|
||||
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span>🚗</span>
|
||||
<span>Add from Cars Page</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="bg-primary-600 text-white px-4 py-2 rounded-lg hover:bg-primary-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span>+</span>
|
||||
<span>Add Banner</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slider Settings */}
|
||||
{settings && (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">Slider Settings</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Slide Interval (ms)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.slide_interval}
|
||||
onChange={(e) => handleSettingsUpdate({ slide_interval: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Image Width (px)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.image_width}
|
||||
onChange={(e) => handleSettingsUpdate({ image_width: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Image Height (px)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.image_height}
|
||||
onChange={(e) => handleSettingsUpdate({ image_height: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Banners Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Image</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Order</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">PDF</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{banners.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
||||
No banners yet. Click "Add Banner" or "Add from Car Search" to create one.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
banners.map((banner) => (
|
||||
<tr key={banner.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="w-24 h-14 relative rounded overflow-hidden bg-gray-200">
|
||||
{banner.image_url ? (
|
||||
<img
|
||||
src={getImageUrl(banner.image_url)}
|
||||
alt={banner.title_en || 'Banner'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-xs">
|
||||
No image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<p className="font-medium text-gray-800">{banner.title_en || banner.title_ko || 'Untitled'}</p>
|
||||
{banner.subtitle_en && <p className="text-sm text-gray-500">{banner.subtitle_en}</p>}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-600">{banner.display_order}</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
{banner.car_id ? (
|
||||
pdfStatus[banner.car_id] ? (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700" title="PDF Available">
|
||||
✓ PDF
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700" title="PDF Not Available">
|
||||
✗ PDF
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${banner.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}>
|
||||
{banner.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right space-x-2">
|
||||
<button onClick={() => handleEdit(banner.id)} className="text-primary-600 hover:text-primary-800">Edit</button>
|
||||
<button onClick={() => handleDelete(banner.id)} className="text-red-600 hover:text-red-800">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Banner Edit Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-800">
|
||||
{editingId ? 'Edit Banner' : 'Add New Banner'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* Image Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Banner Image (500x300 recommended)
|
||||
</label>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-40 h-24 bg-gray-200 rounded-lg overflow-hidden flex-shrink-0">
|
||||
{formData.image_url ? (
|
||||
<img
|
||||
src={getImageUrl(formData.image_url)}
|
||||
alt="Preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||
No image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleImageUpload}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Upload Image'}
|
||||
</button>
|
||||
<p className="text-sm text-gray-500 mt-2">Or enter URL directly:</p>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.image_url}
|
||||
onChange={(e) => setFormData({ ...formData, image_url: e.target.value })}
|
||||
placeholder="https://..."
|
||||
className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Titles */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Title (Korean)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title_ko}
|
||||
onChange={(e) => setFormData({ ...formData, title_ko: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Title (English)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title_en}
|
||||
onChange={(e) => setFormData({ ...formData, title_en: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Title (Mongolian)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title_mn}
|
||||
onChange={(e) => setFormData({ ...formData, title_mn: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtitles */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Subtitle (Korean)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.subtitle_ko}
|
||||
onChange={(e) => setFormData({ ...formData, subtitle_ko: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Subtitle (English)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.subtitle_en}
|
||||
onChange={(e) => setFormData({ ...formData, subtitle_en: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Subtitle (Mongolian)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.subtitle_mn}
|
||||
onChange={(e) => setFormData({ ...formData, subtitle_mn: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Link URL (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.link_url}
|
||||
onChange={(e) => setFormData({ ...formData, link_url: e.target.value })}
|
||||
placeholder="/cars or https://..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Order & Status */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Display Order</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.display_order}
|
||||
onChange={(e) => setFormData({ ...formData, display_order: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<label className="flex items-center gap-2 mt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
className="w-5 h-5 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-gray-700">Active</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
||||
>
|
||||
{editingId ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
432
frontend/src/app/admin/inquiries/page.tsx
Normal file
432
frontend/src/app/admin/inquiries/page.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { inquiryApi, Inquiry, InquiryWithMessages, InquiryStats } from '@/lib/api';
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: '대기중',
|
||||
in_progress: '처리중',
|
||||
resolved: '해결됨',
|
||||
closed: '종료',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
in_progress: 'bg-blue-100 text-blue-800',
|
||||
resolved: 'bg-green-100 text-green-800',
|
||||
closed: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
general: '일반',
|
||||
vehicle: '차량',
|
||||
payment: '결제',
|
||||
shipping: '배송',
|
||||
dealer: '딜러',
|
||||
account: '계정',
|
||||
other: '기타',
|
||||
};
|
||||
|
||||
export default function AdminInquiriesPage() {
|
||||
const [inquiries, setInquiries] = useState<Inquiry[]>([]);
|
||||
const [stats, setStats] = useState<InquiryStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('');
|
||||
|
||||
// Modal state
|
||||
const [selectedInquiry, setSelectedInquiry] = useState<InquiryWithMessages | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [responseMessage, setResponseMessage] = useState('');
|
||||
const [responseStatus, setResponseStatus] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
useEffect(() => {
|
||||
fetchInquiries();
|
||||
fetchStats();
|
||||
}, [page, statusFilter, categoryFilter]);
|
||||
|
||||
const fetchInquiries = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await inquiryApi.adminGetInquiries(page, pageSize, statusFilter || undefined, categoryFilter || undefined);
|
||||
setInquiries(response.inquiries);
|
||||
setTotal(response.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch inquiries:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await inquiryApi.adminGetStats();
|
||||
setStats(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const openInquiryDetail = async (inquiry: Inquiry) => {
|
||||
try {
|
||||
const detail = await inquiryApi.adminGetInquiryDetail(inquiry.id);
|
||||
setSelectedInquiry(detail);
|
||||
setResponseStatus(detail.inquiry.status);
|
||||
setShowModal(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch inquiry detail:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRespond = async () => {
|
||||
if (!selectedInquiry || !responseMessage.trim()) return;
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
await inquiryApi.adminRespond(selectedInquiry.inquiry.id, {
|
||||
message: responseMessage.trim(),
|
||||
status: responseStatus || undefined
|
||||
});
|
||||
|
||||
// Refresh
|
||||
const detail = await inquiryApi.adminGetInquiryDetail(selectedInquiry.inquiry.id);
|
||||
setSelectedInquiry(detail);
|
||||
setResponseMessage('');
|
||||
fetchInquiries();
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
console.error('Failed to respond:', error);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (status: string) => {
|
||||
if (!selectedInquiry) return;
|
||||
|
||||
try {
|
||||
await inquiryApi.adminUpdateStatus(selectedInquiry.inquiry.id, status);
|
||||
const detail = await inquiryApi.adminGetInquiryDetail(selectedInquiry.inquiry.id);
|
||||
setSelectedInquiry(detail);
|
||||
setResponseStatus(status);
|
||||
fetchInquiries();
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
console.error('Failed to update status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">문의 관리</h1>
|
||||
<p className="text-gray-600 mt-1">고객 문의를 관리하고 응답합니다.</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<p className="text-gray-500 text-sm">전체</p>
|
||||
<p className="text-2xl font-bold">{stats.total}</p>
|
||||
</div>
|
||||
<div className="bg-yellow-50 p-4 rounded-lg shadow-sm">
|
||||
<p className="text-yellow-600 text-sm">대기중</p>
|
||||
<p className="text-2xl font-bold text-yellow-700">{stats.pending}</p>
|
||||
</div>
|
||||
<div className="bg-blue-50 p-4 rounded-lg shadow-sm">
|
||||
<p className="text-blue-600 text-sm">처리중</p>
|
||||
<p className="text-2xl font-bold text-blue-700">{stats.in_progress}</p>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg shadow-sm">
|
||||
<p className="text-green-600 text-sm">해결됨</p>
|
||||
<p className="text-2xl font-bold text-green-700">{stats.resolved}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-lg shadow-sm">
|
||||
<p className="text-gray-600 text-sm">종료</p>
|
||||
<p className="text-2xl font-bold text-gray-700">{stats.closed}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white p-4 rounded-lg shadow-md">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">상태</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="pending">대기중</option>
|
||||
<option value="in_progress">처리중</option>
|
||||
<option value="resolved">해결됨</option>
|
||||
<option value="closed">종료</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">카테고리</label>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => { setCategoryFilter(e.target.value); setPage(1); }}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="general">일반</option>
|
||||
<option value="vehicle">차량</option>
|
||||
<option value="payment">결제</option>
|
||||
<option value="shipping">배송</option>
|
||||
<option value="dealer">딜러</option>
|
||||
<option value="account">계정</option>
|
||||
<option value="other">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inquiry List */}
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-600 border-t-transparent rounded-full mx-auto"></div>
|
||||
</div>
|
||||
) : inquiries.length === 0 ? (
|
||||
<div className="p-12 text-center text-gray-500">
|
||||
문의가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">카테고리</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">제목</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">상태</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">등록일</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{inquiries.map((inquiry) => (
|
||||
<tr key={inquiry.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm text-gray-500">#{inquiry.id}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm bg-gray-100 px-2 py-1 rounded">
|
||||
{CATEGORY_LABELS[inquiry.category] || inquiry.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm font-medium text-gray-900 truncate max-w-xs">
|
||||
{inquiry.subject || '제목 없음'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate max-w-xs">
|
||||
{inquiry.message.substring(0, 50)}...
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${STATUS_COLORS[inquiry.status]}`}>
|
||||
{STATUS_LABELS[inquiry.status] || inquiry.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{formatDate(inquiry.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => openInquiryDetail(inquiry)}
|
||||
className="text-primary-600 hover:text-primary-700 text-sm font-medium"
|
||||
>
|
||||
상세보기
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 bg-white rounded-lg shadow disabled:opacity-50"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<span className="px-4 py-2 text-gray-600">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-4 py-2 bg-white rounded-lg shadow disabled:opacity-50"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail Modal */}
|
||||
{showModal && selectedInquiry && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Modal Header */}
|
||||
<div className="p-6 border-b sticky top-0 bg-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">문의 상세 #{selectedInquiry.inquiry.id}</h2>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="p-2 hover:bg-gray-100 rounded-full"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Inquiry Info */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">카테고리:</span>
|
||||
<span className="ml-2 font-medium">{CATEGORY_LABELS[selectedInquiry.inquiry.category] || selectedInquiry.inquiry.category}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">상태:</span>
|
||||
<span className={`ml-2 px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[selectedInquiry.inquiry.status]}`}>
|
||||
{STATUS_LABELS[selectedInquiry.inquiry.status] || selectedInquiry.inquiry.status}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">이메일:</span>
|
||||
<span className="ml-2 font-medium">{selectedInquiry.inquiry.contact_email || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">전화번호:</span>
|
||||
<span className="ml-2 font-medium">{selectedInquiry.inquiry.contact_phone || '-'}</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500">등록일:</span>
|
||||
<span className="ml-2 font-medium">{formatDate(selectedInquiry.inquiry.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Original Message */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700 mb-2">문의 내용</h3>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="font-medium mb-2">{selectedInquiry.inquiry.subject || '제목 없음'}</p>
|
||||
<p className="text-gray-700 whitespace-pre-wrap">{selectedInquiry.inquiry.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Thread */}
|
||||
{selectedInquiry.messages.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700 mb-2">대화 내역</h3>
|
||||
<div className="space-y-3">
|
||||
{selectedInquiry.messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`p-3 rounded-lg ${
|
||||
msg.is_admin
|
||||
? 'bg-primary-50 border-l-4 border-primary-500'
|
||||
: 'bg-gray-50 border-l-4 border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={`text-xs font-medium ${msg.is_admin ? 'text-primary-600' : 'text-gray-600'}`}>
|
||||
{msg.is_admin ? '관리자' : '고객'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{formatDate(msg.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap">{msg.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Update */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700 mb-2">상태 변경</h3>
|
||||
<div className="flex gap-2">
|
||||
{['pending', 'in_progress', 'resolved', 'closed'].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => handleStatusChange(status)}
|
||||
className={`px-3 py-1 rounded-lg text-sm transition ${
|
||||
selectedInquiry.inquiry.status === status
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{STATUS_LABELS[status]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response Form */}
|
||||
{selectedInquiry.inquiry.status !== 'closed' && (
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700 mb-2">답변 작성</h3>
|
||||
<textarea
|
||||
value={responseMessage}
|
||||
onChange={(e) => setResponseMessage(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="답변 내용을 입력하세요..."
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<select
|
||||
value={responseStatus}
|
||||
onChange={(e) => setResponseStatus(e.target.value)}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">상태 유지</option>
|
||||
<option value="in_progress">처리중으로 변경</option>
|
||||
<option value="resolved">해결됨으로 변경</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleRespond}
|
||||
disabled={sending || !responseMessage.trim()}
|
||||
className="bg-primary-600 text-white px-6 py-2 rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{sending ? '전송중...' : '답변 전송'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
frontend/src/app/admin/layout.tsx
Normal file
195
frontend/src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
|
||||
const menuItems = [
|
||||
{ href: '/admin', label: 'Dashboard', icon: '📊' },
|
||||
{ href: '/admin/visitor-stats', label: 'Visitor Stats', icon: '👁️' },
|
||||
{ href: '/admin/hero-banners', label: 'Hero Banners', icon: '🖼️' },
|
||||
{ href: '/admin/cars', label: 'Cars', icon: '🚗' },
|
||||
{ href: '/admin/vehicle-requests', label: 'Vehicle Requests', icon: '📋' },
|
||||
{ href: '/admin/purchased', label: 'Purchased Vehicles', icon: '📦' },
|
||||
{ href: '/admin/dealers', label: 'Dealers', icon: '🤝' },
|
||||
{ href: '/admin/payments', label: 'Payments', icon: '💳' },
|
||||
{ href: '/admin/withdrawals', label: 'Withdrawals', icon: '💸' },
|
||||
{ href: '/admin/notifications', label: 'Notifications', icon: '🔔' },
|
||||
{ href: '/admin/translations', label: 'Translations', icon: '🌐' },
|
||||
{ href: '/admin/dealer-translations', label: 'Dealer Descriptions', icon: '📝' },
|
||||
{ href: '/admin/users', label: 'Users', icon: '👥' },
|
||||
{ href: '/admin/inquiries', label: 'Inquiries', icon: '💬' },
|
||||
{ href: '/admin/settings', label: 'Settings', icon: '⚙️' },
|
||||
];
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { user, token, logout, isLoading: authLoading } = useAuthStore();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
|
||||
// 디버그 로그
|
||||
useEffect(() => {
|
||||
console.log('Admin Layout Debug:', {
|
||||
pathname,
|
||||
token: token ? 'exists' : 'null',
|
||||
user: user ? { id: user.id, email: user.email, is_admin: user.is_admin } : null,
|
||||
authLoading
|
||||
});
|
||||
}, [pathname, token, user, authLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
// 로그인 페이지는 체크 필요 없음
|
||||
if (pathname === '/admin/login') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 아직 로딩 중이면 대기
|
||||
if (authLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 토큰이 없으면 로그인 페이지로
|
||||
if (!token) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// user 정보가 없으면 로그인 페이지로
|
||||
if (!user) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// 관리자가 아니면 홈으로
|
||||
if (!user.is_admin) {
|
||||
console.log('User is not admin, redirecting to home');
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
}, [pathname, router, token, user, authLoading]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
router.push('/admin/login');
|
||||
};
|
||||
|
||||
// 로그인 페이지는 레이아웃 없이 렌더링
|
||||
if (pathname === '/admin/login') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// 로딩 중
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 토큰 없음 또는 유저 없음 또는 관리자 아님 -> 빈 화면 (리다이렉트 될 예정)
|
||||
if (!token || !user || !user.is_admin) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`${
|
||||
sidebarOpen ? 'w-64' : 'w-20'
|
||||
} bg-gray-900 text-white transition-all duration-300 flex flex-col`}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="p-4 border-b border-gray-800">
|
||||
<Link href="/admin" className="flex items-center gap-3">
|
||||
<span className="text-2xl">🚗</span>
|
||||
{sidebarOpen && (
|
||||
<span className="font-bold text-lg">AutonetSellCar</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 py-4">
|
||||
{menuItems.map((item) => {
|
||||
const isActive = pathname === item.href ||
|
||||
(item.href !== '/admin' && pathname.startsWith(item.href));
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-4 py-3 transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="text-xl">{item.icon}</span>
|
||||
{sidebarOpen && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-800">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-3 text-gray-300 hover:text-white transition-colors w-full"
|
||||
>
|
||||
<span className="text-xl">🚪</span>
|
||||
{sidebarOpen && <span>Logout</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Top Bar */}
|
||||
<header className="bg-white shadow-sm h-16 flex items-center justify-between px-6">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
target="_blank"
|
||||
className="text-sm text-gray-600 hover:text-primary-600 flex items-center gap-1"
|
||||
>
|
||||
<span>View Site</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div className="w-8 h-8 rounded-full bg-primary-600 text-white flex items-center justify-center font-bold">
|
||||
{user?.name?.charAt(0).toUpperCase() || user?.email?.charAt(0).toUpperCase() || 'A'}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 p-6 overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
frontend/src/app/admin/login/page.tsx
Normal file
139
frontend/src/app/admin/login/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { authApi } from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const router = useRouter();
|
||||
const { setToken, setUser } = useAuthStore();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// 1. 로그인 및 토큰 저장
|
||||
const { access_token } = await authApi.login(email, password);
|
||||
setToken(access_token); // store와 localStorage 모두 업데이트
|
||||
|
||||
// 2. 사용자 정보 로드
|
||||
const user = await authApi.getMe();
|
||||
setUser(user);
|
||||
|
||||
// 3. 관리자 권한 확인
|
||||
if (!user.is_admin) {
|
||||
setError('Admin access required. You are not an administrator.');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/admin');
|
||||
} catch (err: any) {
|
||||
console.error('Login failed:', err);
|
||||
setError(err.response?.data?.detail || 'Invalid email or password');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-700 to-primary-900 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-8">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary-100 rounded-full mb-4">
|
||||
<span className="text-3xl">🚗</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">AutonetSellCar</h1>
|
||||
<p className="text-gray-500 mt-1">Admin Panel</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors"
|
||||
placeholder="admin@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary-600 text-white font-semibold py-3 rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
<span>Signing in...</span>
|
||||
</>
|
||||
) : (
|
||||
'Sign In'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 text-center text-sm text-gray-500">
|
||||
<a href="/" className="text-primary-600 hover:text-primary-700">
|
||||
← Back to Website
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
frontend/src/app/admin/notifications/page.tsx
Normal file
199
frontend/src/app/admin/notifications/page.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { notificationApi } from '@/lib/api';
|
||||
|
||||
export default function AdminNotificationsPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [link, setLink] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
const handleSendToAll = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!title.trim() || !message.trim()) {
|
||||
setResult({ success: false, message: '제목과 내용을 입력해주세요.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const response = await notificationApi.adminSendToAll(
|
||||
title.trim(),
|
||||
message.trim(),
|
||||
link.trim() || undefined
|
||||
);
|
||||
setResult({ success: true, message: response.message });
|
||||
setTitle('');
|
||||
setMessage('');
|
||||
setLink('');
|
||||
} catch (error) {
|
||||
console.error('Failed to send notification:', error);
|
||||
setResult({ success: false, message: '알림 발송에 실패했습니다.' });
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">알림 관리</h1>
|
||||
<p className="text-gray-600 mt-1">전체 사용자에게 알림을 보낼 수 있습니다.</p>
|
||||
</div>
|
||||
|
||||
{/* Send Notification Form */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">전체 알림 발송</h2>
|
||||
|
||||
<form onSubmit={handleSendToAll} className="space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
제목 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="알림 제목"
|
||||
maxLength={200}
|
||||
disabled={sending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
내용 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="알림 내용"
|
||||
rows={4}
|
||||
disabled={sending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Link (optional) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
링크 (선택사항)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="/page-path (클릭 시 이동할 페이지)"
|
||||
disabled={sending}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
예: /my-request, /charge, /cost
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Result Message */}
|
||||
{result && (
|
||||
<div className={`p-4 rounded-lg ${result.success ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>
|
||||
{result.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-lg hover:bg-primary-700 transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{sending ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
발송 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
전체 사용자에게 발송
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Notification Types Info */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">알림 유형 안내</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-2xl">🚗</span>
|
||||
<div>
|
||||
<p className="font-medium">차량 추천</p>
|
||||
<p className="text-xs text-gray-500">사용자 요청에 차량 추천 시</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-2xl">🚚</span>
|
||||
<div>
|
||||
<p className="font-medium">배송 업데이트</p>
|
||||
<p className="text-xs text-gray-500">배송 상태 변경 시</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-2xl">💰</span>
|
||||
<div>
|
||||
<p className="font-medium">출금 처리</p>
|
||||
<p className="text-xs text-gray-500">출금 신청 처리 시</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-2xl">🎁</span>
|
||||
<div>
|
||||
<p className="font-medium">레퍼럴 보상</p>
|
||||
<p className="text-xs text-gray-500">추천인 보상 적립 시</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-2xl">✅</span>
|
||||
<div>
|
||||
<p className="font-medium">딜러 승인</p>
|
||||
<p className="text-xs text-gray-500">딜러 신청 승인/거부 시</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-2xl">🎉</span>
|
||||
<div>
|
||||
<p className="font-medium">공유 판매</p>
|
||||
<p className="text-xs text-gray-500">공유 차량 판매 시</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
위 알림은 관련 이벤트 발생 시 자동으로 발송됩니다.
|
||||
이 페이지에서는 시스템 공지사항을 전체 사용자에게 수동으로 보낼 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
408
frontend/src/app/admin/page.tsx
Normal file
408
frontend/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
dashboardApi,
|
||||
DashboardStats,
|
||||
RevenueStats,
|
||||
ChartData,
|
||||
RecentActivity,
|
||||
TopDealer,
|
||||
PendingActions,
|
||||
} from '@/lib/api';
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [revenue, setRevenue] = useState<RevenueStats | null>(null);
|
||||
const [userChart, setUserChart] = useState<ChartData | null>(null);
|
||||
const [requestChart, setRequestChart] = useState<ChartData | null>(null);
|
||||
const [recentActivities, setRecentActivities] = useState<RecentActivity[]>([]);
|
||||
const [topDealers, setTopDealers] = useState<TopDealer[]>([]);
|
||||
const [pendingActions, setPendingActions] = useState<PendingActions | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [chartDays, setChartDays] = useState(14);
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadChartData();
|
||||
}, [chartDays]);
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
const [statsData, revenueData, activitiesData, dealersData, pendingData] = await Promise.all([
|
||||
dashboardApi.getStats(),
|
||||
dashboardApi.getRevenue(),
|
||||
dashboardApi.getRecentActivities(10),
|
||||
dashboardApi.getTopDealers(5),
|
||||
dashboardApi.getPendingActions(),
|
||||
]);
|
||||
setStats(statsData);
|
||||
setRevenue(revenueData);
|
||||
setRecentActivities(activitiesData);
|
||||
setTopDealers(dealersData);
|
||||
setPendingActions(pendingData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadChartData = async () => {
|
||||
try {
|
||||
const [userData, requestData] = await Promise.all([
|
||||
dashboardApi.getUserChart(chartDays),
|
||||
dashboardApi.getRequestChart(chartDays),
|
||||
]);
|
||||
setUserChart(userData);
|
||||
setRequestChart(requestData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load chart data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return new Intl.NumberFormat('ko-KR').format(num);
|
||||
};
|
||||
|
||||
const formatCurrency = (num: number) => {
|
||||
return new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(num);
|
||||
};
|
||||
|
||||
const getActivityIcon = (icon: string) => {
|
||||
switch (icon) {
|
||||
case 'user': return '👤';
|
||||
case 'car': return '🚗';
|
||||
case 'message': return '💬';
|
||||
case 'badge': return '🎫';
|
||||
case 'wallet': return '💰';
|
||||
default: return '📌';
|
||||
}
|
||||
};
|
||||
|
||||
const getTimeAgo = (time: string) => {
|
||||
if (!time) return '';
|
||||
const now = new Date();
|
||||
const then = new Date(time);
|
||||
const diff = Math.floor((now.getTime() - then.getTime()) / 1000);
|
||||
|
||||
if (diff < 60) return `${diff}초 전`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}분 전`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}시간 전`;
|
||||
return `${Math.floor(diff / 86400)}일 전`;
|
||||
};
|
||||
|
||||
const SimpleBarChart = ({ data, color = 'bg-primary-500', height = 100 }: { data: ChartData | null; color?: string; height?: number }) => {
|
||||
if (!data || data.values.length === 0) return <div className="text-gray-400 text-center py-4">No data</div>;
|
||||
|
||||
const maxValue = Math.max(...data.values, 1);
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-1" style={{ height }}>
|
||||
{data.values.map((value, index) => (
|
||||
<div key={index} className="flex-1 flex flex-col items-center group">
|
||||
<div className="hidden group-hover:block absolute -mt-6 bg-gray-800 text-white text-xs px-2 py-1 rounded">
|
||||
{value}
|
||||
</div>
|
||||
<div
|
||||
className={`w-full ${color} rounded-t transition-all hover:opacity-80`}
|
||||
style={{ height: `${(value / maxValue) * 100}%`, minHeight: value > 0 ? '4px' : '0' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Dashboard</h1>
|
||||
<button
|
||||
onClick={loadDashboardData}
|
||||
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm flex items-center gap-2"
|
||||
>
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pending Actions Alert */}
|
||||
{pendingActions && pendingActions.total_pending > 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 text-amber-700 font-medium mb-3">
|
||||
<span className="text-xl">⚠️</span>
|
||||
<span>Pending Actions Required ({pendingActions.total_pending})</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{pendingActions.pending_requests > 0 && (
|
||||
<Link href="/admin/vehicle-requests" className="flex items-center gap-2 p-2 bg-white rounded-lg hover:shadow">
|
||||
<span className="text-lg">🚗</span>
|
||||
<span className="text-sm">Vehicle Requests: {pendingActions.pending_requests}</span>
|
||||
</Link>
|
||||
)}
|
||||
{pendingActions.pending_inquiries > 0 && (
|
||||
<Link href="/admin/inquiries" className="flex items-center gap-2 p-2 bg-white rounded-lg hover:shadow">
|
||||
<span className="text-lg">💬</span>
|
||||
<span className="text-sm">Inquiries: {pendingActions.pending_inquiries}</span>
|
||||
</Link>
|
||||
)}
|
||||
{pendingActions.pending_dealer_applications > 0 && (
|
||||
<Link href="/admin/dealers" className="flex items-center gap-2 p-2 bg-white rounded-lg hover:shadow">
|
||||
<span className="text-lg">🎫</span>
|
||||
<span className="text-sm">Dealer Apps: {pendingActions.pending_dealer_applications}</span>
|
||||
</Link>
|
||||
)}
|
||||
{pendingActions.pending_withdrawals > 0 && (
|
||||
<Link href="/admin/withdrawals" className="flex items-center gap-2 p-2 bg-white rounded-lg hover:shadow">
|
||||
<span className="text-lg">💰</span>
|
||||
<span className="text-sm">Withdrawals: {pendingActions.pending_withdrawals}</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Link href="/admin/users" className="bg-white rounded-xl shadow-sm p-5 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">Total Users</p>
|
||||
<p className="text-2xl font-bold text-gray-800 mt-1">{formatNumber(stats?.total_users || 0)}</p>
|
||||
<p className="text-xs text-green-600 mt-1">+{stats?.new_users_this_week || 0} this week</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center text-2xl">
|
||||
👥
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/dealers" className="bg-white rounded-xl shadow-sm p-5 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">Active Dealers</p>
|
||||
<p className="text-2xl font-bold text-gray-800 mt-1">{formatNumber(stats?.total_dealers || 0)}</p>
|
||||
{(stats?.pending_dealer_applications || 0) > 0 && (
|
||||
<p className="text-xs text-amber-600 mt-1">{stats?.pending_dealer_applications} pending</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center text-2xl">
|
||||
🎫
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/vehicle-requests" className="bg-white rounded-xl shadow-sm p-5 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">Vehicle Requests</p>
|
||||
<p className="text-2xl font-bold text-gray-800 mt-1">{formatNumber(stats?.total_vehicle_requests || 0)}</p>
|
||||
{(stats?.pending_requests || 0) > 0 && (
|
||||
<p className="text-xs text-amber-600 mt-1">{stats?.pending_requests} pending</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center text-2xl">
|
||||
🚗
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/purchased" className="bg-white rounded-xl shadow-sm p-5 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">Purchased Vehicles</p>
|
||||
<p className="text-2xl font-bold text-gray-800 mt-1">{formatNumber(stats?.total_purchased_vehicles || 0)}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-amber-100 rounded-lg flex items-center justify-center text-2xl">
|
||||
📦
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Revenue & CC Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 rounded-xl p-5 text-white">
|
||||
<p className="text-blue-100 text-sm">Total CC Charged</p>
|
||||
<p className="text-3xl font-bold mt-1">{formatNumber(stats?.total_cc_charged || 0)} CC</p>
|
||||
<div className="mt-2 flex items-center gap-2 text-sm text-blue-100">
|
||||
<span>Revenue this month:</span>
|
||||
<span className="font-semibold text-white">{formatCurrency(revenue?.revenue_this_month || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-r from-green-500 to-green-600 rounded-xl p-5 text-white">
|
||||
<p className="text-green-100 text-sm">Share Rewards</p>
|
||||
<p className="text-3xl font-bold mt-1">{formatNumber(stats?.total_shares || 0)}</p>
|
||||
<div className="mt-2 flex items-center gap-2 text-sm text-green-100">
|
||||
<span>Purchased:</span>
|
||||
<span className="font-semibold text-white">{formatNumber(stats?.purchased_shares || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link href="/admin/withdrawals" className="bg-gradient-to-r from-purple-500 to-purple-600 rounded-xl p-5 text-white hover:opacity-90">
|
||||
<p className="text-purple-100 text-sm">Total Withdrawals</p>
|
||||
<p className="text-3xl font-bold mt-1">{formatCurrency(stats?.total_withdrawal_amount || 0)}</p>
|
||||
{(stats?.pending_withdrawals || 0) > 0 && (
|
||||
<div className="mt-2 flex items-center gap-2 text-sm">
|
||||
<span className="bg-white/20 px-2 py-0.5 rounded">{stats?.pending_withdrawals} pending</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-800">User Registrations</h3>
|
||||
<select
|
||||
value={chartDays}
|
||||
onChange={(e) => setChartDays(Number(e.target.value))}
|
||||
className="text-sm border rounded px-2 py-1"
|
||||
>
|
||||
<option value={7}>7 days</option>
|
||||
<option value={14}>14 days</option>
|
||||
<option value={30}>30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<SimpleBarChart data={userChart} color="bg-blue-500" height={120} />
|
||||
{userChart && (
|
||||
<div className="flex justify-between mt-2 text-xs text-gray-400 overflow-hidden">
|
||||
<span>{userChart.labels[0]}</span>
|
||||
<span>{userChart.labels[userChart.labels.length - 1]}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-800">Vehicle Requests</h3>
|
||||
</div>
|
||||
<SimpleBarChart data={requestChart} color="bg-purple-500" height={120} />
|
||||
{requestChart && (
|
||||
<div className="flex justify-between mt-2 text-xs text-gray-400 overflow-hidden">
|
||||
<span>{requestChart.labels[0]}</span>
|
||||
<span>{requestChart.labels[requestChart.labels.length - 1]}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two Column Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">Recent Activity</h3>
|
||||
<div className="space-y-3">
|
||||
{recentActivities.length === 0 ? (
|
||||
<p className="text-gray-400 text-center py-4">No recent activity</p>
|
||||
) : (
|
||||
recentActivities.map((activity, index) => (
|
||||
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-xl">{getActivityIcon(activity.icon)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-800 truncate">{activity.title}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{activity.description}</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap">{getTimeAgo(activity.time)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Dealers */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-800">Top Dealers</h3>
|
||||
<Link href="/admin/dealers" className="text-sm text-primary-600 hover:underline">View all</Link>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{topDealers.length === 0 ? (
|
||||
<p className="text-gray-400 text-center py-4">No dealers yet</p>
|
||||
) : (
|
||||
topDealers.map((dealer, index) => (
|
||||
<div key={dealer.id} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="w-8 h-8 bg-gradient-to-r from-amber-400 to-amber-500 rounded-full flex items-center justify-center text-white font-bold text-sm">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-800">{dealer.name}</p>
|
||||
<p className="text-xs text-gray-500">{dealer.dealer_code}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-gray-800">{dealer.total_sales} sales</p>
|
||||
<p className="text-xs text-green-600">{formatCurrency(dealer.total_commission)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">Quick Actions</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Link
|
||||
href="/admin/hero-banners"
|
||||
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-colors"
|
||||
>
|
||||
<span className="text-2xl">🖼️</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">Hero Banners</p>
|
||||
<p className="text-xs text-gray-500">Manage slider</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/admin/cars"
|
||||
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-colors"
|
||||
>
|
||||
<span className="text-2xl">🚙</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">Car Listings</p>
|
||||
<p className="text-xs text-gray-500">{formatNumber(stats?.total_cars || 0)} cars</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/admin/settings"
|
||||
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-colors"
|
||||
>
|
||||
<span className="text-2xl">⚙️</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">Settings</p>
|
||||
<p className="text-xs text-gray-500">System config</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/admin/notifications"
|
||||
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-colors"
|
||||
>
|
||||
<span className="text-2xl">🔔</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">Notifications</p>
|
||||
<p className="text-xs text-gray-500">Send alerts</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
403
frontend/src/app/admin/payments/page.tsx
Normal file
403
frontend/src/app/admin/payments/page.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ccApi, AdminPayment } from '@/lib/api';
|
||||
|
||||
export default function AdminPaymentsPage() {
|
||||
const [payments, setPayments] = useState<AdminPayment[]>([]);
|
||||
const [pendingPayments, setPendingPayments] = useState<AdminPayment[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedPayment, setSelectedPayment] = useState<AdminPayment | null>(null);
|
||||
const [adminNote, setAdminNote] = useState('');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [page, statusFilter]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [pendingData, allData] = await Promise.all([
|
||||
ccApi.adminGetPendingPayments(),
|
||||
ccApi.adminGetAllPayments({ status: statusFilter || undefined, page, page_size: pageSize }),
|
||||
]);
|
||||
setPendingPayments(pendingData);
|
||||
setPayments(allData.payments);
|
||||
setTotal(allData.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to load payments:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async (approved: boolean) => {
|
||||
if (!selectedPayment) return;
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
await ccApi.adminVerifyPayment(selectedPayment.id, approved, adminNote);
|
||||
setSelectedPayment(null);
|
||||
setAdminNote('');
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Failed to process payment');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <span className="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs">Completed</span>;
|
||||
case 'pending':
|
||||
return <span className="px-2 py-1 bg-yellow-100 text-yellow-700 rounded-full text-xs">Pending</span>;
|
||||
case 'rejected':
|
||||
return <span className="px-2 py-1 bg-red-100 text-red-700 rounded-full text-xs">Rejected</span>;
|
||||
default:
|
||||
return <span className="px-2 py-1 bg-gray-100 text-gray-700 rounded-full text-xs">{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const getMethodBadge = (method: string) => {
|
||||
switch (method) {
|
||||
case 'usdc':
|
||||
return <span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">USDC</span>;
|
||||
case 'bank_transfer':
|
||||
return <span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">Bank</span>;
|
||||
case 'card':
|
||||
return <span className="px-2 py-1 bg-purple-100 text-purple-700 rounded text-xs">Card</span>;
|
||||
default:
|
||||
return <span className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs">{method}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleString('ko-KR');
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Payment Management</h1>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pending Payments Alert */}
|
||||
{pendingPayments.length > 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<h3 className="font-semibold text-amber-800 mb-3">
|
||||
Pending Payments ({pendingPayments.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{pendingPayments.slice(0, 5).map((payment) => (
|
||||
<div
|
||||
key={payment.id}
|
||||
className="flex items-center justify-between p-3 bg-white rounded-lg cursor-pointer hover:shadow"
|
||||
onClick={() => setSelectedPayment(payment)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{getMethodBadge(payment.payment_method)}
|
||||
<div>
|
||||
<p className="font-medium">{payment.user_email}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{payment.amount} {payment.currency} = {payment.cc_amount} CC
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-500">{formatDate(payment.created_at)}</p>
|
||||
<button className="text-primary-600 text-sm hover:underline">Review</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
<span className="text-gray-500 self-center">Total: {total} payments</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payments Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
) : payments.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">No payments found</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">User</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Amount</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">CC</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Method</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">TX Hash</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{payments.map((payment) => (
|
||||
<tr key={payment.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm text-gray-800">{payment.id}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<div>{payment.user_email}</div>
|
||||
<div className="text-xs text-gray-500">{payment.user_name || '-'}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-medium">
|
||||
{payment.amount} {payment.currency}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-blue-600 font-semibold">
|
||||
+{payment.cc_amount} CC
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{getMethodBadge(payment.payment_method)}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{payment.transaction_id ? (
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded truncate max-w-[100px] block">
|
||||
{payment.transaction_id.slice(0, 10)}...
|
||||
</code>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{getStatusBadge(payment.status)}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">{formatDate(payment.created_at)}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{payment.status === 'pending' ? (
|
||||
<button
|
||||
onClick={() => setSelectedPayment(payment)}
|
||||
className="px-3 py-1 bg-primary-500 text-white rounded hover:bg-primary-600 text-xs"
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setSelectedPayment(payment)}
|
||||
className="px-3 py-1 border rounded hover:bg-gray-50 text-xs"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t">
|
||||
<div className="text-sm text-gray-500">
|
||||
Page {page} of {totalPages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment Detail Modal */}
|
||||
{selectedPayment && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Payment Details</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPayment(null);
|
||||
setAdminNote('');
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Payment ID</p>
|
||||
<p className="font-medium">{selectedPayment.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Status</p>
|
||||
<p>{getStatusBadge(selectedPayment.status)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">User</p>
|
||||
<p className="font-medium">{selectedPayment.user_email}</p>
|
||||
<p className="text-sm text-gray-500">{selectedPayment.user_name || 'No name'}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Amount</p>
|
||||
<p className="font-medium text-lg">
|
||||
{selectedPayment.amount} {selectedPayment.currency}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">CC to Credit</p>
|
||||
<p className="font-medium text-lg text-blue-600">+{selectedPayment.cc_amount} CC</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Payment Method</p>
|
||||
<p>{getMethodBadge(selectedPayment.payment_method)}</p>
|
||||
</div>
|
||||
|
||||
{selectedPayment.transaction_id && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Transaction Hash</p>
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded block break-all">
|
||||
{selectedPayment.transaction_id}
|
||||
</code>
|
||||
{selectedPayment.payment_method === 'usdc' && (
|
||||
<a
|
||||
href={`https://polygonscan.com/tx/${selectedPayment.transaction_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary-600 hover:underline mt-1 inline-block"
|
||||
>
|
||||
View on PolygonScan
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPayment.wallet_address && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">User Wallet (for refund)</p>
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded block break-all">
|
||||
{selectedPayment.wallet_address}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Submitted At</p>
|
||||
<p>{formatDate(selectedPayment.created_at)}</p>
|
||||
</div>
|
||||
|
||||
{selectedPayment.verified_at && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Verified At</p>
|
||||
<p>{formatDate(selectedPayment.verified_at)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPayment.admin_note && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Admin Note</p>
|
||||
<p className="bg-gray-50 p-2 rounded">{selectedPayment.admin_note}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPayment.status === 'pending' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Admin Note (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={adminNote}
|
||||
onChange={(e) => setAdminNote(e.target.value)}
|
||||
placeholder="Add a note..."
|
||||
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => handleVerify(false)}
|
||||
disabled={processing}
|
||||
className="flex-1 px-4 py-2 border border-red-500 text-red-600 rounded-lg hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
{processing ? 'Processing...' : 'Reject'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleVerify(true)}
|
||||
disabled={processing}
|
||||
className="flex-1 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50"
|
||||
>
|
||||
{processing ? 'Processing...' : 'Approve'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedPayment.status !== 'pending' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPayment(null);
|
||||
setAdminNote('');
|
||||
}}
|
||||
className="w-full px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
521
frontend/src/app/admin/purchased/page.tsx
Normal file
521
frontend/src/app/admin/purchased/page.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { vehicleRequestsApi, PurchasedVehicle } from '@/lib/api';
|
||||
import { useExchangeRateStore } from '@/lib/exchangeRateStore';
|
||||
|
||||
const SHIPPING_STEPS = [
|
||||
{ step: 1, label: 'Purchased', labelKo: '구매 완료' },
|
||||
{ step: 2, label: 'Incheon Port', labelKo: '인천항' },
|
||||
{ step: 3, label: 'Tianjin Port', labelKo: '텐진항 (중국)' },
|
||||
{ step: 4, label: 'Zamyn-Uud', labelKo: '자먼우드 (몽골)' },
|
||||
{ step: 5, label: 'Ulaanbaatar', labelKo: '울란바토르' },
|
||||
{ step: 6, label: 'Customs', labelKo: '통관' },
|
||||
{ step: 7, label: 'Delivered', labelKo: '배송 완료' },
|
||||
];
|
||||
|
||||
export default function AdminPurchasedPage() {
|
||||
const [vehicles, setVehicles] = useState<PurchasedVehicle[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedVehicle, setSelectedVehicle] = useState<PurchasedVehicle | null>(null);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
|
||||
// Edit form state
|
||||
const [editStatus, setEditStatus] = useState<number>(1);
|
||||
const [editLocation, setEditLocation] = useState<string>('');
|
||||
const [editArrival, setEditArrival] = useState<string>('');
|
||||
|
||||
// Create form state
|
||||
const [createUserId, setCreateUserId] = useState<string>('');
|
||||
const [createCarName, setCreateCarName] = useState<string>('');
|
||||
const [createCarImage, setCreateCarImage] = useState<string>('');
|
||||
const [createVehiclePrice, setCreateVehiclePrice] = useState<string>('');
|
||||
const [createDomesticCost, setCreateDomesticCost] = useState<string>('1207500'); // 1,150,000 + 5%
|
||||
const [createShippingCost, setCreateShippingCost] = useState<string>('1000');
|
||||
const [createCarType, setCreateCarType] = useState<string>('small');
|
||||
|
||||
// Load vehicles
|
||||
useEffect(() => {
|
||||
loadVehicles();
|
||||
}, []);
|
||||
|
||||
const loadVehicles = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await vehicleRequestsApi.adminGetAllPurchased();
|
||||
setVehicles(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load vehicles:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Open edit modal
|
||||
const openEditModal = (vehicle: PurchasedVehicle) => {
|
||||
setSelectedVehicle(vehicle);
|
||||
setEditStatus(vehicle.shipping_status);
|
||||
setEditLocation(vehicle.current_location || '');
|
||||
setEditArrival(vehicle.estimated_arrival ? vehicle.estimated_arrival.split('T')[0] : '');
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
// Update shipping status
|
||||
const updateShippingStatus = async () => {
|
||||
if (!selectedVehicle) return;
|
||||
|
||||
try {
|
||||
await vehicleRequestsApi.adminUpdateShippingStatus(selectedVehicle.id, {
|
||||
shipping_status: editStatus,
|
||||
current_location: editLocation || undefined,
|
||||
estimated_arrival: editArrival || undefined,
|
||||
});
|
||||
|
||||
setShowEditModal(false);
|
||||
loadVehicles();
|
||||
} catch (error) {
|
||||
console.error('Failed to update status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Create purchased vehicle
|
||||
const createPurchasedVehicle = async () => {
|
||||
if (!createUserId || !createCarName || !createVehiclePrice) {
|
||||
alert('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const vehiclePrice = parseInt(createVehiclePrice) * 10000;
|
||||
const domesticCost = parseInt(createDomesticCost);
|
||||
const shippingCostUsd = parseInt(createShippingCost);
|
||||
const usdToKrw = useExchangeRateStore.getState().rates.USD?.rate || 1483;
|
||||
const shippingCostKrw = shippingCostUsd * usdToKrw;
|
||||
|
||||
// Calculate total with 5% Mongolian margin
|
||||
const subtotal = vehiclePrice + domesticCost + shippingCostKrw;
|
||||
const mongolianMargin = subtotal * 0.05;
|
||||
const totalCost = Math.round(subtotal + mongolianMargin);
|
||||
|
||||
await vehicleRequestsApi.adminCreatePurchased(parseInt(createUserId), {
|
||||
car_name: createCarName,
|
||||
car_image: createCarImage || undefined,
|
||||
vehicle_price_krw: vehiclePrice,
|
||||
domestic_cost_krw: domesticCost,
|
||||
shipping_cost_usd: shippingCostUsd,
|
||||
total_cost_krw: totalCost,
|
||||
car_type: createCarType,
|
||||
});
|
||||
|
||||
setShowCreateModal(false);
|
||||
resetCreateForm();
|
||||
loadVehicles();
|
||||
} catch (error) {
|
||||
console.error('Failed to create vehicle:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const resetCreateForm = () => {
|
||||
setCreateUserId('');
|
||||
setCreateCarName('');
|
||||
setCreateCarImage('');
|
||||
setCreateVehiclePrice('');
|
||||
setCreateDomesticCost('1207500');
|
||||
setCreateShippingCost('1000');
|
||||
setCreateCarType('small');
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Format price
|
||||
const formatPrice = (price: number | null | undefined) => {
|
||||
if (!price) return '-';
|
||||
return `₩${price.toLocaleString()}`;
|
||||
};
|
||||
|
||||
// Get status badge
|
||||
const getStatusBadge = (status: number) => {
|
||||
const step = SHIPPING_STEPS.find(s => s.step === status);
|
||||
const colors = [
|
||||
'',
|
||||
'bg-yellow-100 text-yellow-800', // 1: Purchased
|
||||
'bg-blue-100 text-blue-800', // 2: Incheon
|
||||
'bg-cyan-100 text-cyan-800', // 3: Tianjin
|
||||
'bg-indigo-100 text-indigo-800', // 4: Zamyn-Uud
|
||||
'bg-purple-100 text-purple-800', // 5: Ulaanbaatar
|
||||
'bg-orange-100 text-orange-800', // 6: Customs
|
||||
'bg-green-100 text-green-800', // 7: Delivered
|
||||
];
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colors[status] || 'bg-gray-100 text-gray-800'}`}>
|
||||
{step?.label || `Step ${status}`}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Purchased Vehicles</h1>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="bg-primary-600 text-white px-4 py-2 rounded-lg hover:bg-primary-700"
|
||||
>
|
||||
+ Add Purchased Vehicle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-7 gap-3">
|
||||
{SHIPPING_STEPS.map((step) => {
|
||||
const count = vehicles.filter(v => v.shipping_status === step.step).length;
|
||||
return (
|
||||
<div key={step.step} className="bg-white rounded-lg shadow p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-800">{count}</div>
|
||||
<div className="text-sm text-gray-500">{step.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Vehicles Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div>
|
||||
</div>
|
||||
) : vehicles.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No purchased vehicles</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Vehicle</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">User ID</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Total Cost</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Location</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Est. Arrival</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{vehicles.map((vehicle) => (
|
||||
<tr key={vehicle.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm text-gray-500">{vehicle.id}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{vehicle.car_image && (
|
||||
<img src={vehicle.car_image} alt="" className="w-12 h-9 object-cover rounded" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-gray-800 max-w-[150px] truncate">
|
||||
{vehicle.car_name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">{vehicle.user_id}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 capitalize">{vehicle.car_type}</td>
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-800">
|
||||
{formatPrice(vehicle.total_cost_krw)}
|
||||
</td>
|
||||
<td className="px-4 py-3">{getStatusBadge(vehicle.shipping_status)}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 max-w-[100px] truncate">
|
||||
{vehicle.current_location || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{formatDate(vehicle.estimated_arrival)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => openEditModal(vehicle)}
|
||||
className="text-primary-600 hover:text-primary-800 text-sm font-medium"
|
||||
>
|
||||
Update Status
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Modal */}
|
||||
{showEditModal && selectedVehicle && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg max-w-md w-full">
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-800">Update Shipping Status</h3>
|
||||
<button onClick={() => setShowEditModal(false)} className="text-gray-500 hover:text-gray-700">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="bg-gray-50 rounded p-3">
|
||||
<p className="font-medium text-sm">{selectedVehicle.car_name}</p>
|
||||
<p className="text-xs text-gray-500">User ID: {selectedVehicle.user_id}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Shipping Status
|
||||
</label>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{SHIPPING_STEPS.map((step) => (
|
||||
<button
|
||||
key={step.step}
|
||||
onClick={() => setEditStatus(step.step)}
|
||||
className={`p-2 rounded text-xs text-center ${
|
||||
editStatus === step.step
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{step.step}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Selected: {SHIPPING_STEPS.find(s => s.step === editStatus)?.label}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Current Location
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editLocation}
|
||||
onChange={(e) => setEditLocation(e.target.value)}
|
||||
placeholder="e.g., Incheon Port, On Ship, Ulaanbaatar"
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Estimated Arrival Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editArrival}
|
||||
onChange={(e) => setEditArrival(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
onClick={() => setShowEditModal(false)}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={updateShippingStatus}
|
||||
className="flex-1 px-4 py-2 bg-primary-600 text-white rounded hover:bg-primary-700"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-800">Add Purchased Vehicle</h3>
|
||||
<button onClick={() => { setShowCreateModal(false); resetCreateForm(); }} className="text-gray-500 hover:text-gray-700">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
User ID <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={createUserId}
|
||||
onChange={(e) => setCreateUserId(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Car Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createCarName}
|
||||
onChange={(e) => setCreateCarName(e.target.value)}
|
||||
placeholder="e.g., 2022 Hyundai Sonata"
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Car Image URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createCarImage}
|
||||
onChange={(e) => setCreateCarImage(e.target.value)}
|
||||
placeholder="https://..."
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Vehicle Price (만원) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={createVehiclePrice}
|
||||
onChange={(e) => setCreateVehiclePrice(e.target.value)}
|
||||
placeholder="2000"
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
{createVehiclePrice && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
= ₩{(parseInt(createVehiclePrice) * 10000).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Domestic Cost (원)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={createDomesticCost}
|
||||
onChange={(e) => setCreateDomesticCost(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Default: ₩1,150,000 + 5% margin = ₩1,207,500
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Car Type
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => { setCreateCarType('small'); setCreateShippingCost('1000'); }}
|
||||
className={`p-3 rounded border-2 ${
|
||||
createCarType === 'small'
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">Small Car</div>
|
||||
<div className="text-sm text-gray-500">$1,000</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setCreateCarType('compact'); setCreateShippingCost('800'); }}
|
||||
className={`p-3 rounded border-2 ${
|
||||
createCarType === 'compact'
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">Compact Car</div>
|
||||
<div className="text-sm text-gray-500">$800</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Shipping Cost (USD)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={createShippingCost}
|
||||
onChange={(e) => setCreateShippingCost(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cost Preview */}
|
||||
{createVehiclePrice && (
|
||||
<div className="bg-gray-50 rounded p-3 text-sm">
|
||||
<h4 className="font-medium mb-2">Cost Preview</h4>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Vehicle:</span>
|
||||
<span>₩{(parseInt(createVehiclePrice || '0') * 10000).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Domestic:</span>
|
||||
<span>₩{parseInt(createDomesticCost || '0').toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Shipping:</span>
|
||||
<span>${parseInt(createShippingCost || '0').toLocaleString()} (≈₩{(parseInt(createShippingCost || '0') * (useExchangeRateStore.getState().rates.USD?.rate || 1483)).toLocaleString()})</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t pt-1 font-medium">
|
||||
<span>Total (+ 5% margin):</span>
|
||||
<span>
|
||||
₩{(() => {
|
||||
const vp = parseInt(createVehiclePrice || '0') * 10000;
|
||||
const dc = parseInt(createDomesticCost || '0');
|
||||
const usdRate = useExchangeRateStore.getState().rates.USD?.rate || 1483;
|
||||
const sc = parseInt(createShippingCost || '0') * usdRate;
|
||||
const subtotal = vp + dc + sc;
|
||||
const total = Math.round(subtotal * 1.05);
|
||||
return total.toLocaleString();
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
onClick={() => { setShowCreateModal(false); resetCreateForm(); }}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={createPurchasedVehicle}
|
||||
className="flex-1 px-4 py-2 bg-primary-600 text-white rounded hover:bg-primary-700"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
589
frontend/src/app/admin/settings/page.tsx
Normal file
589
frontend/src/app/admin/settings/page.tsx
Normal file
@@ -0,0 +1,589 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface SystemSettings {
|
||||
id: number;
|
||||
search_page_size: number;
|
||||
korea_margin_percent: number;
|
||||
mongolia_margin_percent: number;
|
||||
cc_per_usdc: number;
|
||||
cc_per_view: number;
|
||||
cc_signup_bonus: number;
|
||||
cars_per_cc: number;
|
||||
cache_ttl_hours: number;
|
||||
container_logistics_usd: number;
|
||||
shoring_cost_usd: number;
|
||||
referral_reward_enabled: boolean;
|
||||
referral_reward_percent: number;
|
||||
referral_reward_type: string;
|
||||
exchange_rate_weight_usd: number;
|
||||
exchange_rate_weight_mnt: number;
|
||||
exchange_rate_weight_rub: number;
|
||||
exchange_rate_weight_cny: number;
|
||||
}
|
||||
|
||||
interface ExchangeRateWeights {
|
||||
usd: number;
|
||||
mnt: number;
|
||||
rub: number;
|
||||
cny: number;
|
||||
}
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [settings, setSettings] = useState<SystemSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingExchangeRates, setSavingExchangeRates] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [exchangeRateWeights, setExchangeRateWeights] = useState<ExchangeRateWeights>({
|
||||
usd: 0,
|
||||
mnt: 0,
|
||||
rub: 0,
|
||||
cny: 0,
|
||||
});
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
search_page_size: 20,
|
||||
korea_margin_percent: 5.0,
|
||||
mongolia_margin_percent: 5.0,
|
||||
cc_per_usdc: 1,
|
||||
cc_per_view: 1,
|
||||
cc_signup_bonus: 3,
|
||||
cars_per_cc: 3,
|
||||
cache_ttl_hours: 2,
|
||||
container_logistics_usd: 3600,
|
||||
shoring_cost_usd: 300,
|
||||
referral_reward_enabled: true,
|
||||
referral_reward_percent: 10.0,
|
||||
referral_reward_type: 'one_time',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
fetchExchangeRateWeights();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/settings/`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSettings(data);
|
||||
setFormData({
|
||||
search_page_size: data.search_page_size,
|
||||
korea_margin_percent: data.korea_margin_percent,
|
||||
mongolia_margin_percent: data.mongolia_margin_percent,
|
||||
cc_per_usdc: data.cc_per_usdc,
|
||||
cc_per_view: data.cc_per_view,
|
||||
cc_signup_bonus: data.cc_signup_bonus,
|
||||
cars_per_cc: data.cars_per_cc || 3,
|
||||
cache_ttl_hours: data.cache_ttl_hours,
|
||||
container_logistics_usd: data.container_logistics_usd || 3600,
|
||||
shoring_cost_usd: data.shoring_cost_usd || 300,
|
||||
referral_reward_enabled: data.referral_reward_enabled ?? true,
|
||||
referral_reward_percent: data.referral_reward_percent ?? 10.0,
|
||||
referral_reward_type: data.referral_reward_type || 'one_time',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
setMessage({ type: 'error', text: 'Failed to load settings' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchExchangeRateWeights = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/exchange-rate/weights`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setExchangeRateWeights(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch exchange rate weights:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveExchangeRateWeights = async () => {
|
||||
setSavingExchangeRates(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/api/exchange-rate/weights`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(exchangeRateWeights),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: 'success', text: 'Exchange rate weights saved successfully!' });
|
||||
} else {
|
||||
const error = await response.json();
|
||||
setMessage({ type: 'error', text: error.detail || 'Failed to save exchange rate weights' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save exchange rate weights:', error);
|
||||
setMessage({ type: 'error', text: 'Failed to save exchange rate weights' });
|
||||
} finally {
|
||||
setSavingExchangeRates(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE_URL}/api/settings/`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSettings(data);
|
||||
setMessage({ type: 'success', text: 'Settings saved successfully!' });
|
||||
} else {
|
||||
const error = await response.json();
|
||||
setMessage({ type: 'error', text: error.detail || 'Failed to save settings' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
setMessage({ type: 'error', text: 'Failed to save settings' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof typeof formData, value: number) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">System Settings</h1>
|
||||
|
||||
{message && (
|
||||
<div className={`mb-6 p-4 rounded-lg ${
|
||||
message.type === 'success' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Search Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<span>Search Settings</span>
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Search Results Per Page
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
max="100"
|
||||
value={formData.search_page_size}
|
||||
onChange={(e) => handleChange('search_page_size', parseInt(e.target.value) || 20)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">Number of cars displayed per page in search results (5-100)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Cache TTL (Hours)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="24"
|
||||
value={formData.cache_ttl_hours}
|
||||
onChange={(e) => handleChange('cache_ttl_hours', parseInt(e.target.value) || 2)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">How long to cache search results (1-24 hours)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Margin Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<span>Margin Settings</span>
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Korea Margin (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="50"
|
||||
step="0.1"
|
||||
value={formData.korea_margin_percent}
|
||||
onChange={(e) => handleChange('korea_margin_percent', parseFloat(e.target.value) || 5)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">Margin percentage for Korea sales</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Mongolia Margin (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="50"
|
||||
step="0.1"
|
||||
value={formData.mongolia_margin_percent}
|
||||
onChange={(e) => handleChange('mongolia_margin_percent', parseFloat(e.target.value) || 5)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">Margin percentage for Mongolia exports</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CC Coin Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<span>CC Coin Settings</span>
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
CC per USD
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={formData.cc_per_usdc}
|
||||
onChange={(e) => handleChange('cc_per_usdc', parseInt(e.target.value) || 10)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">1 USD = X CC</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
CC per Request
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
value={formData.cc_per_view}
|
||||
onChange={(e) => handleChange('cc_per_view', parseInt(e.target.value) || 1)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">CC consumed per vehicle request</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Cars per CC (추천 대수)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={formData.cars_per_cc}
|
||||
onChange={(e) => handleChange('cars_per_cc', parseInt(e.target.value) || 3)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">1 CC = {formData.cars_per_cc} recommended vehicles</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Signup Bonus CC
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={formData.cc_signup_bonus}
|
||||
onChange={(e) => handleChange('cc_signup_bonus', parseInt(e.target.value) || 3)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">Free CC for new users</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 bg-blue-50 rounded-lg">
|
||||
<h3 className="font-medium text-blue-800 mb-2">CC Value Preview</h3>
|
||||
<div className="text-sm text-blue-700 space-y-1">
|
||||
<p>1 CC = {formData.cars_per_cc} recommended vehicles</p>
|
||||
<p>Signup Bonus ({formData.cc_signup_bonus} CC) = {formData.cc_signup_bonus * formData.cars_per_cc} vehicles</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Container Logistics Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<span>🚢 Container Logistics Settings</span>
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Container Logistics Cost (USD)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="10000"
|
||||
value={formData.container_logistics_usd}
|
||||
onChange={(e) => handleChange('container_logistics_usd', parseInt(e.target.value) || 3600)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">컨테이너 물류비 (기본값: $3,600)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Shoring Cost (USD)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="1000"
|
||||
value={formData.shoring_cost_usd}
|
||||
onChange={(e) => handleChange('shoring_cost_usd', parseInt(e.target.value) || 300)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">쇼링비 - 컨테이너 고정 비용 (기본값: $300)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 bg-blue-50 rounded-lg">
|
||||
<h3 className="font-medium text-blue-800 mb-2">Cost Calculation Preview</h3>
|
||||
<div className="text-sm text-blue-700 space-y-1">
|
||||
<p>Total Container Cost: ${formData.container_logistics_usd + formData.shoring_cost_usd}</p>
|
||||
<p>Small Car (5.5/10): ${((formData.container_logistics_usd + formData.shoring_cost_usd) * 0.275).toFixed(0)} per car</p>
|
||||
<p>Compact Car (4.5/10): ${((formData.container_logistics_usd + formData.shoring_cost_usd) * 0.225).toFixed(0)} per car</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Referral Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<span>👥 Referral Reward Settings</span>
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.referral_reward_enabled}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, referral_reward_enabled: e.target.checked }))}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
|
||||
</label>
|
||||
<span className="text-sm font-medium text-gray-700">Enable Referral Rewards</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Reward Percentage (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="50"
|
||||
step="0.1"
|
||||
value={formData.referral_reward_percent}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, referral_reward_percent: parseFloat(e.target.value) || 10 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">Percentage of payment given as referral reward</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Reward Type
|
||||
</label>
|
||||
<select
|
||||
value={formData.referral_reward_type}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, referral_reward_type: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="one_time">One-time (First payment only)</option>
|
||||
<option value="recurring">Recurring (Every payment)</option>
|
||||
</select>
|
||||
<p className="mt-1 text-sm text-gray-500">When to give referral rewards</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-green-50 rounded-lg">
|
||||
<h3 className="font-medium text-green-800 mb-2">Example Calculation</h3>
|
||||
<div className="text-sm text-green-700 space-y-1">
|
||||
<p>If a referred user charges $100 USD:</p>
|
||||
<p>Referrer receives: ${(100 * formData.referral_reward_percent / 100).toFixed(2)} USD</p>
|
||||
<p>Type: {formData.referral_reward_type === 'one_time' ? 'Only on first payment' : 'Every time they pay'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{saving && (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
)}
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Exchange Rate Weight Settings - Separate Section */}
|
||||
<div className="mt-8 bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<span>💱 Exchange Rate Weight Settings</span>
|
||||
</h2>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
환율에 가중치를 적용하여 실제 표시되는 환율을 조정할 수 있습니다. 양수 값은 가격 인상, 음수 값은 가격 인하를 의미합니다.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
🇺🇸 USD (미국 달러) Weight (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="-10"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={exchangeRateWeights.usd}
|
||||
onChange={(e) => setExchangeRateWeights(prev => ({ ...prev, usd: parseFloat(e.target.value) || 0 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
🇲🇳 MNT (몽골 투그릭) Weight (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="-10"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={exchangeRateWeights.mnt}
|
||||
onChange={(e) => setExchangeRateWeights(prev => ({ ...prev, mnt: parseFloat(e.target.value) || 0 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
🇷🇺 RUB (러시아 루블) Weight (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="-10"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={exchangeRateWeights.rub}
|
||||
onChange={(e) => setExchangeRateWeights(prev => ({ ...prev, rub: parseFloat(e.target.value) || 0 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
🇨🇳 CNY (중국 위안) Weight (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="-10"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={exchangeRateWeights.cny}
|
||||
onChange={(e) => setExchangeRateWeights(prev => ({ ...prev, cny: parseFloat(e.target.value) || 0 }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 bg-amber-50 rounded-lg">
|
||||
<h3 className="font-medium text-amber-800 mb-2">Weight Preview (예시: +2%)</h3>
|
||||
<div className="text-sm text-amber-700 space-y-2">
|
||||
<p className="font-medium">예: 기준 환율이 1 USD = 1,400 KRW 일 때</p>
|
||||
<div className="bg-white rounded p-3 space-y-1">
|
||||
<p>USD 가중치: +2%</p>
|
||||
<p>계산식: 1,400 × (1 + 2/100) = 1,400 × 1.0200</p>
|
||||
<p className="font-bold text-lg">표시 환율: 1 USD = 1,428 KRW</p>
|
||||
<p className="text-xs text-amber-600">(+28 KRW 증가)</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
현재 설정된 가중치 - USD: {exchangeRateWeights.usd >= 0 ? '+' : ''}{exchangeRateWeights.usd}%,
|
||||
MNT: {exchangeRateWeights.mnt >= 0 ? '+' : ''}{exchangeRateWeights.mnt}%,
|
||||
RUB: {exchangeRateWeights.rub >= 0 ? '+' : ''}{exchangeRateWeights.rub}%,
|
||||
CNY: {exchangeRateWeights.cny >= 0 ? '+' : ''}{exchangeRateWeights.cny}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveExchangeRateWeights}
|
||||
disabled={savingExchangeRates}
|
||||
className="px-6 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{savingExchangeRates && (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
)}
|
||||
{savingExchangeRates ? 'Saving...' : 'Save Exchange Rate Weights'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
742
frontend/src/app/admin/translations/page.tsx
Normal file
742
frontend/src/app/admin/translations/page.tsx
Normal file
@@ -0,0 +1,742 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { translationsApi, Translation, TranslationListResponse } from '@/lib/api';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
maker: 'Maker (제조사)',
|
||||
model: 'Model (모델)',
|
||||
fuel: 'Fuel (연료)',
|
||||
transmission: 'Transmission (변속기)',
|
||||
color: 'Color (색상)',
|
||||
car_name: 'Car Name (차량명)',
|
||||
general: 'General (일반)',
|
||||
};
|
||||
|
||||
interface TranslationStats {
|
||||
total_entries: number;
|
||||
by_category: Record<string, number>;
|
||||
translation_coverage: {
|
||||
english: { translated: number; total: number; percentage: number };
|
||||
mongolian: { translated: number; total: number; percentage: number };
|
||||
russian: { translated: number; total: number; percentage: number };
|
||||
};
|
||||
}
|
||||
|
||||
export default function TranslationsPage() {
|
||||
const [translations, setTranslations] = useState<Translation[]>([]);
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editData, setEditData] = useState<Partial<Translation>>({});
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showBatchModal, setShowBatchModal] = useState(false);
|
||||
const [translatingId, setTranslatingId] = useState<number | null>(null);
|
||||
const [batchTranslating, setBatchTranslating] = useState(false);
|
||||
const [stats, setStats] = useState<TranslationStats | null>(null);
|
||||
const [batchOptions, setBatchOptions] = useState({
|
||||
category: '',
|
||||
overwriteExisting: false,
|
||||
targetLangs: ['en', 'mn', 'ru'] as string[],
|
||||
});
|
||||
const [batchResult, setBatchResult] = useState<{
|
||||
total_processed: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
} | null>(null);
|
||||
const [newTranslation, setNewTranslation] = useState({
|
||||
source_text: '',
|
||||
category: 'general',
|
||||
text_en: '',
|
||||
text_mn: '',
|
||||
text_ru: '',
|
||||
});
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTranslations();
|
||||
}, [page, selectedCategory, searchTerm]);
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const data = await translationsApi.getCategories();
|
||||
setCategories(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load categories:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const data = await translationsApi.getStats();
|
||||
setStats(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load stats:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTranslations = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await translationsApi.getList({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
category: selectedCategory || undefined,
|
||||
search: searchTerm || undefined,
|
||||
});
|
||||
setTranslations(data.translations);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
console.error('Failed to load translations:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoExtract = async () => {
|
||||
try {
|
||||
const result = await translationsApi.autoExtract();
|
||||
alert(result.message);
|
||||
loadTranslations();
|
||||
loadStats();
|
||||
} catch (err) {
|
||||
console.error('Failed to auto-extract:', err);
|
||||
alert('Failed to auto-extract translations');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoTranslate = async (translation: Translation) => {
|
||||
setTranslatingId(translation.id);
|
||||
try {
|
||||
const result = await translationsApi.autoTranslate(translation.id);
|
||||
alert(`Auto-translated: ${result.message}`);
|
||||
loadTranslations();
|
||||
loadStats();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to auto-translate:', err);
|
||||
alert(err.response?.data?.detail || 'Failed to auto-translate');
|
||||
} finally {
|
||||
setTranslatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchTranslate = async () => {
|
||||
setBatchTranslating(true);
|
||||
setBatchResult(null);
|
||||
try {
|
||||
const result = await translationsApi.autoTranslateBatch(
|
||||
batchOptions.targetLangs,
|
||||
batchOptions.category || undefined,
|
||||
batchOptions.overwriteExisting
|
||||
);
|
||||
setBatchResult({
|
||||
total_processed: result.total_processed,
|
||||
successful: result.successful,
|
||||
failed: result.failed,
|
||||
});
|
||||
loadTranslations();
|
||||
loadStats();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to batch translate:', err);
|
||||
alert(err.response?.data?.detail || 'Failed to batch translate');
|
||||
} finally {
|
||||
setBatchTranslating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (translation: Translation) => {
|
||||
setEditingId(translation.id);
|
||||
setEditData({
|
||||
text_en: translation.text_en || '',
|
||||
text_mn: translation.text_mn || '',
|
||||
text_ru: translation.text_ru || '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async (id: number) => {
|
||||
try {
|
||||
await translationsApi.update(id, editData);
|
||||
setEditingId(null);
|
||||
loadTranslations();
|
||||
loadStats();
|
||||
} catch (err) {
|
||||
console.error('Failed to save:', err);
|
||||
alert('Failed to save translation');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Delete this translation?')) return;
|
||||
try {
|
||||
await translationsApi.delete(id);
|
||||
loadTranslations();
|
||||
loadStats();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete:', err);
|
||||
alert('Failed to delete translation');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newTranslation.source_text.trim()) {
|
||||
alert('Source text is required');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await translationsApi.create(newTranslation);
|
||||
setShowAddModal(false);
|
||||
setNewTranslation({
|
||||
source_text: '',
|
||||
category: 'general',
|
||||
text_en: '',
|
||||
text_mn: '',
|
||||
text_ru: '',
|
||||
});
|
||||
loadTranslations();
|
||||
loadStats();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to add:', err);
|
||||
alert(err.response?.data?.detail || 'Failed to add translation');
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Translations Management</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleAutoExtract}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Auto Extract
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowBatchModal(true)}
|
||||
className="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
|
||||
</svg>
|
||||
Batch Translate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Translation Statistics */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||
<div className="text-sm text-gray-500">Total Entries</div>
|
||||
<div className="text-2xl font-bold text-gray-800">{stats.total_entries}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||
<div className="text-sm text-gray-500">English Coverage</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-2xl font-bold text-blue-600">{stats.translation_coverage.english.percentage.toFixed(1)}%</div>
|
||||
<div className="text-xs text-gray-400">({stats.translation_coverage.english.translated}/{stats.translation_coverage.english.total})</div>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div className="bg-blue-600 h-2 rounded-full" style={{ width: `${stats.translation_coverage.english.percentage}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||
<div className="text-sm text-gray-500">Mongolian Coverage</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.translation_coverage.mongolian.percentage.toFixed(1)}%</div>
|
||||
<div className="text-xs text-gray-400">({stats.translation_coverage.mongolian.translated}/{stats.translation_coverage.mongolian.total})</div>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div className="bg-green-600 h-2 rounded-full" style={{ width: `${stats.translation_coverage.mongolian.percentage}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||
<div className="text-sm text-gray-500">Russian Coverage</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-2xl font-bold text-red-600">{stats.translation_coverage.russian.percentage.toFixed(1)}%</div>
|
||||
<div className="text-xs text-gray-400">({stats.translation_coverage.russian.translated}/{stats.translation_coverage.russian.total})</div>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div className="bg-red-600 h-2 rounded-full" style={{ width: `${stats.translation_coverage.russian.percentage}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category Stats */}
|
||||
{stats && Object.keys(stats.by_category).length > 0 && (
|
||||
<div className="bg-white rounded-xl shadow-sm p-4 mb-6">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Entries by Category</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(stats.by_category).map(([cat, count]) => (
|
||||
<span key={cat} className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm">
|
||||
{CATEGORY_LABELS[cat] || cat}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-4 mb-6">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Search translations..."
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => {
|
||||
setSelectedCategory(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{CATEGORY_LABELS[cat] || cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Category</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Korean (Source)</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">English</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Mongolian</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Russian</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600 w-40">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-8 h-8 border-4 border-primary-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : translations.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center text-gray-500">
|
||||
No translations found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
translations.map((trans) => (
|
||||
<tr key={trans.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="py-3 px-4">
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">
|
||||
{CATEGORY_LABELS[trans.category] || trans.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-medium text-gray-800">{trans.source_text}</td>
|
||||
<td className="py-3 px-4">
|
||||
{editingId === trans.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.text_en || ''}
|
||||
onChange={(e) => setEditData({ ...editData, text_en: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-2 py-1 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className={trans.text_en ? 'text-gray-700' : 'text-gray-400 italic'}>
|
||||
{trans.text_en || 'Not set'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{editingId === trans.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.text_mn || ''}
|
||||
onChange={(e) => setEditData({ ...editData, text_mn: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-2 py-1 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className={trans.text_mn ? 'text-gray-700' : 'text-gray-400 italic'}>
|
||||
{trans.text_mn || 'Not set'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{editingId === trans.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.text_ru || ''}
|
||||
onChange={(e) => setEditData({ ...editData, text_ru: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-2 py-1 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className={trans.text_ru ? 'text-gray-700' : 'text-gray-400 italic'}>
|
||||
{trans.text_ru || 'Not set'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{editingId === trans.id ? (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleSave(trans.id)}
|
||||
className="text-green-600 hover:text-green-700"
|
||||
title="Save"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingId(null)}
|
||||
className="text-gray-600 hover:text-gray-700"
|
||||
title="Cancel"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleAutoTranslate(trans)}
|
||||
disabled={translatingId === trans.id}
|
||||
className={`text-purple-600 hover:text-purple-700 ${translatingId === trans.id ? 'opacity-50' : ''}`}
|
||||
title="Auto Translate"
|
||||
>
|
||||
{translatingId === trans.id ? (
|
||||
<div className="w-5 h-5 border-2 border-purple-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(trans)}
|
||||
className="text-blue-600 hover:text-blue-700"
|
||||
title="Edit"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(trans.id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
title="Delete"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-2 py-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span className="px-4 text-gray-600">
|
||||
Page {page} of {totalPages} ({total} total)
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Modal */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl max-w-lg w-full p-6">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-4">Add Translation</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Korean (Source Text) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTranslation.source_text}
|
||||
onChange={(e) => setNewTranslation({ ...newTranslation, source_text: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="Enter Korean text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
||||
<select
|
||||
value={newTranslation.category}
|
||||
onChange={(e) => setNewTranslation({ ...newTranslation, category: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{CATEGORY_LABELS[cat] || cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">English</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTranslation.text_en}
|
||||
onChange={(e) => setNewTranslation({ ...newTranslation, text_en: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="English translation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Mongolian</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTranslation.text_mn}
|
||||
onChange={(e) => setNewTranslation({ ...newTranslation, text_mn: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="Mongolian translation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Russian</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTranslation.text_ru}
|
||||
onChange={(e) => setNewTranslation({ ...newTranslation, text_ru: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
placeholder="Russian translation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch Translate Modal */}
|
||||
{showBatchModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl max-w-lg w-full p-6">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-4">Batch Auto-Translate</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Category (Optional)</label>
|
||||
<select
|
||||
value={batchOptions.category}
|
||||
onChange={(e) => setBatchOptions({ ...batchOptions, category: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{CATEGORY_LABELS[cat] || cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">Leave empty to translate all categories</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Target Languages</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={batchOptions.targetLangs.includes('en')}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setBatchOptions({ ...batchOptions, targetLangs: [...batchOptions.targetLangs, 'en'] });
|
||||
} else {
|
||||
setBatchOptions({ ...batchOptions, targetLangs: batchOptions.targetLangs.filter(l => l !== 'en') });
|
||||
}
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>English</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={batchOptions.targetLangs.includes('mn')}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setBatchOptions({ ...batchOptions, targetLangs: [...batchOptions.targetLangs, 'mn'] });
|
||||
} else {
|
||||
setBatchOptions({ ...batchOptions, targetLangs: batchOptions.targetLangs.filter(l => l !== 'mn') });
|
||||
}
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>Mongolian</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={batchOptions.targetLangs.includes('ru')}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setBatchOptions({ ...batchOptions, targetLangs: [...batchOptions.targetLangs, 'ru'] });
|
||||
} else {
|
||||
setBatchOptions({ ...batchOptions, targetLangs: batchOptions.targetLangs.filter(l => l !== 'ru') });
|
||||
}
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>Russian</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={batchOptions.overwriteExisting}
|
||||
onChange={(e) => setBatchOptions({ ...batchOptions, overwriteExisting: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Overwrite existing translations</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">If unchecked, only empty translations will be filled</p>
|
||||
</div>
|
||||
|
||||
{batchResult && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="font-medium text-gray-700 mb-2">Translation Results</h3>
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-800">{batchResult.total_processed}</div>
|
||||
<div className="text-xs text-gray-500">Processed</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">{batchResult.successful}</div>
|
||||
<div className="text-xs text-gray-500">Successful</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-red-600">{batchResult.failed}</div>
|
||||
<div className="text-xs text-gray-500">Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowBatchModal(false);
|
||||
setBatchResult(null);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBatchTranslate}
|
||||
disabled={batchTranslating || batchOptions.targetLangs.length === 0}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{batchTranslating ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Translating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
|
||||
</svg>
|
||||
Start Batch Translate
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
616
frontend/src/app/admin/users/page.tsx
Normal file
616
frontend/src/app/admin/users/page.tsx
Normal file
@@ -0,0 +1,616 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { adminUserApi, AdminUser } from '@/lib/api';
|
||||
|
||||
type TabType = 'active' | 'deleted';
|
||||
|
||||
interface DeletedUser extends AdminUser {
|
||||
deleted_at?: string;
|
||||
withdrawal_reason?: string;
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('active');
|
||||
|
||||
// Active users state
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterDealer, setFilterDealer] = useState<boolean | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Deleted users state
|
||||
const [deletedUsers, setDeletedUsers] = useState<DeletedUser[]>([]);
|
||||
const [deletedTotal, setDeletedTotal] = useState(0);
|
||||
const [deletedPage, setDeletedPage] = useState(1);
|
||||
const [deletedSearch, setDeletedSearch] = useState('');
|
||||
const [deletedLoading, setDeletedLoading] = useState(true);
|
||||
|
||||
// Modal state
|
||||
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
|
||||
const [showCCModal, setShowCCModal] = useState(false);
|
||||
const [ccAmount, setCCAmount] = useState('');
|
||||
const [ccReason, setCCReason] = useState('');
|
||||
|
||||
// Delete modal state
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deleteUser, setDeleteUser] = useState<AdminUser | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [hardDelete, setHardDelete] = useState(false);
|
||||
|
||||
// Restore state
|
||||
const [restoreLoading, setRestoreLoading] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'active') {
|
||||
loadUsers();
|
||||
} else {
|
||||
loadDeletedUsers();
|
||||
}
|
||||
}, [page, filterDealer, activeTab, deletedPage]);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await adminUserApi.getUsers({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
search: search || undefined,
|
||||
is_dealer: filterDealer,
|
||||
});
|
||||
setUsers(response.users);
|
||||
setTotal(response.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to load users:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadDeletedUsers = async () => {
|
||||
try {
|
||||
setDeletedLoading(true);
|
||||
const response = await adminUserApi.getDeletedUsers({
|
||||
page: deletedPage,
|
||||
page_size: pageSize,
|
||||
search: deletedSearch || undefined,
|
||||
});
|
||||
setDeletedUsers(response.users);
|
||||
setDeletedTotal(response.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to load deleted users:', error);
|
||||
} finally {
|
||||
setDeletedLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
loadUsers();
|
||||
};
|
||||
|
||||
const handleDeletedSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setDeletedPage(1);
|
||||
loadDeletedUsers();
|
||||
};
|
||||
|
||||
const handleAdjustCC = async () => {
|
||||
if (!selectedUser || !ccAmount) return;
|
||||
|
||||
try {
|
||||
const result = await adminUserApi.adjustCC(
|
||||
selectedUser.id,
|
||||
parseFloat(ccAmount),
|
||||
ccReason || 'Admin adjustment'
|
||||
);
|
||||
alert(`CC adjusted! New balance: ${result.new_balance}`);
|
||||
setShowCCModal(false);
|
||||
setCCAmount('');
|
||||
setCCReason('');
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
console.error('Failed to adjust CC:', error);
|
||||
alert('Failed to adjust CC');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!deleteUser) return;
|
||||
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
const result = await adminUserApi.deleteUser(deleteUser.id, hardDelete);
|
||||
alert(result.message);
|
||||
setShowDeleteModal(false);
|
||||
setDeleteUser(null);
|
||||
setHardDelete(false);
|
||||
loadUsers();
|
||||
loadDeletedUsers();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete user:', error);
|
||||
alert(error.response?.data?.detail || 'Failed to delete user');
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreUser = async (userId: number) => {
|
||||
if (!confirm('Are you sure you want to restore this user?')) return;
|
||||
|
||||
setRestoreLoading(userId);
|
||||
try {
|
||||
const result = await adminUserApi.restoreUser(userId);
|
||||
alert(result.message);
|
||||
loadDeletedUsers();
|
||||
loadUsers();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to restore user:', error);
|
||||
alert(error.response?.data?.detail || 'Failed to restore user');
|
||||
} finally {
|
||||
setRestoreLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePermanentDelete = async (user: DeletedUser) => {
|
||||
if (!confirm(`Are you sure you want to PERMANENTLY delete ${user.email}? This cannot be undone!`)) return;
|
||||
|
||||
setRestoreLoading(user.id);
|
||||
try {
|
||||
const result = await adminUserApi.deleteUser(user.id, true);
|
||||
alert(result.message);
|
||||
loadDeletedUsers();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to permanently delete user:', error);
|
||||
alert(error.response?.data?.detail || 'Failed to delete user');
|
||||
} finally {
|
||||
setRestoreLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
const deletedTotalPages = Math.ceil(deletedTotal / pageSize);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-800">User Management</h1>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => { setActiveTab('active'); setPage(1); }}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'active'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Active Users
|
||||
<span className={`ml-2 py-0.5 px-2 rounded-full text-xs ${
|
||||
activeTab === 'active' ? 'bg-primary-100 text-primary-600' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{total}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTab('deleted'); setDeletedPage(1); }}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'deleted'
|
||||
? 'border-red-500 text-red-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Deleted Users
|
||||
<span className={`ml-2 py-0.5 px-2 rounded-full text-xs ${
|
||||
activeTab === 'deleted' ? 'bg-red-100 text-red-600' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{deletedTotal}
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Active Users Tab */}
|
||||
{activeTab === 'active' && (
|
||||
<>
|
||||
{/* Search & Filter */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||
<form onSubmit={handleSearch} className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by email, name, or phone..."
|
||||
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filterDealer === undefined ? '' : filterDealer ? 'true' : 'false'}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === '') setFilterDealer(undefined);
|
||||
else setFilterDealer(e.target.value === 'true');
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">All Users</option>
|
||||
<option value="true">Dealers Only</option>
|
||||
<option value="false">Non-Dealers</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No users found
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Phone</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Country</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">CC Balance</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Referral</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Registered</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm text-gray-800">{user.id}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-800">{user.email}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-800">{user.name || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-800">{user.phone || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-800">{user.country || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm font-semibold text-blue-600">
|
||||
{user.cc_balance.toLocaleString()} CC
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{user.is_dealer ? (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs">Dealer</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-600 rounded-full text-xs">User</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
<div className="text-xs">
|
||||
<div>Code: {user.referral_code || '-'}</div>
|
||||
{user.referred_by && <div className="text-blue-500">By: {user.referred_by}</div>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{formatDate(user.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setShowCCModal(true);
|
||||
}}
|
||||
className="px-3 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Adjust CC
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDeleteUser(user);
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t">
|
||||
<div className="text-sm text-gray-500">
|
||||
Page {page} of {totalPages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Deleted Users Tab */}
|
||||
{activeTab === 'deleted' && (
|
||||
<>
|
||||
{/* Search */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||
<form onSubmit={handleDeletedSearch} className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
value={deletedSearch}
|
||||
onChange={(e) => setDeletedSearch(e.target.value)}
|
||||
placeholder="Search deleted users by email, name, or phone..."
|
||||
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Deleted Users Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
{deletedLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-500"></div>
|
||||
</div>
|
||||
) : deletedUsers.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No deleted users found
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-red-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-red-600 uppercase">ID</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-red-600 uppercase">Email</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-red-600 uppercase">Name</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-red-600 uppercase">Phone</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-red-600 uppercase">CC Balance</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-red-600 uppercase">Deleted At</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-red-600 uppercase">Reason</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-red-600 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{deletedUsers.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-red-50">
|
||||
<td className="px-4 py-3 text-sm text-gray-800">{user.id}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-800">{user.email}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-800">{user.name || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-800">{user.phone || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm font-semibold text-blue-600">
|
||||
{user.cc_balance.toLocaleString()} CC
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-red-600">
|
||||
{formatDate(user.deleted_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 max-w-[200px] truncate">
|
||||
{user.withdrawal_reason || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleRestoreUser(user.id)}
|
||||
disabled={restoreLoading === user.id}
|
||||
className="px-3 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50"
|
||||
>
|
||||
{restoreLoading === user.id ? 'Restoring...' : 'Restore'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePermanentDelete(user)}
|
||||
disabled={restoreLoading === user.id}
|
||||
className="px-3 py-1 text-xs bg-red-700 text-white rounded hover:bg-red-800 disabled:opacity-50"
|
||||
>
|
||||
Permanent Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{deletedTotalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t">
|
||||
<div className="text-sm text-gray-500">
|
||||
Page {deletedPage} of {deletedTotalPages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setDeletedPage(p => Math.max(1, p - 1))}
|
||||
disabled={deletedPage === 1}
|
||||
className="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletedPage(p => Math.min(deletedTotalPages, p + 1))}
|
||||
disabled={deletedPage === deletedTotalPages}
|
||||
className="px-3 py-1 border rounded hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* CC Adjustment Modal */}
|
||||
{showCCModal && selectedUser && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
|
||||
<h3 className="text-lg font-semibold mb-4">Adjust CC Balance</h3>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600">User: {selectedUser.email}</p>
|
||||
<p className="text-sm text-gray-600">Current Balance: {selectedUser.cc_balance.toLocaleString()} CC</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Amount (positive to add, negative to subtract)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={ccAmount}
|
||||
onChange={(e) => setCCAmount(e.target.value)}
|
||||
placeholder="e.g., 100 or -50"
|
||||
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Reason (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ccReason}
|
||||
onChange={(e) => setCCReason(e.target.value)}
|
||||
placeholder="e.g., Bonus credit, Refund, etc."
|
||||
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCCModal(false);
|
||||
setCCAmount('');
|
||||
setCCReason('');
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdjustCC}
|
||||
disabled={!ccAmount}
|
||||
className="flex-1 px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 disabled:opacity-50"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete User Modal */}
|
||||
{showDeleteModal && deleteUser && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
|
||||
<h3 className="text-lg font-semibold text-red-600 mb-4">Delete User</h3>
|
||||
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-red-700 text-sm">
|
||||
Are you sure you want to delete this user?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 space-y-2">
|
||||
<p className="text-sm text-gray-600"><span className="font-medium">Email:</span> {deleteUser.email}</p>
|
||||
<p className="text-sm text-gray-600"><span className="font-medium">Name:</span> {deleteUser.name || '-'}</p>
|
||||
<p className="text-sm text-gray-600"><span className="font-medium">CC Balance:</span> {deleteUser.cc_balance.toLocaleString()} CC</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hardDelete}
|
||||
onChange={(e) => setHardDelete(e.target.checked)}
|
||||
className="w-4 h-4 text-red-600 border-gray-300 rounded focus:ring-red-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
Permanently delete (cannot be recovered)
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1 ml-6">
|
||||
{hardDelete
|
||||
? 'User and all related data will be permanently deleted from the database.'
|
||||
: 'User will be soft deleted (can be restored from Deleted Users tab).'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeleteUser(null);
|
||||
setHardDelete(false);
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteUser}
|
||||
disabled={deleteLoading}
|
||||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{deleteLoading ? 'Deleting...' : (hardDelete ? 'Delete Permanently' : 'Delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
525
frontend/src/app/admin/vehicle-requests/page.tsx
Normal file
525
frontend/src/app/admin/vehicle-requests/page.tsx
Normal file
@@ -0,0 +1,525 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { vehicleRequestsApi, carmodooApi, VehicleRequest, VehicleRequestWithVehicles, CarmodooSearchResult } from '@/lib/api';
|
||||
|
||||
const ITEMS_PER_PAGE = 12;
|
||||
|
||||
export default function AdminVehicleRequestsPage() {
|
||||
const [requests, setRequests] = useState<VehicleRequest[]>([]);
|
||||
const [selectedRequest, setSelectedRequest] = useState<VehicleRequestWithVehicles | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<CarmodooSearchResult[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [deletingVehicleId, setDeletingVehicleId] = useState<number | null>(null);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalResults, setTotalResults] = useState(0);
|
||||
|
||||
// Load requests
|
||||
useEffect(() => {
|
||||
loadRequests();
|
||||
}, [statusFilter]);
|
||||
|
||||
const loadRequests = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await vehicleRequestsApi.adminGetAllRequests(statusFilter || undefined);
|
||||
setRequests(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load requests:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load request detail
|
||||
const loadRequestDetail = async (requestId: number) => {
|
||||
try {
|
||||
const data = await vehicleRequestsApi.adminGetRequestDetail(requestId);
|
||||
setSelectedRequest(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load request detail:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Search for vehicles based on request criteria
|
||||
const searchVehicles = async (page: number = 1) => {
|
||||
if (!selectedRequest) return;
|
||||
|
||||
try {
|
||||
setIsSearching(true);
|
||||
const params: any = {
|
||||
page: page,
|
||||
page_size: 50, // Fetch more results
|
||||
};
|
||||
|
||||
if (selectedRequest.request.maker_code) params.maker_code = selectedRequest.request.maker_code;
|
||||
if (selectedRequest.request.model_code) params.model_code = selectedRequest.request.model_code;
|
||||
if (selectedRequest.request.grade_code) params.grade = selectedRequest.request.grade_code;
|
||||
if (selectedRequest.request.year_from) params.year_min = selectedRequest.request.year_from;
|
||||
if (selectedRequest.request.year_to) params.year_max = selectedRequest.request.year_to;
|
||||
if (selectedRequest.request.mileage_min) params.mileage_min = selectedRequest.request.mileage_min;
|
||||
if (selectedRequest.request.mileage_max) params.mileage_max = selectedRequest.request.mileage_max;
|
||||
if (selectedRequest.request.fuel) params.fuel = selectedRequest.request.fuel;
|
||||
|
||||
const result = await carmodooApi.requestSearch(params);
|
||||
setSearchResults(result.cars);
|
||||
setTotalResults(result.cars.length);
|
||||
setCurrentPage(page);
|
||||
} catch (error) {
|
||||
console.error('Failed to search vehicles:', error);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get paginated results
|
||||
const getPaginatedResults = () => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||
return searchResults.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(searchResults.length / ITEMS_PER_PAGE);
|
||||
|
||||
// Add vehicle to request
|
||||
const addVehicleToRequest = async (car: CarmodooSearchResult) => {
|
||||
if (!selectedRequest) return;
|
||||
|
||||
try {
|
||||
await vehicleRequestsApi.adminAddVehicle(selectedRequest.request.id, {
|
||||
request_id: selectedRequest.request.id,
|
||||
car_data: car,
|
||||
is_approved: true, // Auto-approve when adding
|
||||
});
|
||||
|
||||
// Reload request detail
|
||||
await loadRequestDetail(selectedRequest.request.id);
|
||||
|
||||
// Remove from search results
|
||||
setSearchResults(prev => prev.filter(c => c.id !== car.id));
|
||||
} catch (error) {
|
||||
console.error('Failed to add vehicle:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete vehicle from request
|
||||
const deleteVehicleFromRequest = async (vehicleId: number) => {
|
||||
if (!selectedRequest) return;
|
||||
|
||||
if (!confirm('Are you sure you want to remove this vehicle from the recommendation list?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDeletingVehicleId(vehicleId);
|
||||
await vehicleRequestsApi.adminDeleteVehicle(selectedRequest.request.id, vehicleId);
|
||||
|
||||
// Reload request detail
|
||||
await loadRequestDetail(selectedRequest.request.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete vehicle:', error);
|
||||
} finally {
|
||||
setDeletingVehicleId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Update request status
|
||||
const updateStatus = async (requestId: number, newStatus: string) => {
|
||||
try {
|
||||
await vehicleRequestsApi.adminUpdateRequestStatus(requestId, newStatus);
|
||||
loadRequests();
|
||||
if (selectedRequest?.request.id === requestId) {
|
||||
loadRequestDetail(requestId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Get status badge
|
||||
const getStatusBadge = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
reviewed: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
pending: 'Pending',
|
||||
reviewed: 'Reviewed',
|
||||
completed: 'Completed',
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colors[status] || 'bg-gray-100 text-gray-800'}`}>
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Vehicle Requests</h1>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="border border-gray-300 rounded-lg px-4 py-2"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="reviewed">Reviewed</option>
|
||||
<option value="completed">Completed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Requests List */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="font-semibold text-gray-700">Requests ({requests.length})</h2>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div>
|
||||
</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No requests found</div>
|
||||
) : (
|
||||
<div className="divide-y max-h-[600px] overflow-y-auto">
|
||||
{requests.map((request) => (
|
||||
<div
|
||||
key={request.id}
|
||||
onClick={() => loadRequestDetail(request.id)}
|
||||
className={`p-4 cursor-pointer hover:bg-gray-50 transition ${
|
||||
selectedRequest?.request.id === request.id ? 'bg-primary-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-800">
|
||||
{request.maker_name} - {request.model_name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
User ID: {request.user_id}
|
||||
</p>
|
||||
</div>
|
||||
{getStatusBadge(request.status)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{request.year_from && request.year_to && (
|
||||
<span className="mr-4">Year: {request.year_from}-{request.year_to}</span>
|
||||
)}
|
||||
{request.mileage_max && (
|
||||
<span>Max Mileage: {Math.round(request.mileage_max / 10000)}만km</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{formatDate(request.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Request Detail */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="font-semibold text-gray-700">Request Detail</h2>
|
||||
</div>
|
||||
|
||||
{!selectedRequest ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
Select a request to view details
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Request Info */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-gray-700 mb-3">Search Criteria</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Maker:</span>
|
||||
<span className="ml-2 font-medium">{selectedRequest.request.maker_name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Model:</span>
|
||||
<span className="ml-2 font-medium">{selectedRequest.request.model_name}</span>
|
||||
</div>
|
||||
{selectedRequest.request.grade_name && (
|
||||
<div>
|
||||
<span className="text-gray-500">Grade:</span>
|
||||
<span className="ml-2 font-medium">{selectedRequest.request.grade_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{(selectedRequest.request.year_from || selectedRequest.request.year_to) && (
|
||||
<div>
|
||||
<span className="text-gray-500">Year:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{selectedRequest.request.year_from || '-'} ~ {selectedRequest.request.year_to || '-'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(selectedRequest.request.mileage_min || selectedRequest.request.mileage_max) && (
|
||||
<div>
|
||||
<span className="text-gray-500">Mileage:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{selectedRequest.request.mileage_min ? Math.round(selectedRequest.request.mileage_min / 10000) : '-'} ~{' '}
|
||||
{selectedRequest.request.mileage_max ? Math.round(selectedRequest.request.mileage_max / 10000) : '-'} 만km
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedRequest.request.fuel && (
|
||||
<div>
|
||||
<span className="text-gray-500">Fuel:</span>
|
||||
<span className="ml-2 font-medium">{selectedRequest.request.fuel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Status:</span>
|
||||
{getStatusBadge(selectedRequest.request.status)}
|
||||
<select
|
||||
value={selectedRequest.request.status}
|
||||
onChange={(e) => updateStatus(selectedRequest.request.id, e.target.value)}
|
||||
className="ml-auto border border-gray-300 rounded px-3 py-1 text-sm"
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="reviewed">Reviewed</option>
|
||||
<option value="completed">Completed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Approved Vehicles */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-gray-700">
|
||||
Recommended Vehicles ({selectedRequest.approved_vehicles.length})
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddModal(true);
|
||||
setCurrentPage(1);
|
||||
searchVehicles(1);
|
||||
}}
|
||||
className="bg-primary-600 text-white px-3 py-1 rounded text-sm hover:bg-primary-700"
|
||||
>
|
||||
+ Add Vehicle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedRequest.approved_vehicles.length === 0 ? (
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-center text-gray-500 text-sm">
|
||||
No vehicles added yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{selectedRequest.approved_vehicles.map((vehicle) => (
|
||||
<div key={vehicle.id} className="bg-gray-50 rounded-lg p-3 flex items-center gap-3">
|
||||
{vehicle.car_data.main_image && (
|
||||
<img
|
||||
src={vehicle.car_data.main_image}
|
||||
alt=""
|
||||
className="w-16 h-12 object-cover rounded"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{vehicle.car_data.car_name}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{vehicle.car_data.year} | {vehicle.car_data.mileage?.toLocaleString()}km
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-primary-600">
|
||||
{vehicle.car_data.final_price?.toLocaleString()}만원
|
||||
</p>
|
||||
<button
|
||||
onClick={() => deleteVehicleFromRequest(vehicle.id)}
|
||||
disabled={deletingVehicleId === vehicle.id}
|
||||
className="text-red-500 hover:text-red-700 p-1 disabled:opacity-50"
|
||||
title="Remove from list"
|
||||
>
|
||||
{deletingVehicleId === vehicle.id ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-500"></div>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Vehicle Modal */}
|
||||
{showAddModal && selectedRequest && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg max-w-5xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b flex items-center justify-between shrink-0">
|
||||
<h3 className="font-semibold text-gray-800">Search & Add Vehicles</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddModal(false);
|
||||
setSearchResults([]);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="text-gray-500 hover:text-gray-700 text-2xl"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Searching for: <span className="font-medium">{selectedRequest.request.maker_name} {selectedRequest.request.model_name}</span>
|
||||
</p>
|
||||
{searchResults.length > 0 && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Found {searchResults.length} vehicles (showing {Math.min((currentPage - 1) * ITEMS_PER_PAGE + 1, searchResults.length)}-{Math.min(currentPage * ITEMS_PER_PAGE, searchResults.length)})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => searchVehicles(1)}
|
||||
disabled={isSearching}
|
||||
className="bg-primary-600 text-white px-4 py-2 rounded hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{isSearching ? 'Searching...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{isSearching ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p className="mt-2 text-gray-500">Searching from Carmodoo...</p>
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No vehicles found</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{getPaginatedResults().map((car) => (
|
||||
<div key={car.id} className="border rounded-lg p-3 hover:shadow-md transition">
|
||||
{car.main_image && (
|
||||
<img
|
||||
src={car.main_image}
|
||||
alt=""
|
||||
className="w-full h-32 object-cover rounded mb-2"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-sm truncate" title={car.car_name}>{car.car_name}</p>
|
||||
<div className="text-xs text-gray-500 space-y-0.5">
|
||||
<p>{car.year}년 | {car.mileage?.toLocaleString()}km</p>
|
||||
<p>{car.fuel} | {car.transmission}</p>
|
||||
{car.color && <p>Color: {car.color}</p>}
|
||||
</div>
|
||||
<p className="text-sm font-bold text-primary-600">
|
||||
{car.final_price?.toLocaleString()}만원
|
||||
</p>
|
||||
<button
|
||||
onClick={() => addVehicleToRequest(car)}
|
||||
className="w-full bg-green-600 text-white px-3 py-1.5 rounded text-sm hover:bg-green-700 mt-2"
|
||||
>
|
||||
+ Add to List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{searchResults.length > ITEMS_PER_PAGE && (
|
||||
<div className="p-4 border-t shrink-0 flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 border rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 border rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
{/* Page numbers */}
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter(page => {
|
||||
// Show first, last, current, and pages near current
|
||||
return page === 1 || page === totalPages || Math.abs(page - currentPage) <= 2;
|
||||
})
|
||||
.map((page, index, array) => (
|
||||
<span key={page}>
|
||||
{index > 0 && array[index - 1] !== page - 1 && (
|
||||
<span className="px-2 text-gray-400">...</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className={`px-3 py-1 border rounded ${
|
||||
currentPage === page
|
||||
? 'bg-primary-600 text-white border-primary-600'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 border rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 border rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
410
frontend/src/app/admin/visitor-stats/page.tsx
Normal file
410
frontend/src/app/admin/visitor-stats/page.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { visitorApi, VisitorStatsOverview, TopPage, TopReferrer, ChartData, RealtimeStats } from '@/lib/api';
|
||||
|
||||
// Country code to name mapping
|
||||
const countryNames: Record<string, string> = {
|
||||
MN: 'Mongolia',
|
||||
RU: 'Russia',
|
||||
KR: 'Korea',
|
||||
US: 'United States',
|
||||
CN: 'China',
|
||||
JP: 'Japan',
|
||||
DE: 'Germany',
|
||||
FR: 'France',
|
||||
GB: 'United Kingdom',
|
||||
AU: 'Australia',
|
||||
unknown: 'Unknown',
|
||||
LO: 'Local',
|
||||
};
|
||||
|
||||
// Simple bar chart component
|
||||
const SimpleBarChart = ({ data, color = 'bg-primary-500', height = 120 }: {
|
||||
data: ChartData | null;
|
||||
color?: string;
|
||||
height?: number;
|
||||
}) => {
|
||||
if (!data || data.values.length === 0) {
|
||||
return <div className="text-gray-400 text-center py-4">No data</div>;
|
||||
}
|
||||
|
||||
const maxValue = Math.max(...data.values, 1);
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-1" style={{ height }}>
|
||||
{data.values.map((value, index) => (
|
||||
<div key={index} className="flex-1 flex flex-col items-center group relative">
|
||||
<div className="hidden group-hover:block absolute -top-8 bg-gray-800 text-white text-xs px-2 py-1 rounded z-10 whitespace-nowrap">
|
||||
{data.labels[index]}: {value}
|
||||
</div>
|
||||
<div
|
||||
className={`w-full ${color} rounded-t transition-all hover:opacity-80`}
|
||||
style={{ height: `${(value / maxValue) * 100}%`, minHeight: value > 0 ? '4px' : '0' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Breakdown card component
|
||||
const BreakdownCard = ({ title, data, icon, nameMap }: {
|
||||
title: string;
|
||||
data: Record<string, number>;
|
||||
icon: string;
|
||||
nameMap?: Record<string, string>;
|
||||
}) => {
|
||||
const total = Object.values(data).reduce((a, b) => a + b, 0);
|
||||
const sortedEntries = Object.entries(data).sort((a, b) => b[1] - a[1]).slice(0, 5);
|
||||
|
||||
const colors = ['bg-blue-500', 'bg-green-500', 'bg-purple-500', 'bg-amber-500', 'bg-red-500'];
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-xl">{icon}</span>
|
||||
<h3 className="font-semibold text-gray-800">{title}</h3>
|
||||
</div>
|
||||
<div className="text-gray-400 text-center py-4">No data</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-xl">{icon}</span>
|
||||
<h3 className="font-semibold text-gray-800">{title}</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{sortedEntries.map(([key, value], index) => {
|
||||
const displayName = nameMap ? (nameMap[key] || key) : key;
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${colors[index] || 'bg-gray-400'}`} />
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-700">{displayName || 'Unknown'}</span>
|
||||
<span className="text-gray-500">{value} ({((value / total) * 100).toFixed(1)}%)</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5 mt-1">
|
||||
<div
|
||||
className={`h-1.5 rounded-full ${colors[index] || 'bg-gray-400'}`}
|
||||
style={{ width: `${(value / total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function VisitorStatsPage() {
|
||||
const [overview, setOverview] = useState<VisitorStatsOverview | null>(null);
|
||||
const [visitsChart, setVisitsChart] = useState<ChartData | null>(null);
|
||||
const [uniqueChart, setUniqueChart] = useState<ChartData | null>(null);
|
||||
const [topPages, setTopPages] = useState<TopPage[]>([]);
|
||||
const [topReferrers, setTopReferrers] = useState<TopReferrer[]>([]);
|
||||
const [realtime, setRealtime] = useState<RealtimeStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [days, setDays] = useState(30);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [days]);
|
||||
|
||||
// Realtime refresh every 30 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(loadRealtime, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadRealtime = async () => {
|
||||
try {
|
||||
const realtimeData = await visitorApi.getRealtime(5);
|
||||
setRealtime(realtimeData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load realtime stats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [overviewData, visitsData, uniqueData, pagesData, referrersData, realtimeData] = await Promise.all([
|
||||
visitorApi.getOverview(days),
|
||||
visitorApi.getVisitsChart(days),
|
||||
visitorApi.getUniqueVisitorsChart(days),
|
||||
visitorApi.getTopPages(days),
|
||||
visitorApi.getTopReferrers(days),
|
||||
visitorApi.getRealtime(5),
|
||||
]);
|
||||
setOverview(overviewData);
|
||||
setVisitsChart(visitsData);
|
||||
setUniqueChart(uniqueData);
|
||||
setTopPages(pagesData);
|
||||
setTopReferrers(referrersData);
|
||||
setRealtime(realtimeData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load visitor stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatNumber = (num: number) => new Intl.NumberFormat('ko-KR').format(num);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Visitor Statistics</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={days}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
className="px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value={7}>Last 7 days</option>
|
||||
<option value={14}>Last 14 days</option>
|
||||
<option value={30}>Last 30 days</option>
|
||||
<option value={90}>Last 90 days</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Realtime Stats */}
|
||||
{realtime && (
|
||||
<div className="bg-gradient-to-r from-green-500 to-emerald-600 rounded-xl shadow-sm p-5 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-3 h-3 bg-white rounded-full animate-pulse"></div>
|
||||
<span className="font-semibold">Real-time Visitors</span>
|
||||
</div>
|
||||
<span className="text-3xl font-bold">{realtime.active_visitors}</span>
|
||||
</div>
|
||||
<p className="text-green-100 text-sm mt-1">Active in the last {realtime.minutes} minutes</p>
|
||||
{realtime.recent_pages.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-green-400/30">
|
||||
<p className="text-green-100 text-xs mb-2">Active Pages:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{realtime.recent_pages.slice(0, 3).map((page, i) => (
|
||||
<span key={i} className="bg-white/20 px-2 py-1 rounded text-xs">
|
||||
{page.path} ({page.views})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overview Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">Total Page Views</p>
|
||||
<p className="text-2xl font-bold text-gray-800 mt-1">
|
||||
{formatNumber(overview?.total_visits || 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center text-2xl">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">Unique Visitors</p>
|
||||
<p className="text-2xl font-bold text-gray-800 mt-1">
|
||||
{formatNumber(overview?.unique_visitors || 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center text-2xl">
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">Pages per Visit</p>
|
||||
<p className="text-2xl font-bold text-gray-800 mt-1">
|
||||
{overview && overview.unique_visitors > 0
|
||||
? (overview.total_visits / overview.unique_visitors).toFixed(1)
|
||||
: '0'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center text-2xl">
|
||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">Mobile Visitors</p>
|
||||
<p className="text-2xl font-bold text-gray-800 mt-1">
|
||||
{overview?.device_breakdown?.mobile && overview.total_visits > 0
|
||||
? `${((overview.device_breakdown.mobile / overview.total_visits) * 100).toFixed(0)}%`
|
||||
: '0%'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-amber-100 rounded-lg flex items-center justify-center text-2xl">
|
||||
<svg className="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">Page Views</h3>
|
||||
<SimpleBarChart data={visitsChart} color="bg-blue-500" height={120} />
|
||||
{visitsChart && visitsChart.labels.length > 0 && (
|
||||
<div className="flex justify-between mt-2 text-xs text-gray-400">
|
||||
<span>{visitsChart.labels[0]}</span>
|
||||
<span>{visitsChart.labels[visitsChart.labels.length - 1]}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">Unique Visitors</h3>
|
||||
<SimpleBarChart data={uniqueChart} color="bg-green-500" height={120} />
|
||||
{uniqueChart && uniqueChart.labels.length > 0 && (
|
||||
<div className="flex justify-between mt-2 text-xs text-gray-400">
|
||||
<span>{uniqueChart.labels[0]}</span>
|
||||
<span>{uniqueChart.labels[uniqueChart.labels.length - 1]}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breakdowns */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<BreakdownCard
|
||||
title="By Device"
|
||||
data={overview?.device_breakdown || {}}
|
||||
icon="💻"
|
||||
/>
|
||||
<BreakdownCard
|
||||
title="By Browser"
|
||||
data={overview?.browser_breakdown || {}}
|
||||
icon="🌐"
|
||||
/>
|
||||
<BreakdownCard
|
||||
title="By Country"
|
||||
data={overview?.country_breakdown || {}}
|
||||
icon="🌍"
|
||||
nameMap={countryNames}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tables */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top Pages */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">Top Pages</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-gray-500 uppercase border-b">
|
||||
<th className="pb-2 pr-2">#</th>
|
||||
<th className="pb-2">Page</th>
|
||||
<th className="pb-2 text-right">Views</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{topPages.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="py-4 text-center text-gray-400">
|
||||
No data
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
topPages.slice(0, 10).map((page, index) => (
|
||||
<tr key={index} className="text-sm hover:bg-gray-50">
|
||||
<td className="py-2 pr-2 text-gray-400">{index + 1}</td>
|
||||
<td className="py-2 text-gray-700 truncate max-w-[250px]" title={page.path}>
|
||||
{page.path}
|
||||
</td>
|
||||
<td className="py-2 text-right text-gray-600 font-medium">{formatNumber(page.views)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Referrers */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">Top Referrers</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-gray-500 uppercase border-b">
|
||||
<th className="pb-2 pr-2">#</th>
|
||||
<th className="pb-2">Source</th>
|
||||
<th className="pb-2 text-right">Visits</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{topReferrers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="py-4 text-center text-gray-400">
|
||||
No referrer data (direct visits)
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
topReferrers.map((ref, index) => (
|
||||
<tr key={index} className="text-sm hover:bg-gray-50">
|
||||
<td className="py-2 pr-2 text-gray-400">{index + 1}</td>
|
||||
<td className="py-2 text-gray-700">{ref.domain}</td>
|
||||
<td className="py-2 text-right text-gray-600 font-medium">{formatNumber(ref.visits)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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