diff --git a/frontend/src/app/admin/cars/page.tsx b/frontend/src/app/admin/cars/page.tsx index 2ed1591..632e02f 100644 --- a/frontend/src/app/admin/cars/page.tsx +++ b/frontend/src/app/admin/cars/page.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useState, useEffect } from 'react'; +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'; @@ -138,6 +139,13 @@ export default function CarsAdminPage() { const [togglingBanner, setTogglingBanner] = useState(null); const [bannerCarIds, setBannerCarIds] = useState([]); // 배너 순서대로 정렬된 차량 ID + // 배너 관리 상태 (배치 업데이트용) + const [localBannerSelections, setLocalBannerSelections] = useState>(new Set()); // 체크된 배너 차량 ID + const [bannerOrderedCars, setBannerOrderedCars] = useState([]); // 순서 변경 가능한 배너 차량 목록 + const [nonBannerCars, setNonBannerCars] = useState([]); // 배너가 아닌 차량 목록 + const [hasBannerChanges, setHasBannerChanges] = useState(false); // 변경사항 있는지 + const [updatingBanners, setUpdatingBanners] = useState(false); // 업데이트 중 + // All Cars (public view) state const [allCars, setAllCars] = useState([]); const [allCarsLoading, setAllCarsLoading] = useState(false); @@ -242,7 +250,6 @@ export default function CarsAdminPage() { loadLocalCars(); loadAllCars(); loadInitialData(); - loadBannerCars(); }, []); // 제조사 변경 시 모델 목록 로드 @@ -295,34 +302,84 @@ export default function CarsAdminPage() { } }, [requestId]); - // 배너 차량 목록 로드 - const loadBannerCars = async () => { + // 배너 체크박스 토글 (로컬 상태만 변경, 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 { - const result = await heroBannersApi.adminGetBannerCars(); - setBannerCarIds(result.car_ids); + // 현재 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 load banner cars:', err); + console.error('Failed to update banners:', err); + alert('배너 업데이트에 실패했습니다.'); + } finally { + setUpdatingBanners(false); } }; - // 배너 토글 핸들러 - const handleToggleBanner = async (carId: number, e: React.MouseEvent) => { - e.stopPropagation(); - setTogglingBanner(carId); - try { - const result = await heroBannersApi.adminToggleBanner(carId); - // 로컬 상태 업데이트 - setLocalCars(prev => prev.map(car => - car.id === carId ? { ...car, is_banner: result.is_banner } : car - )); - // 배너 목록 갱신 - await loadBannerCars(); - } catch (err) { - console.error('Failed to toggle banner:', err); - alert('배너 상태 변경에 실패했습니다.'); - } finally { - setTogglingBanner(null); - } + // 드래그앤드롭으로 배너 순서 변경 + const handleBannerReorder = (newOrder: LocalCar[]) => { + setBannerOrderedCars(newOrder); + setLocalCars([...newOrder, ...nonBannerCars]); + setHasBannerChanges(true); }; // Soldout 토글 핸들러 @@ -344,23 +401,54 @@ export default function CarsAdminPage() { } }; - const loadLocalCars = async (page = 1) => { + 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 || []; - // 배너 차량을 맨 위로 정렬 - const cars = data.cars || []; - const bannerCars = cars.filter((c: LocalCar) => c.is_banner); - const nonBannerCars = cars.filter((c: LocalCar) => !c.is_banner); - const sortedCars = [...bannerCars, ...nonBannerCars]; + // 배너 목록도 함께 로드 (순서 정보 포함) + const bannerResult = await heroBannersApi.adminGetBannerCars(); + const orderedBannerIds: number[] = bannerResult.car_ids || []; + setBannerCarIds(orderedBannerIds); - setLocalCars(sortedCars); + // 배너 차량과 비배너 차량 분리 + const bannerCarsMap = new Map(); + 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 = (data.cars || []).map((c: LocalCar) => c.id); + const carIds = cars.map((c: LocalCar) => c.id); if (carIds.length > 0) { try { const pdfRes = await api.post('/carmodoo/pdf-status', carIds); @@ -1152,27 +1240,32 @@ export default function CarsAdminPage() { )}
- {selectedLocalCars.size > 0 && ( + {/* 배너 업데이트 버튼 */} + {hasBannerChanges && ( )} + {/* 배너 상태 표시 */} + + Banner: {localBannerSelections.size} +
) : ( <> + {/* 배너 차량 드래그앤드롭 섹션 */} + {bannerOrderedCars.length > 0 && ( +
+

+ 🎯 + Banner Cars ({bannerOrderedCars.length}) - Drag to reorder +

+ + {bannerOrderedCars.map((car, index) => ( + + {index + 1} +
+ {car.images?.[0]?.url && ( + {car.car_name + )} +
+
+
{car.car_name}
+
+ {car.year}년 | {car.mileage?.toLocaleString()}km +
+
+ +
+ ))} +
+
+ )} +
@@ -1228,41 +1372,44 @@ export default function CarsAdminPage() { - {localCars.map((car) => { + {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 isBanner = car.is_banner || false; + const isCheckedBanner = localBannerSelections.has(car.id); + const isBannerCar = bannerCarIds.includes(car.id); return ( handleCarClick(car)} > {/* Banner 체크박스 */} {/* Display 토글 */}
e.stopPropagation()}> - +
+ {/* 드래그 핸들 (배너 차량만) */} + {isBannerCar && ( + + ⠿ + + )} + +