Implement batch banner update with drag-and-drop reordering

- Change banner checkbox from immediate toggle to local state management
- Add "Update Banner" button for batch saving changes
- Add draggable banner section at top using framer-motion Reorder
- Banner cars now sorted by display_order from server
- Checkboxes reflect current banner state from DB on page load

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
AutonetSellCar Deploy
2025-12-31 15:59:47 +09:00
parent 13bad3ab36
commit ea91e020b0

View File

@@ -1,8 +1,9 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import Image from 'next/image'; import Image from 'next/image';
import { Reorder, useDragControls } from 'framer-motion';
import api, { heroBannersApi, carmodooApi, vehicleRequestsApi, carsApi } from '@/lib/api'; import api, { heroBannersApi, carmodooApi, vehicleRequestsApi, carsApi } from '@/lib/api';
import { translateCarName } from '@/lib/i18n'; import { translateCarName } from '@/lib/i18n';
@@ -138,6 +139,13 @@ export default function CarsAdminPage() {
const [togglingBanner, setTogglingBanner] = useState<number | null>(null); const [togglingBanner, setTogglingBanner] = useState<number | null>(null);
const [bannerCarIds, setBannerCarIds] = useState<number[]>([]); // 배너 순서대로 정렬된 차량 ID 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 // All Cars (public view) state
const [allCars, setAllCars] = useState<LocalCar[]>([]); const [allCars, setAllCars] = useState<LocalCar[]>([]);
const [allCarsLoading, setAllCarsLoading] = useState(false); const [allCarsLoading, setAllCarsLoading] = useState(false);
@@ -242,7 +250,6 @@ export default function CarsAdminPage() {
loadLocalCars(); loadLocalCars();
loadAllCars(); loadAllCars();
loadInitialData(); loadInitialData();
loadBannerCars();
}, []); }, []);
// 제조사 변경 시 모델 목록 로드 // 제조사 변경 시 모델 목록 로드
@@ -295,34 +302,84 @@ export default function CarsAdminPage() {
} }
}, [requestId]); }, [requestId]);
// 배너 차량 목록 로드 // 배너 체크박스 토글 (로컬 상태만 변경, API 호출하지 않음)
const loadBannerCars = async () => { 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 { try {
const result = await heroBannersApi.adminGetBannerCars(); // 현재 DB에 저장된 배너 ID와 새로 선택된 배너 ID 비교
setBannerCarIds(result.car_ids); 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) { } 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) => { const handleBannerReorder = (newOrder: LocalCar[]) => {
e.stopPropagation(); setBannerOrderedCars(newOrder);
setTogglingBanner(carId); setLocalCars([...newOrder, ...nonBannerCars]);
try { setHasBannerChanges(true);
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);
}
}; };
// Soldout 토글 핸들러 // Soldout 토글 핸들러
@@ -344,23 +401,54 @@ export default function CarsAdminPage() {
} }
}; };
const loadLocalCars = async (page = 1) => { const loadLocalCars = async (page = 1, preserveBannerState = false) => {
setLocalLoading(true); setLocalLoading(true);
try { try {
const { data } = await api.get('/cars', { params: { page, page_size: 100, admin: true } }); const { data } = await api.get('/cars', { params: { page, page_size: 100, admin: true } });
const cars: LocalCar[] = data.cars || [];
// 배너 차량을 맨 위로 정렬 // 배너 목록도 함께 로드 (순서 정보 포함)
const cars = data.cars || []; const bannerResult = await heroBannersApi.adminGetBannerCars();
const bannerCars = cars.filter((c: LocalCar) => c.is_banner); const orderedBannerIds: number[] = bannerResult.car_ids || [];
const nonBannerCars = cars.filter((c: LocalCar) => !c.is_banner); setBannerCarIds(orderedBannerIds);
const sortedCars = [...bannerCars, ...nonBannerCars];
setLocalCars(sortedCars); // 배너 차량과 비배너 차량 분리
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); setLocalTotal(data.total || 0);
setLocalPage(page); setLocalPage(page);
// 배너 상태 초기화 (preserveBannerState가 false일 때만)
if (!preserveBannerState) {
setLocalBannerSelections(new Set(orderedBannerIds));
setHasBannerChanges(false);
}
// Fetch PDF status for all cars // 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) { if (carIds.length > 0) {
try { try {
const pdfRes = await api.post('/carmodoo/pdf-status', carIds); const pdfRes = await api.post('/carmodoo/pdf-status', carIds);
@@ -1152,27 +1240,32 @@ export default function CarsAdminPage() {
)} )}
</h2> </h2>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{selectedLocalCars.size > 0 && ( {/* 배너 업데이트 버튼 */}
{hasBannerChanges && (
<button <button
onClick={handleRegisterLocalCarAsBanner} onClick={handleUpdateBanners}
disabled={registeringLocalBanner} 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" 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"
> >
{registeringLocalBanner ? ( {updatingBanners ? (
<> <>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div> <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Registering...</span> <span>Updating...</span>
</> </>
) : ( ) : (
<> <>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg> </svg>
<span>Register as Banner</span> <span>Update Banner</span>
</> </>
)} )}
</button> </button>
)} )}
{/* 배너 상태 표시 */}
<span className="text-sm text-gray-500">
Banner: {localBannerSelections.size}
</span>
<button <button
onClick={() => loadLocalCars(localPage)} onClick={() => loadLocalCars(localPage)}
disabled={localLoading} disabled={localLoading}
@@ -1206,6 +1299,57 @@ export default function CarsAdminPage() {
</div> </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"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead> <thead>
@@ -1228,41 +1372,44 @@ export default function CarsAdminPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{localCars.map((car) => { {localCars.map((car, index) => {
const mainImage = getImageUrl(car.images?.find((img) => img.is_main)?.url || car.images?.[0]?.url); const mainImage = getImageUrl(car.images?.find((img) => img.is_main)?.url || car.images?.[0]?.url);
const isSoldout = car.soldout || false; const isSoldout = car.soldout || false;
const isBanner = car.is_banner || false; const isCheckedBanner = localBannerSelections.has(car.id);
const isBannerCar = bannerCarIds.includes(car.id);
return ( return (
<tr <tr
key={car.id} key={car.id}
className={`border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-all ${ className={`border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-all ${
isSoldout ? 'bg-gray-100 opacity-50' : '' isSoldout ? 'bg-gray-100 opacity-50' : ''
} ${isBanner ? 'bg-purple-50' : ''}`} } ${isCheckedBanner ? 'bg-purple-50' : ''}`}
onClick={() => handleCarClick(car)} onClick={() => handleCarClick(car)}
> >
{/* Banner 체크박스 */} {/* Banner 체크박스 */}
<td className="py-3 px-2 text-center" onClick={(e) => e.stopPropagation()}> <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 <button
onClick={(e) => handleToggleBanner(car.id, e)} onClick={(e) => handleToggleBannerLocal(car.id, e)}
disabled={togglingBanner === car.id}
className={`w-6 h-6 rounded border-2 flex items-center justify-center transition-all ${ className={`w-6 h-6 rounded border-2 flex items-center justify-center transition-all ${
isBanner isCheckedBanner
? 'bg-purple-600 border-purple-600 text-white' ? 'bg-purple-600 border-purple-600 text-white'
: 'border-gray-300 hover:border-purple-400' : 'border-gray-300 hover:border-purple-400'
} ${togglingBanner === car.id ? 'opacity-50' : ''}`} }`}
title={isBanner ? 'Remove from banner' : 'Add to banner'} title={isCheckedBanner ? 'Remove from banner' : 'Add to banner'}
> >
{togglingBanner === car.id ? ( {isCheckedBanner ? (
<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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : isBanner ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg> </svg>
) : null} ) : null}
</button> </button>
</div>
</td> </td>
{/* Display 토글 */} {/* Display 토글 */}
<td className="py-3 px-2 text-center"> <td className="py-3 px-2 text-center">