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';
|
||||
|
||||
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<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);
|
||||
@@ -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<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 = (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() {
|
||||
)}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedLocalCars.size > 0 && (
|
||||
{/* 배너 업데이트 버튼 */}
|
||||
{hasBannerChanges && (
|
||||
<button
|
||||
onClick={handleRegisterLocalCarAsBanner}
|
||||
disabled={registeringLocalBanner}
|
||||
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"
|
||||
>
|
||||
{registeringLocalBanner ? (
|
||||
{updatingBanners ? (
|
||||
<>
|
||||
<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">
|
||||
<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>
|
||||
<span>Register as Banner</span>
|
||||
<span>Update Banner</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* 배너 상태 표시 */}
|
||||
<span className="text-sm text-gray-500">
|
||||
Banner: {localBannerSelections.size}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => loadLocalCars(localPage)}
|
||||
disabled={localLoading}
|
||||
@@ -1206,6 +1299,57 @@ export default function CarsAdminPage() {
|
||||
</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>
|
||||
@@ -1228,41 +1372,44 @@ export default function CarsAdminPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr
|
||||
key={car.id}
|
||||
className={`border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-all ${
|
||||
isSoldout ? 'bg-gray-100 opacity-50' : ''
|
||||
} ${isBanner ? 'bg-purple-50' : ''}`}
|
||||
} ${isCheckedBanner ? 'bg-purple-50' : ''}`}
|
||||
onClick={() => handleCarClick(car)}
|
||||
>
|
||||
{/* Banner 체크박스 */}
|
||||
<td className="py-3 px-2 text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={(e) => handleToggleBanner(car.id, e)}
|
||||
disabled={togglingBanner === car.id}
|
||||
className={`w-6 h-6 rounded border-2 flex items-center justify-center transition-all ${
|
||||
isBanner
|
||||
? 'bg-purple-600 border-purple-600 text-white'
|
||||
: 'border-gray-300 hover:border-purple-400'
|
||||
} ${togglingBanner === car.id ? 'opacity-50' : ''}`}
|
||||
title={isBanner ? 'Remove from banner' : 'Add to banner'}
|
||||
>
|
||||
{togglingBanner === car.id ? (
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : null}
|
||||
</button>
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user