- 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>
2836 lines
122 KiB
TypeScript
2836 lines
122 KiB
TypeScript
'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 "Search Carmodoo" 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 "Local Cars" 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>
|
||
);
|
||
}
|