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:
@@ -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()}>
|
||||||
<button
|
<div className="flex items-center justify-center gap-1">
|
||||||
onClick={(e) => handleToggleBanner(car.id, e)}
|
{/* 드래그 핸들 (배너 차량만) */}
|
||||||
disabled={togglingBanner === car.id}
|
{isBannerCar && (
|
||||||
className={`w-6 h-6 rounded border-2 flex items-center justify-center transition-all ${
|
<span className="cursor-grab text-gray-400 hover:text-gray-600" title="Drag to reorder">
|
||||||
isBanner
|
⠿
|
||||||
? 'bg-purple-600 border-purple-600 text-white'
|
</span>
|
||||||
: 'border-gray-300 hover:border-purple-400'
|
)}
|
||||||
} ${togglingBanner === car.id ? 'opacity-50' : ''}`}
|
<button
|
||||||
title={isBanner ? 'Remove from banner' : 'Add to banner'}
|
onClick={(e) => handleToggleBannerLocal(car.id, e)}
|
||||||
>
|
className={`w-6 h-6 rounded border-2 flex items-center justify-center transition-all ${
|
||||||
{togglingBanner === car.id ? (
|
isCheckedBanner
|
||||||
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
? 'bg-purple-600 border-purple-600 text-white'
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
: 'border-gray-300 hover:border-purple-400'
|
||||||
<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>
|
title={isCheckedBanner ? 'Remove from banner' : 'Add to banner'}
|
||||||
) : isBanner ? (
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
{isCheckedBanner ? (
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
) : null}
|
</svg>
|
||||||
</button>
|
) : null}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{/* Display 토글 */}
|
{/* Display 토글 */}
|
||||||
<td className="py-3 px-2 text-center">
|
<td className="py-3 px-2 text-center">
|
||||||
|
|||||||
Reference in New Issue
Block a user