From ea91e020b0116d867389fd71537d2a91a8d17eff Mon Sep 17 00:00:00 2001 From: AutonetSellCar Deploy Date: Wed, 31 Dec 2025 15:59:47 +0900 Subject: [PATCH] Implement batch banner update with drag-and-drop reordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/app/admin/cars/page.tsx | 275 ++++++++++++++++++++------- 1 file changed, 211 insertions(+), 64 deletions(-) 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 && ( + + โ ฟ + + )} + +