Files
AutonetSellCar/frontend/src/app/admin/cars/page.tsx
AutonetSellCar Deploy 6cf2c69371 Fix loadLocalCars error handling for banner API failure
- Wrap banner API call in try-catch to prevent entire function failure
- Fall back to car.is_banner field if banner API returns error
- Local Cars tab now loads even if banner API fails

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 16:05:04 +09:00

2836 lines
122 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import Image from 'next/image';
import { Reorder, useDragControls } from 'framer-motion';
import api, { heroBannersApi, carmodooApi, vehicleRequestsApi, carsApi } from '@/lib/api';
import { translateCarName } from '@/lib/i18n';
interface CarmodooMaker {
code: string;
name: string;
}
interface CarmodooModel {
code: string;
name: string;
type: string;
}
interface Grade {
code: string;
name: string;
}
interface CarmodooCarItem {
id: string;
car_name: string;
maker_code?: string;
maker_name?: string;
model_code?: string;
model_name?: string;
car_type?: string;
car_type_name?: string;
grade?: string;
grade_name?: string;
year?: number;
month?: number;
mileage?: number;
price?: number;
fuel?: string;
transmission?: string;
color?: string;
displacement?: number;
car_number?: string;
main_image?: string;
images?: string[];
options?: string[];
dealer_name?: string;
shop_name?: string;
seize_count?: number;
collateral_count?: number;
check_num?: string; // 성능점검번호
car_key?: string; // 암호화된 차량 키 (딜러 설명용)
}
interface LocalCar {
id: number;
source: string;
source_id: string;
car_name: string;
year?: number;
month?: number;
mileage?: number;
price_krw?: number;
margin_krw?: number;
margin_mn?: number;
final_price_krw?: number;
final_price_mn?: number;
is_displayed?: boolean;
is_banner?: boolean;
soldout?: boolean;
fuel?: string;
transmission?: string;
color?: string;
displacement?: number;
car_number?: string;
seize_count?: number;
collateral_count?: number;
dealer_name?: string;
status: string;
created_at: string;
images?: { id: number; url: string; is_main: boolean; sort_order: number }[];
maker?: { name: string };
model?: { name: string };
}
interface SearchFilters {
maker_code: string;
model_code: string;
grade: string;
year_min: string;
year_max: string;
price_min: string;
price_max: string;
mileage_max: string;
fuel: string;
displacement_min: string;
displacement_max: string;
}
const FUEL_TYPES = [
{ value: '', label: 'All' },
{ value: '가솔린', label: 'Gasoline' },
{ value: '디젤', label: 'Diesel' },
{ value: '하이브리드', label: 'Hybrid' },
{ value: '전기', label: 'Electric' },
{ value: 'LPG', label: 'LPG' },
];
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
// 이미지 URL 변환 (로컬 경로는 백엔드 URL 추가)
const getImageUrl = (url: string | undefined): string => {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
return `${API_BASE_URL}${url}`;
};
const YEAR_OPTIONS = Array.from({ length: 15 }, (_, i) => 2024 - i);
type TabType = 'local' | 'all' | 'carmodoo';
export default function CarsAdminPage() {
const [activeTab, setActiveTab] = useState<TabType>('local');
// Local cars state
const [localCars, setLocalCars] = useState<LocalCar[]>([]);
const [localLoading, setLocalLoading] = useState(false);
const [localTotal, setLocalTotal] = useState(0);
const [localPage, setLocalPage] = useState(1);
const [selectedCar, setSelectedCar] = useState<LocalCar | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [selectedLocalCars, setSelectedLocalCars] = useState<Set<number>>(new Set());
const [registeringLocalBanner, setRegisteringLocalBanner] = useState(false);
const [togglingBanner, setTogglingBanner] = useState<number | null>(null);
const [bannerCarIds, setBannerCarIds] = useState<number[]>([]); // 배너 순서대로 정렬된 차량 ID
// 배너 관리 상태 (배치 업데이트용)
const [localBannerSelections, setLocalBannerSelections] = useState<Set<number>>(new Set()); // 체크된 배너 차량 ID
const [bannerOrderedCars, setBannerOrderedCars] = useState<LocalCar[]>([]); // 순서 변경 가능한 배너 차량 목록
const [nonBannerCars, setNonBannerCars] = useState<LocalCar[]>([]); // 배너가 아닌 차량 목록
const [hasBannerChanges, setHasBannerChanges] = useState(false); // 변경사항 있는지
const [updatingBanners, setUpdatingBanners] = useState(false); // 업데이트 중
// All Cars (public view) state
const [allCars, setAllCars] = useState<LocalCar[]>([]);
const [allCarsLoading, setAllCarsLoading] = useState(false);
const [allCarsTotal, setAllCarsTotal] = useState(0);
const [allCarsPage, setAllCarsPage] = useState(1);
// Carmodoo search state
const [makers, setMakers] = useState<CarmodooMaker[]>([]);
const [models, setModels] = useState<CarmodooModel[]>([]);
const [grades, setGrades] = useState<Grade[]>([]);
const [cars, setCars] = useState<CarmodooCarItem[]>([]);
const [selectedCars, setSelectedCars] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState(false);
const [searched, setSearched] = useState(false);
const [totalCount, setTotalCount] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [filters, setFilters] = useState<SearchFilters>({
maker_code: '',
model_code: '',
grade: '',
year_min: '',
year_max: '',
price_min: '',
price_max: '',
mileage_max: '',
fuel: '',
displacement_min: '',
displacement_max: '',
});
const [importResult, setImportResult] = useState<{
imported: number;
skipped: number;
errors: number;
pdfSuccess: number;
pdfFailed: number;
pdfDetails?: Array<{
car_id: number;
car_name: string;
success: boolean;
attempts: number;
error?: string;
}>;
} | null>(null);
const [pdfStatus, setPdfStatus] = useState<Record<number, boolean>>({});
const [regeneratingPdf, setRegeneratingPdf] = useState<number | null>(null);
const [registeringBanners, setRegisteringBanners] = useState(false);
const [bannerResult, setBannerResult] = useState<{
registered: number;
imported: number;
skipped: number;
errors: number;
pdfSuccess: number;
pdfFailed: number;
pdfDetails?: Array<{
car_id: number;
car_name: string;
success: boolean;
attempts: number;
error?: string;
}>;
} | null>(null);
// Vehicle Request mode (when coming from Vehicle Requests page)
const searchParams = useSearchParams();
const router = useRouter();
const requestId = searchParams.get('requestId');
const [addingToRequest, setAddingToRequest] = useState(false);
const [requestAddResult, setRequestAddResult] = useState<{
added: number;
errors: number;
} | null>(null);
// Dealer description editing state
const [showDescEditModal, setShowDescEditModal] = useState(false);
const [editingCar, setEditingCar] = useState<CarmodooCarItem | null>(null);
const [originalDesc, setOriginalDesc] = useState('');
const [editedDesc, setEditedDesc] = useState('');
const [highlightedDesc, setHighlightedDesc] = useState('');
const [sensitiveInfo, setSensitiveInfo] = useState<{ phones: number; addresses: number; others: number; total: number } | null>(null);
const [loadingDesc, setLoadingDesc] = useState(false);
const [editedDescriptions, setEditedDescriptions] = useState<Record<string, string>>({}); // car_id -> edited description
// Dealer comment for detail modal
const [dealerComment, setDealerComment] = useState<{
ko: string | null;
en: string | null;
mn: string | null;
ru: string | null;
} | null>(null);
const [loadingComment, setLoadingComment] = useState(false);
const [editingComment, setEditingComment] = useState(false);
const [editCommentData, setEditCommentData] = useState({
ko: '',
en: '',
mn: '',
ru: '',
});
const [savingComment, setSavingComment] = useState(false);
useEffect(() => {
loadLocalCars();
loadAllCars();
loadInitialData();
}, []);
// 제조사 변경 시 모델 목록 로드
useEffect(() => {
if (filters.maker_code) {
loadModels(filters.maker_code);
setGrades([]);
setFilters((prev) => ({ ...prev, model_code: '', grade: '' }));
} else {
setModels([]);
setGrades([]);
setFilters((prev) => ({ ...prev, model_code: '', grade: '' }));
}
}, [filters.maker_code]);
// 모델 변경 시 등급 목록 로드
useEffect(() => {
if (filters.maker_code && filters.model_code) {
loadGrades(filters.maker_code, filters.model_code);
} else {
setGrades([]);
setFilters((prev) => ({ ...prev, grade: '' }));
}
}, [filters.model_code]);
// Pre-fill filters from URL params (when coming from Vehicle Requests page)
useEffect(() => {
if (requestId) {
const makerCode = searchParams.get('maker_code') || '';
const modelCode = searchParams.get('model_code') || '';
const grade = searchParams.get('grade') || '';
const yearMin = searchParams.get('year_min') || '';
const yearMax = searchParams.get('year_max') || '';
const mileageMax = searchParams.get('mileage_max') || '';
const fuel = searchParams.get('fuel') || '';
setFilters(prev => ({
...prev,
maker_code: makerCode,
model_code: modelCode,
grade: grade,
year_min: yearMin,
year_max: yearMax,
mileage_max: mileageMax,
fuel: fuel,
}));
// Switch to Carmodoo tab
setActiveTab('carmodoo');
}
}, [requestId]);
// 배너 체크박스 토글 (로컬 상태만 변경, API 호출하지 않음)
const handleToggleBannerLocal = (carId: number, e: React.MouseEvent) => {
e.stopPropagation();
setLocalBannerSelections(prev => {
const newSet = new Set(prev);
if (newSet.has(carId)) {
newSet.delete(carId);
} else {
newSet.add(carId);
}
return newSet;
});
setHasBannerChanges(true);
};
// 배너 업데이트 (일괄 저장)
const handleUpdateBanners = async () => {
setUpdatingBanners(true);
try {
// 현재 DB에 저장된 배너 ID와 새로 선택된 배너 ID 비교
const currentBannerIds = new Set(bannerCarIds);
const newBannerIds = localBannerSelections;
// 추가할 배너
const toAdd: number[] = [];
newBannerIds.forEach(id => {
if (!currentBannerIds.has(id)) {
toAdd.push(id);
}
});
// 제거할 배너
const toRemove: number[] = [];
currentBannerIds.forEach(id => {
if (!newBannerIds.has(id)) {
toRemove.push(id);
}
});
// 배너 추가/제거 API 호출
for (const carId of toRemove) {
await heroBannersApi.adminToggleBanner(carId); // 제거
}
for (const carId of toAdd) {
await heroBannersApi.adminToggleBanner(carId); // 추가
}
// 배너 순서 업데이트 (bannerOrderedCars 순서대로)
const orderedIds = bannerOrderedCars
.filter(car => newBannerIds.has(car.id))
.map(car => car.id);
// 새로 추가된 배너는 맨 뒤에
toAdd.forEach(id => {
if (!orderedIds.includes(id)) {
orderedIds.push(id);
}
});
if (orderedIds.length > 0) {
await heroBannersApi.adminReorderBanners(orderedIds);
}
// 목록 새로고침
await loadLocalCars();
alert(`배너가 업데이트되었습니다. (추가: ${toAdd.length}, 제거: ${toRemove.length})`);
} catch (err) {
console.error('Failed to update banners:', err);
alert('배너 업데이트에 실패했습니다.');
} finally {
setUpdatingBanners(false);
}
};
// 드래그앤드롭으로 배너 순서 변경
const handleBannerReorder = (newOrder: LocalCar[]) => {
setBannerOrderedCars(newOrder);
setLocalCars([...newOrder, ...nonBannerCars]);
setHasBannerChanges(true);
};
// Soldout 토글 핸들러
const handleToggleSoldout = async (carId: number, currentSoldout: boolean, e: React.MouseEvent) => {
e.stopPropagation();
try {
if (currentSoldout) {
await carsApi.markAvailable(carId);
} else {
await carsApi.markSoldout(carId);
}
// 로컬 상태 업데이트
setLocalCars(prev => prev.map(car =>
car.id === carId ? { ...car, soldout: !currentSoldout } : car
));
} catch (err) {
console.error('Failed to toggle soldout:', err);
alert('상태 변경에 실패했습니다.');
}
};
const loadLocalCars = async (page = 1, preserveBannerState = false) => {
setLocalLoading(true);
try {
const { data } = await api.get('/cars', { params: { page, page_size: 100, admin: true } });
const cars: LocalCar[] = data.cars || [];
// 배너 목록도 함께 로드 (순서 정보 포함) - 실패해도 차량 목록은 표시
let orderedBannerIds: number[] = [];
try {
const bannerResult = await heroBannersApi.adminGetBannerCars();
orderedBannerIds = bannerResult.car_ids || [];
} catch (bannerErr) {
console.error('Failed to load banner cars:', bannerErr);
// 배너 로드 실패 시 car.is_banner 필드 사용
orderedBannerIds = cars.filter(c => c.is_banner).map(c => c.id);
}
setBannerCarIds(orderedBannerIds);
// 배너 차량과 비배너 차량 분리
const bannerCarsMap = new Map<number, LocalCar>();
const nonBannerCarsList: LocalCar[] = [];
for (const car of cars) {
if (orderedBannerIds.includes(car.id)) {
bannerCarsMap.set(car.id, car);
} else {
nonBannerCarsList.push(car);
}
}
// 배너 차량을 display_order 순서대로 정렬
const sortedBannerCars: LocalCar[] = [];
for (const carId of orderedBannerIds) {
const car = bannerCarsMap.get(carId);
if (car) {
sortedBannerCars.push(car);
}
}
setBannerOrderedCars(sortedBannerCars);
setNonBannerCars(nonBannerCarsList);
// 전체 목록 (배너 먼저, 그 다음 비배너)
setLocalCars([...sortedBannerCars, ...nonBannerCarsList]);
setLocalTotal(data.total || 0);
setLocalPage(page);
// 배너 상태 초기화 (preserveBannerState가 false일 때만)
if (!preserveBannerState) {
setLocalBannerSelections(new Set(orderedBannerIds));
setHasBannerChanges(false);
}
// Fetch PDF status for all cars
const carIds = cars.map((c: LocalCar) => c.id);
if (carIds.length > 0) {
try {
const pdfRes = await api.post('/carmodoo/pdf-status', carIds);
setPdfStatus(prev => ({ ...prev, ...pdfRes.data }));
} catch (err) {
console.error('Failed to fetch PDF status:', err);
}
}
} catch (err) {
console.error('Failed to load local cars:', err);
} finally {
setLocalLoading(false);
}
};
const loadAllCars = async (page = 1) => {
setAllCarsLoading(true);
try {
// Load only displayed cars (public view)
const { data } = await api.get('/cars', { params: { page, page_size: 20 } });
setAllCars(data.cars || []);
setAllCarsTotal(data.total || 0);
setAllCarsPage(page);
// Fetch PDF status for all cars
const carIds = (data.cars || []).map((c: LocalCar) => c.id);
if (carIds.length > 0) {
try {
const pdfRes = await api.post('/carmodoo/pdf-status', carIds);
setPdfStatus(prev => ({ ...prev, ...pdfRes.data }));
} catch (err) {
console.error('Failed to fetch PDF status:', err);
}
}
} catch (err) {
console.error('Failed to load all cars:', err);
} finally {
setAllCarsLoading(false);
}
};
const handleUpdateCar = async (carId: number, updates: { margin_krw?: number; margin_mn?: number; is_displayed?: boolean }) => {
try {
await api.put(`/cars/${carId}`, updates);
// Update local state
setLocalCars(prev => prev.map(car =>
car.id === carId
? {
...car,
...updates,
final_price_krw: updates.margin_krw !== undefined
? (car.price_krw || 0) + updates.margin_krw
: car.final_price_krw,
final_price_mn: updates.margin_mn !== undefined
? (car.price_krw || 0) + updates.margin_mn
: car.final_price_mn
}
: car
));
// Also update selectedCar if it's the same
if (selectedCar?.id === carId) {
setSelectedCar(prev => prev ? {
...prev,
...updates,
final_price_krw: updates.margin_krw !== undefined
? (prev.price_krw || 0) + updates.margin_krw
: prev.final_price_krw,
final_price_mn: updates.margin_mn !== undefined
? (prev.price_krw || 0) + updates.margin_mn
: prev.final_price_mn
} : null);
}
} catch (err) {
console.error('Failed to update car:', err);
alert('Failed to update car.');
}
};
const handleToggleDisplay = async (car: LocalCar, e: React.MouseEvent) => {
e.stopPropagation();
await handleUpdateCar(car.id, { is_displayed: !car.is_displayed });
};
const loadInitialData = async () => {
try {
const makersRes = await api.get('/carmodoo/makers');
setMakers(makersRes.data);
} catch (err) {
console.error('Failed to load initial data:', err);
}
};
// Load grades when model changes
const loadGrades = async (makerCode: string, modelCode: string) => {
try {
const { data } = await api.get(`/carmodoo/grades/${makerCode}/${modelCode}`);
setGrades(data);
} catch (err) {
console.error('Failed to load grades:', err);
setGrades([]);
}
};
const loadModels = async (makerCode: string) => {
try {
const { data } = await api.get(`/carmodoo/models/${makerCode}`);
setModels(data);
} catch (err) {
console.error('Failed to load models:', err);
setModels([]);
}
};
const handleFilterChange = (key: keyof SearchFilters, value: string) => {
setFilters((prev) => ({ ...prev, [key]: value }));
};
const handleSearch = async (page = 1) => {
setLoading(true);
setImportResult(null);
try {
const params: Record<string, any> = { page, page_size: 20 };
if (filters.maker_code) params.maker_code = filters.maker_code;
if (filters.model_code) params.model_code = filters.model_code;
if (filters.grade) params.grade = filters.grade;
if (filters.year_min) params.year_min = parseInt(filters.year_min);
if (filters.year_max) params.year_max = parseInt(filters.year_max);
if (filters.price_min) params.price_min = parseInt(filters.price_min) * 10000;
if (filters.price_max) params.price_max = parseInt(filters.price_max) * 10000;
if (filters.mileage_max) params.mileage_max = parseInt(filters.mileage_max);
if (filters.fuel) params.fuel = filters.fuel;
if (filters.displacement_min) params.displacement_min = parseInt(filters.displacement_min);
if (filters.displacement_max) params.displacement_max = parseInt(filters.displacement_max);
const { data } = await api.get('/carmodoo/search', { params });
setCars(data.cars);
setTotalCount(data.total);
setCurrentPage(page);
setSearched(true);
setSelectedCars(new Set());
// 성능점검번호 재시도 결과 표시
if (data.check_num_retried > 0) {
const msg = `${data.check_num_retried}개 차량의 성능점검번호를 재시도하여 ${data.check_num_retry_success}개 성공`;
console.log('[Carmodoo]', msg);
}
} catch (err) {
console.error('Search failed:', err);
alert('Search failed. Please try again.');
} finally {
setLoading(false);
}
};
const handleSelectCar = (carId: string) => {
setSelectedCars((prev) => {
const next = new Set(prev);
if (next.has(carId)) {
next.delete(carId);
} else {
next.add(carId);
}
return next;
});
};
const handleSelectAll = () => {
if (selectedCars.size === cars.length) {
setSelectedCars(new Set());
} else {
setSelectedCars(new Set(cars.map((c) => c.id)));
}
};
const handleImport = async () => {
if (selectedCars.size === 0) {
alert('Please select at least one car to import.');
return;
}
if (!confirm(`Import ${selectedCars.size} selected car(s) to local database?`)) {
return;
}
setImporting(true);
try {
const { data } = await api.post('/carmodoo/import', {
car_ids: Array.from(selectedCars),
});
// PDF 상태 상세 정보 추출
const pdfDetails = data.imported?.map((item: any) => ({
car_id: item.car_id,
car_name: item.car_name,
success: item.pdf_status?.success || false,
attempts: item.pdf_status?.attempts || 0,
error: item.pdf_status?.error,
})) || [];
setImportResult({
imported: data.summary.imported_count,
skipped: data.summary.skipped_count,
errors: data.summary.error_count,
pdfSuccess: data.summary.pdf_success_count || 0,
pdfFailed: data.summary.pdf_failed_count || 0,
pdfDetails,
});
// Remove imported cars from selection and reload local cars
setSelectedCars(new Set());
loadLocalCars();
} catch (err) {
console.error('Import failed:', err);
alert('Import failed. Please try again.');
} finally {
setImporting(false);
}
};
const handleRegeneratePdf = async (carId: number, e: React.MouseEvent) => {
e.stopPropagation();
if (regeneratingPdf) return;
setRegeneratingPdf(carId);
try {
// 먼저 fetch-check-num 시도 (check_num이 없는 경우 가져오기 + PDF 생성)
const response = await api.post(`/carmodoo/car/${carId}/fetch-check-num`);
if (response.data.pdf_path) {
setPdfStatus(prev => ({ ...prev, [carId]: true }));
alert('PDF 생성 완료!');
} else if (response.data.check_number) {
// check_number는 있지만 PDF 생성 실패
alert(`성능점검번호(${response.data.check_number})를 가져왔으나 PDF 생성에 실패했습니다. 다시 시도해주세요.`);
} else {
alert('PDF 생성 실패: ' + (response.data.message || 'Unknown error'));
}
} catch (err: any) {
console.error('PDF regeneration failed:', err);
const errorMsg = err.response?.data?.detail || err.message;
if (errorMsg.includes('Car number not available')) {
alert('차량번호가 없어 성능점검번호를 가져올 수 없습니다.');
} else if (errorMsg.includes('Could not find check number')) {
alert('카모두에서 성능점검번호를 찾을 수 없습니다.');
} else {
alert('PDF 생성 실패: ' + errorMsg);
}
} finally {
setRegeneratingPdf(null);
}
};
// 딜러 설명 미리보기 및 편집 함수
const handleEditDealerDescription = async (car: CarmodooCarItem) => {
setEditingCar(car);
setShowDescEditModal(true);
setLoadingDesc(true);
setOriginalDesc('');
setEditedDesc('');
setHighlightedDesc('');
setSensitiveInfo(null);
try {
const response = await fetch(`/api/carmodoo/preview-dealer-description/${car.id}?car_key=${encodeURIComponent(car.car_key || '')}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
const data = await response.json();
if (data.found) {
setOriginalDesc(data.original);
setHighlightedDesc(data.highlighted_html);
setSensitiveInfo(data.summary);
// 이미 편집된 설명이 있으면 사용, 없으면 마스킹된 텍스트 사용
setEditedDesc(editedDescriptions[car.id] || data.masked_text);
} else {
setOriginalDesc('');
setEditedDesc('');
setHighlightedDesc('');
setSensitiveInfo({ phones: 0, addresses: 0, others: 0, total: 0 });
}
} catch (err) {
console.error('Failed to load dealer description:', err);
} finally {
setLoadingDesc(false);
}
};
const handleSaveEditedDescription = () => {
if (editingCar) {
setEditedDescriptions(prev => ({
...prev,
[editingCar.id]: editedDesc
}));
}
setShowDescEditModal(false);
setEditingCar(null);
};
const handleApplyMasked = () => {
if (editingCar) {
// 마스킹된 텍스트로 설정
fetch('/api/carmodoo/check-sensitive-info', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({ text: originalDesc }),
})
.then(res => res.json())
.then(data => {
setEditedDesc(data.masked_text);
});
}
};
// 배너 등록 함수
const handleRegisterAsBanner = async () => {
if (selectedCars.size === 0) {
alert('Please select at least one car to register as banner.');
return;
}
if (!confirm(`${selectedCars.size}개의 차량을 Hero Banner로 등록하시겠습니까?`)) {
return;
}
setRegisteringBanners(true);
setBannerResult(null);
try {
const selectedCarsList = cars.filter(car => selectedCars.has(car.id));
// 1. 먼저 차량을 로컬 DB에 저장 (이미지 다운로드 포함)
const carsToImport = selectedCarsList.map(car => ({
car_no: car.id,
car_name: car.car_name || '',
maker_name: car.maker_name,
model_name: car.model_name,
year: car.year,
mileage: car.mileage,
price: car.price,
fuel: car.fuel,
transmission: car.transmission,
color: car.color,
displacement: car.displacement,
main_image: car.main_image,
check_num: car.check_num, // 성능점검번호
car_key: car.car_key, // 암호화된 차량 키 (딜러 설명용)
dealer_description: editedDescriptions[car.id] || undefined, // 편집된 딜러 설명
}));
const importResult = await carmodooApi.importCars(carsToImport);
// 2. car_id 매핑 생성
const carIdMap: Record<string, number> = {};
for (const imp of importResult.imported) {
carIdMap[imp.car_no] = imp.car_id;
}
for (const skip of importResult.skipped) {
carIdMap[skip.car_no] = skip.car_id;
}
// 3. 기존 배너 목록 가져오기 (순서 설정용)
const existingBanners = await heroBannersApi.adminGetList();
let orderStart = existingBanners.length;
let successCount = 0;
// 4. Banner 생성 (car_id 연결)
for (const car of selectedCarsList) {
const carId = carIdMap[car.id];
if (!carId) continue;
const localImageUrl = `/uploads/cars/${carId}/image_0.jpg`;
const bannerData = {
title_ko: car.car_name || '',
title_en: translateCarName(car.car_name, 'en'),
title_mn: translateCarName(car.car_name, 'mn'),
title_ru: translateCarName(car.car_name, 'ru'),
subtitle_ko: `${car.year}년식 | ${car.mileage?.toLocaleString()}km`,
subtitle_en: `${car.year} | ${car.mileage?.toLocaleString()}km`,
subtitle_mn: `${car.year} | ${car.mileage?.toLocaleString()}km`,
subtitle_ru: `${car.year} | ${car.mileage?.toLocaleString()}km`,
image_url: localImageUrl,
link_url: `/cars/${carId}`,
is_active: true,
display_order: orderStart++,
car_id: carId,
};
await heroBannersApi.adminCreate(bannerData);
successCount++;
}
// PDF 상태 상세 정보 추출
const pdfDetails = importResult.imported?.map((item: any) => ({
car_id: item.car_id,
car_name: item.car_name,
success: item.pdf_status?.success || false,
attempts: item.pdf_status?.attempts || 0,
error: item.pdf_status?.error,
})) || [];
setBannerResult({
registered: successCount,
imported: importResult.summary?.imported_count || 0,
skipped: importResult.summary?.skipped_count || 0,
errors: importResult.summary?.error_count || 0,
pdfSuccess: importResult.summary?.pdf_success_count || 0,
pdfFailed: importResult.summary?.pdf_failed_count || 0,
pdfDetails,
});
setSelectedCars(new Set());
loadLocalCars();
const pdfFailedCount = importResult.summary?.pdf_failed_count || 0;
if (pdfFailedCount > 0) {
alert(`${successCount}개의 차량이 Hero Banner로 등록되었습니다.\n(새로 가져온 차량: ${importResult.summary?.imported_count || 0}대)\n\n⚠ ${pdfFailedCount}개 차량의 PDF 생성에 실패했습니다. 상세 내용을 확인하세요.`);
} else {
alert(`${successCount}개의 차량이 Hero Banner로 등록되었습니다.\n(새로 가져온 차량: ${importResult.summary?.imported_count || 0}대)`);
}
} catch (err) {
console.error('Banner registration failed:', err);
alert('배너 등록에 실패했습니다.');
} finally {
setRegisteringBanners(false);
}
};
// Local Cars에서 배너 등록하는 함수
const handleRegisterLocalCarAsBanner = async () => {
if (selectedLocalCars.size === 0) {
alert('Please select at least one car to register as banner.');
return;
}
if (!confirm(`${selectedLocalCars.size}개의 차량을 Hero Banner로 등록하시겠습니까?`)) {
return;
}
setRegisteringLocalBanner(true);
try {
const selectedCarsList = localCars.filter(car => selectedLocalCars.has(car.id));
const existingBanners = await heroBannersApi.adminGetList();
let orderStart = existingBanners.length;
let successCount = 0;
for (const car of selectedCarsList) {
const localImageUrl = `/uploads/cars/${car.id}/image_0.jpg`;
const bannerData = {
title_ko: car.car_name || '',
title_en: translateCarName(car.car_name || '', 'en'),
title_mn: translateCarName(car.car_name || '', 'mn'),
title_ru: translateCarName(car.car_name || '', 'ru'),
subtitle_ko: `${car.year || ''}년식 | ${car.mileage ? formatMileage(car.mileage) : ''}`,
subtitle_en: `${car.year || ''} | ${car.mileage ? formatMileage(car.mileage) : ''}`,
subtitle_mn: `${car.year || ''} | ${car.mileage ? formatMileage(car.mileage) : ''}`,
subtitle_ru: `${car.year || ''} | ${car.mileage ? formatMileage(car.mileage) : ''}`,
image_url: localImageUrl,
link_url: `/cars/${car.id}`,
display_order: orderStart++,
is_active: true,
car_id: car.id,
};
await heroBannersApi.adminCreate(bannerData);
successCount++;
}
alert(`${successCount}개의 차량이 Hero Banner로 등록되었습니다.`);
setSelectedLocalCars(new Set());
} catch (err) {
console.error('Local banner registration failed:', err);
alert('배너 등록에 실패했습니다.');
} finally {
setRegisteringLocalBanner(false);
}
};
// Local car selection toggle
const handleLocalCarSelect = (carId: number, e: React.MouseEvent) => {
e.stopPropagation();
setSelectedLocalCars(prev => {
const newSet = new Set(prev);
if (newSet.has(carId)) {
newSet.delete(carId);
} else {
newSet.add(carId);
}
return newSet;
});
};
// Select all local cars
const handleSelectAllLocalCars = () => {
if (selectedLocalCars.size === localCars.length) {
setSelectedLocalCars(new Set());
} else {
setSelectedLocalCars(new Set(localCars.map(car => car.id)));
}
};
// 차량 추천 목록에 추가 함수 (Vehicle Request용)
const handleAddToRequest = async () => {
if (!requestId) return;
if (selectedCars.size === 0) {
alert('Please select at least one car to add to the request.');
return;
}
if (!confirm(`${selectedCars.size}개의 차량을 추천 목록에 추가하시겠습니까?`)) {
return;
}
setAddingToRequest(true);
setRequestAddResult(null);
try {
const selectedCarsList = cars.filter(car => selectedCars.has(car.id));
let addedCount = 0;
let errorCount = 0;
for (const car of selectedCarsList) {
try {
// Add vehicle to request
await vehicleRequestsApi.adminAddVehicle(parseInt(requestId), {
request_id: parseInt(requestId),
car_data: {
id: car.id,
car_name: car.car_name,
maker_name: car.maker_name,
model_name: car.model_name,
year: car.year,
mileage: car.mileage,
final_price: car.price,
fuel: car.fuel,
transmission: car.transmission,
color: car.color,
main_image: car.main_image,
},
is_approved: true,
});
addedCount++;
} catch (err) {
console.error(`Failed to add car ${car.id}:`, err);
errorCount++;
}
}
setRequestAddResult({
added: addedCount,
errors: errorCount,
});
setSelectedCars(new Set());
if (errorCount > 0) {
alert(`${addedCount}개의 차량이 추천 목록에 추가되었습니다.\n(실패: ${errorCount}개)`);
} else {
alert(`${addedCount}개의 차량이 추천 목록에 추가되었습니다.`);
}
// Redirect back to vehicle requests page
router.push('/admin/vehicle-requests');
} catch (err) {
console.error('Add to request failed:', err);
alert('추천 목록 추가에 실패했습니다.');
} finally {
setAddingToRequest(false);
}
};
const handleDeleteCar = async (carId: number) => {
if (!confirm('Are you sure you want to delete this car?')) {
return;
}
try {
await api.delete(`/cars/${carId}`);
loadLocalCars(localPage);
// 삭제 후 모달 닫기
if (selectedCar?.id === carId) {
setShowDetailModal(false);
setSelectedCar(null);
}
} catch (err) {
console.error('Delete failed:', err);
alert('Failed to delete car.');
}
};
const handleCarClick = async (car: LocalCar) => {
setSelectedCar(car);
setCurrentImageIndex(0);
setShowDetailModal(true);
setDealerComment(null);
setEditingComment(false);
// Fetch dealer comment translations
setLoadingComment(true);
try {
const data = await carmodooApi.getCarTranslations(car.id);
setDealerComment({
ko: data.dealer_description,
en: data.translations.en,
mn: data.translations.mn,
ru: data.translations.ru,
});
} catch (error) {
console.error('Failed to load dealer comment:', error);
setDealerComment(null);
} finally {
setLoadingComment(false);
}
};
const closeDetailModal = () => {
setShowDetailModal(false);
setSelectedCar(null);
setCurrentImageIndex(0);
setDealerComment(null);
setEditingComment(false);
};
const handleEditComment = () => {
if (dealerComment) {
setEditCommentData({
ko: dealerComment.ko || '',
en: dealerComment.en || '',
mn: dealerComment.mn || '',
ru: dealerComment.ru || '',
});
setEditingComment(true);
}
};
const handleSaveComment = async () => {
if (!selectedCar) return;
setSavingComment(true);
try {
await carmodooApi.updateCarTranslations(selectedCar.id, {
dealer_description: editCommentData.ko,
dealer_description_en: editCommentData.en,
dealer_description_mn: editCommentData.mn,
dealer_description_ru: editCommentData.ru,
});
setDealerComment({
ko: editCommentData.ko,
en: editCommentData.en,
mn: editCommentData.mn,
ru: editCommentData.ru,
});
setEditingComment(false);
alert('Dealer comment saved successfully!');
} catch (error) {
console.error('Failed to save dealer comment:', error);
alert('Failed to save dealer comment');
} finally {
setSavingComment(false);
}
};
const handleRegenerateTranslation = async () => {
if (!selectedCar || !dealerComment?.ko) return;
if (!confirm('Regenerate translations from Korean? This will overwrite existing translations.')) return;
setSavingComment(true);
try {
const result = await carmodooApi.regenerateTranslations(selectedCar.id);
setDealerComment({
ko: dealerComment.ko,
en: result.translations.en,
mn: result.translations.mn,
ru: result.translations.ru,
});
alert('Translations regenerated successfully!');
} catch (error: any) {
console.error('Failed to regenerate translations:', error);
alert(error.response?.data?.detail || 'Failed to regenerate translations');
} finally {
setSavingComment(false);
}
};
const nextImage = () => {
if (selectedCar?.images && selectedCar.images.length > 0) {
setCurrentImageIndex((prev) => (prev + 1) % selectedCar.images!.length);
}
};
const prevImage = () => {
if (selectedCar?.images && selectedCar.images.length > 0) {
setCurrentImageIndex((prev) => (prev - 1 + selectedCar.images!.length) % selectedCar.images!.length);
}
};
const formatPrice = (price?: number) => {
if (!price) return '-';
return `${(price / 10000).toLocaleString()}만원`;
};
const formatMileage = (mileage?: number) => {
if (!mileage) return '-';
return `${mileage.toLocaleString()}km`;
};
const totalPages = Math.ceil(totalCount / 20);
const localTotalPages = Math.ceil(localTotal / 20);
const allCarsTotalPages = Math.ceil(allCarsTotal / 20);
return (
<div>
<h1 className="text-2xl font-bold text-gray-800 mb-6">Cars Management</h1>
{/* Request Mode Banner */}
{requestId && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="bg-blue-100 rounded-full p-2">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p className="font-medium text-blue-800">Adding vehicles to Request #{requestId}</p>
<p className="text-sm text-blue-600">Search and select vehicles, then click "Add to Request" button.</p>
</div>
</div>
<button
onClick={() => router.push('/admin/vehicle-requests')}
className="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center gap-1"
>
Back to Requests
</button>
</div>
)}
{/* Tabs */}
<div className="flex border-b border-gray-200 mb-6">
<button
onClick={() => setActiveTab('local')}
className={`px-6 py-3 font-medium text-sm border-b-2 transition-colors ${
activeTab === 'local'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Local Cars ({localTotal})
</button>
<button
onClick={() => setActiveTab('all')}
className={`px-6 py-3 font-medium text-sm border-b-2 transition-colors ${
activeTab === 'all'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
All Cars ({allCarsTotal})
</button>
<button
onClick={() => setActiveTab('carmodoo')}
className={`px-6 py-3 font-medium text-sm border-b-2 transition-colors ${
activeTab === 'carmodoo'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Search Carmodoo
</button>
</div>
{/* Local Cars Tab */}
{activeTab === 'local' && (
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-gray-800">
Imported Cars ({localTotal} total)
{selectedLocalCars.size > 0 && (
<span className="ml-2 text-sm font-normal text-purple-600">
({selectedLocalCars.size} selected)
</span>
)}
</h2>
<div className="flex items-center gap-3">
{/* 배너 업데이트 버튼 */}
{hasBannerChanges && (
<button
onClick={handleUpdateBanners}
disabled={updatingBanners}
className="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{updatingBanners ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Updating...</span>
</>
) : (
<>
<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>
<span>Update Banner</span>
</>
)}
</button>
)}
{/* 배너 상태 표시 */}
<span className="text-sm text-gray-500">
Banner: {localBannerSelections.size}
</span>
<button
onClick={() => loadLocalCars(localPage)}
disabled={localLoading}
className="text-primary-600 hover:text-primary-700 flex items-center gap-1"
>
<svg className={`w-4 h-4 ${localLoading ? 'animate-spin' : ''}`} 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>
Refresh
</button>
</div>
</div>
{localLoading ? (
<div className="flex justify-center py-12">
<div className="w-8 h-8 border-4 border-primary-600 border-t-transparent rounded-full animate-spin"></div>
</div>
) : localCars.length === 0 ? (
<div className="text-center py-12">
<div className="text-6xl mb-4">📦</div>
<h3 className="text-lg font-medium text-gray-800 mb-2">No cars imported yet</h3>
<p className="text-gray-500 mb-4">
Go to the &quot;Search Carmodoo&quot; tab to find and import cars.
</p>
<button
onClick={() => setActiveTab('carmodoo')}
className="bg-primary-600 text-white px-4 py-2 rounded-lg hover:bg-primary-700"
>
Search Carmodoo
</button>
</div>
) : (
<>
{/* 배너 차량 드래그앤드롭 섹션 */}
{bannerOrderedCars.length > 0 && (
<div className="mb-6 p-4 bg-purple-50 rounded-lg border border-purple-200">
<h3 className="text-sm font-semibold text-purple-800 mb-3 flex items-center gap-2">
<span>🎯</span>
Banner Cars ({bannerOrderedCars.length}) - Drag to reorder
</h3>
<Reorder.Group
axis="y"
values={bannerOrderedCars}
onReorder={handleBannerReorder}
className="space-y-2"
>
{bannerOrderedCars.map((car, index) => (
<Reorder.Item
key={car.id}
value={car}
className="bg-white rounded-lg shadow-sm p-3 flex items-center gap-4 cursor-grab active:cursor-grabbing border border-purple-100 hover:border-purple-300 transition-all"
>
<span className="text-purple-600 font-bold w-6">{index + 1}</span>
<div className="w-16 h-12 bg-gray-200 rounded overflow-hidden flex-shrink-0">
{car.images?.[0]?.url && (
<img
src={getImageUrl(car.images[0].url)}
alt={car.car_name || 'Car'}
className="w-full h-full object-cover"
/>
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-800 truncate">{car.car_name}</div>
<div className="text-xs text-gray-500">
{car.year} | {car.mileage?.toLocaleString()}km
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
handleToggleBannerLocal(car.id, e);
}}
className="text-red-500 hover:text-red-700 px-2 py-1 text-xs"
title="Remove from banner"
>
Remove
</button>
</Reorder.Item>
))}
</Reorder.Group>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">
Banner
</th>
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">Display</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Image</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Car Name</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Year</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Mileage</th>
<th className="py-3 px-4 text-right text-sm font-medium text-gray-600">Original</th>
<th className="py-3 px-4 text-right text-sm font-medium text-gray-600">Margin</th>
<th className="py-3 px-4 text-right text-sm font-medium text-gray-600">Final Price</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Fuel</th>
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">PDF</th>
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">Status</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody>
{localCars.map((car, index) => {
const mainImage = getImageUrl(car.images?.find((img) => img.is_main)?.url || car.images?.[0]?.url);
const isSoldout = car.soldout || false;
const isCheckedBanner = localBannerSelections.has(car.id);
const isBannerCar = bannerCarIds.includes(car.id);
return (
<tr
key={car.id}
className={`border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-all ${
isSoldout ? 'bg-gray-100 opacity-50' : ''
} ${isCheckedBanner ? 'bg-purple-50' : ''}`}
onClick={() => handleCarClick(car)}
>
{/* Banner 체크박스 */}
<td className="py-3 px-2 text-center" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-1">
{/* 드래그 핸들 (배너 차량만) */}
{isBannerCar && (
<span className="cursor-grab text-gray-400 hover:text-gray-600" title="Drag to reorder">
</span>
)}
<button
onClick={(e) => handleToggleBannerLocal(car.id, e)}
className={`w-6 h-6 rounded border-2 flex items-center justify-center transition-all ${
isCheckedBanner
? 'bg-purple-600 border-purple-600 text-white'
: 'border-gray-300 hover:border-purple-400'
}`}
title={isCheckedBanner ? 'Remove from banner' : 'Add to banner'}
>
{isCheckedBanner ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : null}
</button>
</div>
</td>
{/* Display 토글 */}
<td className="py-3 px-2 text-center">
<button
onClick={(e) => handleToggleDisplay(car, e)}
className={`w-10 h-6 rounded-full transition-colors relative ${
car.is_displayed ? 'bg-green-500' : 'bg-gray-300'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
car.is_displayed ? 'left-5' : 'left-1'
}`}
/>
</button>
</td>
<td className="py-3 px-4">
<div className="w-20 h-14 bg-gray-200 rounded overflow-hidden relative">
{mainImage ? (
<img
src={mainImage}
alt={car.car_name || 'Car'}
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
</div>
</td>
<td className="py-3 px-4">
<div className="font-medium text-gray-800">{car.car_name}</div>
<div className="text-sm text-gray-500">
{car.maker?.name} {car.model?.name}
</div>
</td>
<td className="py-3 px-4 text-gray-600">
{car.year} {car.month}
</td>
<td className="py-3 px-4 text-gray-600">{formatMileage(car.mileage)}</td>
<td className="py-3 px-4 text-right text-gray-500">
{formatPrice(car.price_krw)}
</td>
<td className="py-3 px-4 text-right text-blue-600">
{car.margin_krw ? `+${formatPrice(car.margin_krw)}` : '-'}
</td>
<td className="py-3 px-4 text-right font-semibold text-primary-600">
{formatPrice(car.final_price_krw || car.price_krw)}
</td>
<td className="py-3 px-4 text-gray-600">{car.fuel}</td>
<td className="py-3 px-2 text-center">
{pdfStatus[car.id] ? (
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700" title="PDF Available">
</span>
) : (
<button
onClick={(e) => handleRegeneratePdf(car.id, e)}
disabled={regeneratingPdf === car.id}
className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700 hover:bg-red-200 disabled:opacity-50"
title="Click to generate PDF"
>
{regeneratingPdf === car.id ? (
<span className="flex items-center gap-1">
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</span>
) : (
'✗ Retry'
)}
</button>
)}
</td>
{/* Status (Soldout) */}
<td className="py-3 px-2 text-center" onClick={(e) => e.stopPropagation()}>
{isSoldout ? (
<button
onClick={(e) => handleToggleSoldout(car.id, true, e)}
className="px-2 py-1 text-xs rounded-full bg-gray-600 text-white hover:bg-gray-700"
title="Click to mark as available"
>
SOLD
</button>
) : (
<button
onClick={(e) => handleToggleSoldout(car.id, false, e)}
className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700 hover:bg-green-200"
title="Click to mark as sold out"
>
Available
</button>
)}
</td>
<td className="py-3 px-4">
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteCar(car.id);
}}
className="text-red-600 hover:text-red-700"
>
<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>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
{localTotalPages > 1 && (
<div className="flex justify-center items-center gap-2 mt-6">
<button
onClick={() => loadLocalCars(localPage - 1)}
disabled={localPage === 1 || localLoading}
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 {localPage} of {localTotalPages}
</span>
<button
onClick={() => loadLocalCars(localPage + 1)}
disabled={localPage === localTotalPages || localLoading}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
Next
</button>
</div>
)}
</>
)}
</div>
)}
{/* All Cars Tab (Public View) */}
{activeTab === 'all' && (
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-gray-800">
All Displayed Cars ({allCarsTotal} total)
</h2>
<button
onClick={() => loadAllCars(allCarsPage)}
disabled={allCarsLoading}
className="text-primary-600 hover:text-primary-700 flex items-center gap-1"
>
<svg className={`w-4 h-4 ${allCarsLoading ? 'animate-spin' : ''}`} 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>
Refresh
</button>
</div>
<p className="text-sm text-gray-500 mb-4">
This shows the cars that are visible to users (is_displayed = true). This is the same view as the public /cars page.
</p>
{allCarsLoading ? (
<div className="flex justify-center py-12">
<div className="w-8 h-8 border-4 border-primary-600 border-t-transparent rounded-full animate-spin"></div>
</div>
) : allCars.length === 0 ? (
<div className="text-center py-12">
<div className="text-6xl mb-4">📦</div>
<h3 className="text-lg font-medium text-gray-800 mb-2">No cars displayed to users</h3>
<p className="text-gray-500">
Go to the &quot;Local Cars&quot; tab and toggle the display switch to show cars.
</p>
</div>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{allCars.map((car) => {
const mainImage = getImageUrl(car.images?.find((img) => img.is_main)?.url || car.images?.[0]?.url);
return (
<div
key={car.id}
className="border rounded-lg overflow-hidden hover:shadow-md transition cursor-pointer"
onClick={() => handleCarClick(car)}
>
<div className="relative h-40 bg-gray-100">
{mainImage ? (
<img
src={mainImage}
alt={car.car_name || 'Car'}
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
<svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
{/* PDF Status Badge */}
<div className="absolute top-2 right-2">
{pdfStatus[car.id] ? (
<span className="px-2 py-1 text-xs rounded-full bg-green-500 text-white" title="PDF Available">
PDF
</span>
) : (
<button
onClick={(e) => handleRegeneratePdf(car.id, e)}
disabled={regeneratingPdf === car.id}
className="px-2 py-1 text-xs rounded-full bg-red-500 text-white hover:bg-red-600 disabled:opacity-50"
title="Click to generate PDF"
>
{regeneratingPdf === car.id ? '...' : 'No PDF'}
</button>
)}
</div>
</div>
<div className="p-3">
<h3 className="font-medium text-gray-800 truncate">{car.car_name}</h3>
<p className="text-sm text-gray-500 mt-1">
{car.year} | {formatMileage(car.mileage)}
</p>
<p className="text-sm text-gray-500">
{car.fuel} | {car.transmission}
</p>
<p className="text-lg font-bold text-primary-600 mt-2">
{formatPrice(car.final_price_krw || car.price_krw)}
</p>
</div>
</div>
);
})}
</div>
{/* Pagination */}
{allCarsTotalPages > 1 && (
<div className="flex justify-center items-center gap-2 mt-6">
<button
onClick={() => loadAllCars(allCarsPage - 1)}
disabled={allCarsPage === 1 || allCarsLoading}
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 {allCarsPage} of {allCarsTotalPages}
</span>
<button
onClick={() => loadAllCars(allCarsPage + 1)}
disabled={allCarsPage === allCarsTotalPages || allCarsLoading}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
Next
</button>
</div>
)}
</>
)}
</div>
)}
{/* Carmodoo Search Tab */}
{activeTab === 'carmodoo' && (
<>
{/* Search Filters */}
<div className="bg-white rounded-xl shadow-sm p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">Search from Carmodoo</h2>
{/* First Row: Maker, Model, Car Type, Grade */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
{/* Maker (제조사) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Maker ()
</label>
<select
value={filters.maker_code}
onChange={(e) => handleFilterChange('maker_code', e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">All Makers</option>
{makers.map((maker) => (
<option key={maker.code} value={maker.code}>
{maker.name}
</option>
))}
</select>
</div>
{/* Model (모델) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Model ()
</label>
<select
value={filters.model_code}
onChange={(e) => handleFilterChange('model_code', e.target.value)}
disabled={!filters.maker_code}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
>
<option value="">All Models</option>
{models.map((model) => (
<option key={model.code} value={model.code}>
{model.name}
</option>
))}
</select>
</div>
{/* Grade (등급) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Grade ()
</label>
<select
value={filters.grade}
onChange={(e) => handleFilterChange('grade', e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">All Grades</option>
{grades.map((grade) => (
<option key={grade.code} value={grade.code}>
{grade.name}
</option>
))}
</select>
</div>
</div>
{/* Second Row: Year, Price */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
{/* Year Range */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Year Min ()
</label>
<select
value={filters.year_min}
onChange={(e) => handleFilterChange('year_min', e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">Min</option>
{YEAR_OPTIONS.map((year) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Year Max
</label>
<select
value={filters.year_max}
onChange={(e) => handleFilterChange('year_max', e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">Max</option>
{YEAR_OPTIONS.map((year) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
</div>
{/* Price Range (만원 단위) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Price Min (, )
</label>
<input
type="number"
value={filters.price_min}
onChange={(e) => handleFilterChange('price_min', e.target.value)}
placeholder="Min"
className="w-full border border-gray-300 rounded-lg px-3 py-2 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">
Price Max
</label>
<input
type="number"
value={filters.price_max}
onChange={(e) => handleFilterChange('price_max', e.target.value)}
placeholder="Max"
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
{/* Third Row: Mileage, Fuel, Displacement */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
{/* Mileage */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Max Mileage (, km)
</label>
<input
type="number"
value={filters.mileage_max}
onChange={(e) => handleFilterChange('mileage_max', e.target.value)}
placeholder="e.g. 100000"
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
{/* Fuel Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fuel Type ()
</label>
<select
value={filters.fuel}
onChange={(e) => handleFilterChange('fuel', e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
{FUEL_TYPES.map((fuel) => (
<option key={fuel.value} value={fuel.value}>
{fuel.label}
</option>
))}
</select>
</div>
{/* Displacement Range */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Displacement Min (, cc)
</label>
<select
value={filters.displacement_min}
onChange={(e) => handleFilterChange('displacement_min', e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">Min</option>
{[1000, 1500, 2000, 2500, 3000, 3500, 4000, 5000].map((cc) => (
<option key={cc} value={cc}>
{cc}cc
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Displacement Max
</label>
<select
value={filters.displacement_max}
onChange={(e) => handleFilterChange('displacement_max', e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">Max</option>
{[1000, 1500, 2000, 2500, 3000, 3500, 4000, 5000].map((cc) => (
<option key={cc} value={cc}>
{cc}cc
</option>
))}
</select>
</div>
</div>
{/* Search Button */}
<div className="flex justify-end">
<button
onClick={() => handleSearch(1)}
disabled={loading}
className="bg-primary-600 text-white px-6 py-2 rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{loading ? (
<>
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Searching...</span>
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span>Search</span>
</>
)}
</button>
</div>
</div>
{/* Import Result */}
{importResult && (
<div className={`border rounded-xl p-4 mb-6 ${
importResult.pdfFailed > 0
? 'bg-amber-50 border-amber-200'
: 'bg-green-50 border-green-200'
}`}>
<div className="flex justify-between items-start">
<div>
<h3 className={`font-semibold mb-2 ${
importResult.pdfFailed > 0 ? 'text-amber-800' : 'text-green-800'
}`}>
Import Complete
</h3>
<div className="text-gray-700 space-y-1">
<div>
<span className="mr-4">Imported: {importResult.imported}</span>
<span className="mr-4">Skipped: {importResult.skipped}</span>
{importResult.errors > 0 && (
<span className="text-red-600">Errors: {importResult.errors}</span>
)}
</div>
<div className="flex items-center gap-4 mt-2">
<span className="flex items-center gap-1">
<svg className="w-4 h-4 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
PDF Success: {importResult.pdfSuccess}
</span>
{importResult.pdfFailed > 0 && (
<span className="flex items-center gap-1 text-amber-700">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
PDF Failed: {importResult.pdfFailed}
</span>
)}
</div>
</div>
</div>
<button
onClick={() => setImportResult(null)}
className="text-gray-400 hover:text-gray-600"
>
<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>
{/* PDF 실패 상세 정보 */}
{importResult.pdfFailed > 0 && importResult.pdfDetails && (
<div className="mt-4 pt-4 border-t border-amber-200">
<h4 className="text-sm font-medium text-amber-800 mb-2">
PDF (3 ):
</h4>
<div className="space-y-2 max-h-40 overflow-y-auto">
{importResult.pdfDetails
.filter(d => !d.success)
.map((detail, idx) => (
<div key={idx} className="flex items-center justify-between text-sm bg-white rounded p-2">
<span className="text-gray-700">
[{detail.car_id}] {detail.car_name}
</span>
<span className="text-amber-600 text-xs">
{detail.attempts} - {detail.error || 'Unknown error'}
</span>
</div>
))}
</div>
<p className="mt-3 text-xs text-amber-700">
Local Cars "PDF" .
</p>
</div>
)}
</div>
)}
{/* Banner Registration Result */}
{bannerResult && (
<div className={`border rounded-xl p-4 mb-6 ${
bannerResult.pdfFailed > 0
? 'bg-amber-50 border-amber-200'
: 'bg-purple-50 border-purple-200'
}`}>
<div className="flex justify-between items-start">
<div>
<h3 className={`font-semibold mb-2 ${
bannerResult.pdfFailed > 0 ? 'text-amber-800' : 'text-purple-800'
}`}>
Banner Registration Complete
</h3>
<div className="text-gray-700 space-y-1">
<div>
<span className="mr-4">Banners Created: {bannerResult.registered}</span>
<span className="mr-4">Cars Imported: {bannerResult.imported}</span>
<span className="mr-4">Skipped: {bannerResult.skipped}</span>
{bannerResult.errors > 0 && (
<span className="text-red-600">Errors: {bannerResult.errors}</span>
)}
</div>
<div className="flex items-center gap-4 mt-2">
<span className="flex items-center gap-1">
<svg className="w-4 h-4 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
PDF Success: {bannerResult.pdfSuccess}
</span>
{bannerResult.pdfFailed > 0 && (
<span className="flex items-center gap-1 text-amber-700">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
PDF Failed: {bannerResult.pdfFailed}
</span>
)}
</div>
</div>
</div>
<button
onClick={() => setBannerResult(null)}
className="text-gray-400 hover:text-gray-600"
>
<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>
{/* PDF 실패 상세 정보 */}
{bannerResult.pdfFailed > 0 && bannerResult.pdfDetails && (
<div className="mt-4 pt-4 border-t border-amber-200">
<h4 className="text-sm font-medium text-amber-800 mb-2">
PDF (3 ):
</h4>
<div className="space-y-2 max-h-40 overflow-y-auto">
{bannerResult.pdfDetails
.filter(d => !d.success)
.map((detail, idx) => (
<div key={idx} className="flex items-center justify-between text-sm bg-white rounded p-2">
<span className="text-gray-700">
[{detail.car_id}] {detail.car_name}
</span>
<span className="text-amber-600 text-xs">
{detail.attempts} - {detail.error || 'Unknown error'}
</span>
</div>
))}
</div>
<p className="mt-3 text-xs text-amber-700">
Local Cars "PDF" .
</p>
</div>
)}
</div>
)}
{/* Search Results */}
{searched && (
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-gray-800">
Search Results ({totalCount} cars found)
</h2>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">
{selectedCars.size} selected
</span>
<button
onClick={handleImport}
disabled={importing || registeringBanners || addingToRequest || selectedCars.size === 0}
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{importing ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Importing...</span>
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span>Import Selected</span>
</>
)}
</button>
<button
onClick={handleRegisterAsBanner}
disabled={importing || registeringBanners || addingToRequest || selectedCars.size === 0}
className="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{registeringBanners ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Registering...</span>
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>Register as Banner</span>
</>
)}
</button>
{requestId && (
<button
onClick={handleAddToRequest}
disabled={importing || registeringBanners || addingToRequest || selectedCars.size === 0}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{addingToRequest ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Adding...</span>
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span>Add to Request #{requestId}</span>
</>
)}
</button>
)}
</div>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="py-3 px-4 text-left">
<input
type="checkbox"
checked={cars.length > 0 && selectedCars.size === cars.length}
onChange={handleSelectAll}
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Image</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Car Name</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Type/Grade</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Year</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Mileage</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Price</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Fuel</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Status</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Description</th>
</tr>
</thead>
<tbody>
{cars.map((car) => (
<tr
key={car.id}
className={`border-b border-gray-100 hover:bg-gray-50 cursor-pointer ${
selectedCars.has(car.id) ? 'bg-primary-50' : ''
}`}
onClick={() => handleSelectCar(car.id)}
>
<td className="py-3 px-4">
<input
type="checkbox"
checked={selectedCars.has(car.id)}
onChange={() => handleSelectCar(car.id)}
onClick={(e) => e.stopPropagation()}
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</td>
<td className="py-3 px-4">
<div className="w-20 h-14 bg-gray-200 rounded overflow-hidden relative">
{car.main_image ? (
<Image
src={car.main_image}
alt={car.car_name}
fill
className="object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
</div>
</td>
<td className="py-3 px-4">
<div className="font-medium text-gray-800">{car.car_name}</div>
<div className="text-sm text-gray-500">{car.car_number}</div>
</td>
<td className="py-3 px-4">
<div className="text-sm">
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs mr-1">
{car.car_type_name || car.car_type || '-'}
</span>
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
{car.grade_name || car.grade || '-'}
</span>
</div>
</td>
<td className="py-3 px-4 text-gray-600">
{car.year} {car.month}
</td>
<td className="py-3 px-4 text-gray-600">{formatMileage(car.mileage)}</td>
<td className="py-3 px-4 font-semibold text-primary-600">
{formatPrice(car.price)}
</td>
<td className="py-3 px-4 text-gray-600">{car.fuel}</td>
<td className="py-3 px-4">
{(car.seize_count || 0) > 0 || (car.collateral_count || 0) > 0 ? (
<span className="px-2 py-1 bg-red-100 text-red-700 rounded text-xs">
Issues
</span>
) : (
<span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">
Clean
</span>
)}
</td>
<td className="py-3 px-4">
<button
onClick={(e) => {
e.stopPropagation();
handleEditDealerDescription(car);
}}
className={`px-2 py-1 text-xs rounded flex items-center gap-1 ${
editedDescriptions[car.id]
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<svg className="w-3 h-3" 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>
{editedDescriptions[car.id] ? 'Edited' : 'Edit'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-2 mt-6">
<button
onClick={() => handleSearch(currentPage - 1)}
disabled={currentPage === 1 || loading}
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 {currentPage} of {totalPages}
</span>
<button
onClick={() => handleSearch(currentPage + 1)}
disabled={currentPage === totalPages || loading}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
Next
</button>
</div>
)}
{/* Empty State */}
{cars.length === 0 && (
<div className="text-center py-12 text-gray-500">
No cars found matching your criteria.
</div>
)}
</div>
)}
{/* Initial State */}
{!searched && (
<div className="bg-white rounded-xl shadow-sm p-12 text-center">
<div className="text-6xl mb-4">🔍</div>
<h3 className="text-lg font-medium text-gray-800 mb-2">
Search for Cars from Carmodoo
</h3>
<p className="text-gray-500">
Use the filters above to search for cars, then select and import them to your local database.
</p>
</div>
)}
</>
)}
{/* Car Detail Modal */}
{showDetailModal && selectedCar && (
<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-4xl w-full max-h-[90vh] overflow-y-auto">
{/* Modal Header */}
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex justify-between items-center">
<h2 className="text-xl font-bold text-gray-800">{selectedCar.car_name}</h2>
<button
onClick={closeDetailModal}
className="text-gray-500 hover:text-gray-700"
>
<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>
{/* Modal Content */}
<div className="p-6">
{/* Image Gallery */}
<div className="mb-6">
<div className="relative bg-gray-100 rounded-xl overflow-hidden" style={{ height: '400px' }}>
{selectedCar.images && selectedCar.images.length > 0 ? (
<>
<img
src={getImageUrl(selectedCar.images[currentImageIndex]?.url)}
alt={`${selectedCar.car_name} image ${currentImageIndex + 1}`}
className="w-full h-full object-contain"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
{/* Navigation Arrows */}
{selectedCar.images.length > 1 && (
<>
<button
onClick={prevImage}
className="absolute left-4 top-1/2 -translate-y-1/2 bg-black bg-opacity-50 text-white p-2 rounded-full hover:bg-opacity-70"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
onClick={nextImage}
className="absolute right-4 top-1/2 -translate-y-1/2 bg-black bg-opacity-50 text-white p-2 rounded-full hover:bg-opacity-70"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Image Counter */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-black bg-opacity-50 text-white px-3 py-1 rounded-full text-sm">
{currentImageIndex + 1} / {selectedCar.images.length}
</div>
</>
)}
</>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
<div className="text-center">
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="mt-2">No images available</p>
</div>
</div>
)}
</div>
{/* Thumbnail Strip */}
{selectedCar.images && selectedCar.images.length > 1 && (
<div className="flex gap-2 mt-4 overflow-x-auto pb-2">
{selectedCar.images.map((img, idx) => (
<button
key={img.id}
onClick={() => setCurrentImageIndex(idx)}
className={`flex-shrink-0 w-16 h-12 rounded-lg overflow-hidden border-2 ${
idx === currentImageIndex ? 'border-primary-600' : 'border-transparent'
}`}
>
<img
src={getImageUrl(img.url)}
alt={`Thumbnail ${idx + 1}`}
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</button>
))}
</div>
)}
</div>
{/* Car Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Basic Info */}
<div className="bg-gray-50 rounded-xl p-4">
<h3 className="font-semibold text-gray-800 mb-3">Basic Information</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">Maker</span>
<span className="font-medium">{selectedCar.maker?.name || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Model</span>
<span className="font-medium">{selectedCar.model?.name || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Year</span>
<span className="font-medium">{selectedCar.year} {selectedCar.month}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Mileage</span>
<span className="font-medium">{formatMileage(selectedCar.mileage)}</span>
</div>
</div>
</div>
{/* Pricing Info */}
<div className="bg-blue-50 rounded-xl p-4">
<h3 className="font-semibold text-gray-800 mb-3">Pricing</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-gray-600">Original Price</span>
<span className="font-medium">{formatPrice(selectedCar.price_krw)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Korea Margin ( )</span>
<div className="flex items-center gap-2">
<input
type="number"
value={(selectedCar.margin_krw || 0) / 10000}
onChange={(e) => {
const marginValue = parseInt(e.target.value || '0') * 10000;
handleUpdateCar(selectedCar.id, { margin_krw: marginValue });
}}
className="w-24 px-2 py-1 border border-gray-300 rounded text-right"
min="0"
onClick={(e) => e.stopPropagation()}
/>
<span className="text-gray-500"></span>
</div>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Mongolia Margin ( )</span>
<div className="flex items-center gap-2">
<input
type="number"
value={(selectedCar.margin_mn || 0) / 10000}
onChange={(e) => {
const marginValue = parseInt(e.target.value || '0') * 10000;
handleUpdateCar(selectedCar.id, { margin_mn: marginValue });
}}
className="w-24 px-2 py-1 border border-gray-300 rounded text-right"
min="0"
onClick={(e) => e.stopPropagation()}
/>
<span className="text-gray-500"></span>
</div>
</div>
<div className="border-t border-blue-200 pt-2 mt-2">
<div className="flex justify-between items-center">
<span className="text-gray-700 font-medium">Final Price (Korea)</span>
<span className="font-bold text-lg text-primary-600">
{formatPrice(selectedCar.final_price_krw || selectedCar.price_krw)}
</span>
</div>
<div className="flex justify-between items-center mt-1">
<span className="text-gray-700 font-medium">Final Price (Mongolia)</span>
<span className="font-bold text-lg text-green-600">
{formatPrice(selectedCar.final_price_mn || selectedCar.price_krw)}
</span>
</div>
</div>
<div className="flex justify-between items-center pt-2">
<span className="text-gray-600">Display to Users</span>
<button
onClick={(e) => {
e.stopPropagation();
handleUpdateCar(selectedCar.id, { is_displayed: !selectedCar.is_displayed });
}}
className={`w-12 h-7 rounded-full transition-colors relative ${
selectedCar.is_displayed ? 'bg-green-500' : 'bg-gray-300'
}`}
>
<span
className={`absolute top-1 w-5 h-5 bg-white rounded-full transition-transform ${
selectedCar.is_displayed ? 'left-6' : 'left-1'
}`}
/>
</button>
</div>
</div>
</div>
{/* Technical Info */}
<div className="bg-gray-50 rounded-xl p-4">
<h3 className="font-semibold text-gray-800 mb-3">Technical Details</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">Fuel</span>
<span className="font-medium">{selectedCar.fuel || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Transmission</span>
<span className="font-medium">{selectedCar.transmission || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Color</span>
<span className="font-medium">{selectedCar.color || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Displacement</span>
<span className="font-medium">{selectedCar.displacement ? `${selectedCar.displacement}cc` : '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Car Number</span>
<span className="font-medium">{selectedCar.car_number || '-'}</span>
</div>
</div>
</div>
{/* Status Info */}
<div className="bg-gray-50 rounded-xl p-4">
<h3 className="font-semibold text-gray-800 mb-3">Status</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">Status</span>
<span className={`px-2 py-1 rounded text-xs ${
selectedCar.status === 'active'
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-700'
}`}>
{selectedCar.status}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Seize Count</span>
<span className={`font-medium ${(selectedCar.seize_count || 0) > 0 ? 'text-red-600' : ''}`}>
{selectedCar.seize_count || 0}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Collateral Count</span>
<span className={`font-medium ${(selectedCar.collateral_count || 0) > 0 ? 'text-red-600' : ''}`}>
{selectedCar.collateral_count || 0}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Created At</span>
<span className="font-medium">{new Date(selectedCar.created_at).toLocaleDateString()}</span>
</div>
</div>
</div>
{/* Source Info */}
<div className="bg-gray-50 rounded-xl p-4">
<h3 className="font-semibold text-gray-800 mb-3">Source Info</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">Source</span>
<span className="font-medium">{selectedCar.source}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Source ID</span>
<span className="font-medium text-sm">{selectedCar.source_id}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Dealer</span>
<span className="font-medium">{selectedCar.dealer_name || '-'}</span>
</div>
</div>
</div>
</div>
{/* Dealer's Comment Section */}
<div className="mt-6 bg-amber-50 border border-amber-200 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-amber-800">Dealer's Comment</h3>
{!editingComment && dealerComment && (
<div className="flex gap-2">
<button
onClick={handleEditComment}
className="text-sm px-3 py-1 bg-amber-600 text-white rounded hover:bg-amber-700"
>
Edit
</button>
{dealerComment.ko && (
<button
onClick={handleRegenerateTranslation}
disabled={savingComment}
className="text-sm px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{savingComment ? 'Regenerating...' : 'Regenerate'}
</button>
)}
</div>
)}
</div>
{loadingComment ? (
<div className="flex justify-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-amber-600"></div>
</div>
) : editingComment ? (
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Korean (Original)</label>
<textarea
value={editCommentData.ko}
onChange={(e) => setEditCommentData({ ...editCommentData, ko: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-y min-h-[80px]"
rows={3}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">English</label>
<textarea
value={editCommentData.en}
onChange={(e) => setEditCommentData({ ...editCommentData, en: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-y min-h-[80px]"
rows={3}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Mongolian</label>
<textarea
value={editCommentData.mn}
onChange={(e) => setEditCommentData({ ...editCommentData, mn: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-y min-h-[80px]"
rows={3}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Russian</label>
<textarea
value={editCommentData.ru}
onChange={(e) => setEditCommentData({ ...editCommentData, ru: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-y min-h-[80px]"
rows={3}
/>
</div>
<div className="flex gap-2 justify-end">
<button
onClick={() => setEditingComment(false)}
className="px-3 py-1.5 border border-gray-300 text-gray-700 rounded hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleSaveComment}
disabled={savingComment}
className="px-3 py-1.5 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
>
{savingComment ? 'Saving...' : 'Save'}
</button>
</div>
</div>
) : dealerComment ? (
<div className="space-y-3">
{dealerComment.ko && (
<div>
<span className="text-xs font-medium text-gray-500 bg-gray-100 px-2 py-0.5 rounded">KO</span>
<p className="mt-1 text-sm text-gray-700 whitespace-pre-wrap">{dealerComment.ko}</p>
</div>
)}
{dealerComment.en && (
<div>
<span className="text-xs font-medium text-blue-600 bg-blue-50 px-2 py-0.5 rounded">EN</span>
<p className="mt-1 text-sm text-gray-700 whitespace-pre-wrap">{dealerComment.en}</p>
</div>
)}
{dealerComment.mn && (
<div>
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded">MN</span>
<p className="mt-1 text-sm text-gray-700 whitespace-pre-wrap">{dealerComment.mn}</p>
</div>
)}
{dealerComment.ru && (
<div>
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded">RU</span>
<p className="mt-1 text-sm text-gray-700 whitespace-pre-wrap">{dealerComment.ru}</p>
</div>
)}
{!dealerComment.ko && !dealerComment.en && !dealerComment.mn && !dealerComment.ru && (
<p className="text-sm text-gray-400 italic">No dealer comment available</p>
)}
</div>
) : (
<p className="text-sm text-gray-400 italic">No dealer comment available</p>
)}
</div>
{/* Actions */}
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => {
handleDeleteCar(selectedCar.id);
}}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-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="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>
Delete
</button>
<button
onClick={closeDetailModal}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300"
>
Close
</button>
</div>
</div>
</div>
</div>
)}
{/* Dealer Description Edit Modal */}
{showDescEditModal && editingCar && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg max-w-3xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">Edit Dealer Description</h3>
<button
onClick={() => setShowDescEditModal(false)}
className="text-gray-500 hover:text-gray-700"
>
<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>
<p className="text-sm text-gray-600 mb-4">{editingCar.car_name}</p>
{loadingDesc ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : (
<>
{/* Sensitive Info Summary */}
{sensitiveInfo && sensitiveInfo.total > 0 && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center gap-2 text-yellow-800">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="font-medium">Sensitive info detected:</span>
{sensitiveInfo.phones > 0 && <span className="px-2 py-0.5 bg-red-100 text-red-700 rounded text-xs">Phone: {sensitiveInfo.phones}</span>}
{sensitiveInfo.addresses > 0 && <span className="px-2 py-0.5 bg-amber-100 text-amber-700 rounded text-xs">Address: {sensitiveInfo.addresses}</span>}
{sensitiveInfo.others > 0 && <span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">Other: {sensitiveInfo.others}</span>}
</div>
</div>
)}
{/* Original Text with Highlighting */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Original (Highlighted)</label>
<div
className="p-3 bg-gray-50 border rounded-lg text-sm whitespace-pre-wrap max-h-40 overflow-y-auto"
dangerouslySetInnerHTML={{ __html: highlightedDesc || originalDesc || '<span class="text-gray-400">No description</span>' }}
/>
</div>
{/* Editable Text */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Edited Description
<span className="text-gray-400 font-normal ml-2">(Remove or mask sensitive info)</span>
</label>
<textarea
value={editedDesc}
onChange={(e) => setEditedDesc(e.target.value)}
className="w-full p-3 border rounded-lg text-sm h-40 resize-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Enter edited description..."
/>
</div>
{/* Action Buttons */}
<div className="flex gap-2 justify-end">
<button
onClick={handleApplyMasked}
className="px-3 py-2 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
Apply Auto-Mask
</button>
<button
onClick={() => setEditedDesc('')}
className="px-3 py-2 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
Clear
</button>
<button
onClick={() => setShowDescEditModal(false)}
className="px-4 py-2 text-sm bg-gray-200 text-gray-800 rounded hover:bg-gray-300"
>
Cancel
</button>
<button
onClick={handleSaveEditedDescription}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"
>
Save
</button>
</div>
</>
)}
</div>
</div>
</div>
)}
</div>
);
}