From c9fd7611a7484a0cdc9cd069ffe25925c53f961b Mon Sep 17 00:00:00 2001 From: AutonetSellCar Deploy Date: Wed, 31 Dec 2025 12:50:40 +0900 Subject: [PATCH] feat: Add banner toggle and soldout tracking to Cars page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add is_banner, soldout fields to Car model - Add banner toggle API (POST /hero-banners/admin/toggle/{car_id}) - Add soldout APIs (POST/DELETE /cars/{car_id}/soldout) - Add nightly soldout checker in agent (runs at 3:00 AM) - Update Local Cars UI with banner checkbox and status column - Remove hero-banners admin page (functionality moved to Cars page) - Banner cars sorted to top with purple background - Soldout cars displayed with gray overlay πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- agent/src/sync_agent.py | 133 +++++++++++++++++++++- backend/app/api/cars.py | 80 +++++++++++++- backend/app/api/hero_banners.py | 95 ++++++++++++++++ backend/app/models/car.py | 2 + backend/app/schemas/car.py | 4 + backend/app/services/soldout_service.py | 121 ++++++++++++++++++++ frontend/src/app/admin/cars/page.tsx | 140 +++++++++++++++++++----- frontend/src/app/admin/layout.tsx | 1 - frontend/src/app/admin/page.tsx | 11 -- frontend/src/lib/api.ts | 32 ++++++ 10 files changed, 579 insertions(+), 40 deletions(-) create mode 100644 backend/app/services/soldout_service.py diff --git a/agent/src/sync_agent.py b/agent/src/sync_agent.py index 318ad1f..0caee7d 100644 --- a/agent/src/sync_agent.py +++ b/agent/src/sync_agent.py @@ -1,11 +1,13 @@ """ Carmodoo Sync Agent - Syncs makers/models from Carmodoo to AutonetSellCar Backend +Also performs nightly soldout checks. """ import asyncio import os import logging import httpx +from datetime import datetime, time from dotenv import load_dotenv from .carmodoo_client import CarmodooClient, CarmodooConfig @@ -149,10 +151,139 @@ class SyncAgent: finally: await self.stop() + async def check_soldout(self): + """Check all cars for soldout status""" + logger.info("Starting soldout check...") + + if not await self.start(): + return + + try: + # Get all active cars from backend + response = await self.http_client.get( + f"{self.api_url}/cars", + params={"admin": True, "page_size": 1000, "status": "active"} + ) + + if response.status_code != 200: + logger.error(f"Failed to get cars: {response.status_code}") + return + + data = response.json() + cars = data.get("cars", []) + logger.info(f"Checking {len(cars)} cars for soldout status...") + + soldout_count = 0 + checked = 0 + + for car in cars: + if car.get("soldout"): + continue # Already soldout + + source_id = car.get("source_id") + car_id = car.get("id") + + if not source_id: + continue + + # Check if car exists on Carmodoo + is_available = await self._check_car_on_carmodoo(source_id) + checked += 1 + + if not is_available: + # Mark as soldout via API + try: + resp = await self.http_client.post( + f"{self.api_url}/cars/{car_id}/soldout" + ) + if resp.status_code == 200: + soldout_count += 1 + logger.info(f"Car {car_id} ({car.get('car_name')}) marked as SOLD OUT") + except Exception as e: + logger.error(f"Failed to mark car {car_id} as soldout: {e}") + + # Rate limiting + if checked % 10 == 0: + logger.info(f"Progress: {checked}/{len(cars)} checked, {soldout_count} sold out") + await asyncio.sleep(1) + + logger.info(f"Soldout check completed: {checked} checked, {soldout_count} sold out") + + except Exception as e: + logger.error(f"Soldout check error: {e}") + finally: + await self.stop() + + async def _check_car_on_carmodoo(self, source_id: str) -> bool: + """Check if car exists on Carmodoo""" + try: + # Try to get car info from Carmodoo + url = f"https://dealer.carmodoo.com/car/carPopView.html" + params = {"carNo": source_id} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, params=params) + + if response.status_code == 404: + return False + + content = response.text.lower() + sold_keywords = ["νŒλ§€μ™„λ£Œ", "판맀 μ™„λ£Œ", "μ‚­μ œλœ", "μ—†λŠ” μ°¨λŸ‰", "μ‘΄μž¬ν•˜μ§€ μ•Š"] + + for keyword in sold_keywords: + if keyword in content: + return False + + return True + + except Exception as e: + logger.error(f"Error checking car {source_id}: {e}") + return True # Assume available on error + + async def run_scheduled(self): + """Run agent with scheduled tasks""" + logger.info("Starting scheduled agent...") + + # Run initial sync + await self.run_sync() + + # Schedule nightly soldout check at 3:00 AM + while True: + now = datetime.now() + target_time = time(3, 0) # 3:00 AM + + # Calculate seconds until next 3:00 AM + target_datetime = datetime.combine(now.date(), target_time) + if now.time() >= target_time: + # Already past 3 AM today, schedule for tomorrow + from datetime import timedelta + target_datetime += timedelta(days=1) + + seconds_until = (target_datetime - now).total_seconds() + logger.info(f"Next soldout check in {seconds_until / 3600:.1f} hours at {target_datetime}") + + await asyncio.sleep(seconds_until) + + # Run soldout check + logger.info("Running scheduled soldout check...") + await self.check_soldout() + async def main(): agent = SyncAgent() - await agent.run_sync() + + # Check command line args + import sys + if len(sys.argv) > 1: + if sys.argv[1] == "sync": + await agent.run_sync() + elif sys.argv[1] == "soldout": + await agent.check_soldout() + elif sys.argv[1] == "scheduled": + await agent.run_scheduled() + else: + # Default: run scheduled + await agent.run_scheduled() if __name__ == '__main__': diff --git a/backend/app/api/cars.py b/backend/app/api/cars.py index 36d75ac..8570fd7 100644 --- a/backend/app/api/cars.py +++ b/backend/app/api/cars.py @@ -1,8 +1,9 @@ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks from sqlalchemy.orm import Session, joinedload from typing import Optional, List from ..database import get_db from ..models import Car, CarMaker, CarModel, CarImage, CarOption +from ..services.soldout_service import SoldoutChecker from ..schemas import ( CarCreate, CarUpdate, CarResponse, CarListResponse, CarMakerCreate, CarMakerResponse, @@ -29,6 +30,8 @@ def car_to_response(car: Car) -> dict: "final_price_mn": (car.price_krw or 0) + (car.margin_mn or 0), "price_usd": car.price_usd, "is_displayed": car.is_displayed or False, + "is_banner": car.is_banner or False, + "soldout": car.soldout or False, "fuel": car.fuel, "transmission": car.transmission, "color": car.color, @@ -338,3 +341,78 @@ def create_model(model_data: CarModelCreate, db: Session = Depends(get_db)): db.commit() db.refresh(model) return model + + +# ==================== Soldout APIs ==================== + +@router.post("/{car_id}/soldout") +def mark_car_soldout(car_id: int, db: Session = Depends(get_db)): + """μ°¨λŸ‰μ„ SOLD OUT 처리 (μˆ˜λ™)""" + car = db.query(Car).filter(Car.id == car_id).first() + if not car: + raise HTTPException(status_code=404, detail="Car not found") + + car.soldout = True + db.commit() + return {"car_id": car_id, "soldout": True, "message": "Car marked as sold out"} + + +@router.delete("/{car_id}/soldout") +def mark_car_available(car_id: int, db: Session = Depends(get_db)): + """μ°¨λŸ‰μ„ λ‹€μ‹œ 판맀 κ°€λŠ₯ μƒνƒœλ‘œ λ³€κ²½ (μˆ˜λ™)""" + car = db.query(Car).filter(Car.id == car_id).first() + if not car: + raise HTTPException(status_code=404, detail="Car not found") + + car.soldout = False + db.commit() + return {"car_id": car_id, "soldout": False, "message": "Car marked as available"} + + +async def run_soldout_check(db: Session): + """λ°±κ·ΈλΌμš΄λ“œμ—μ„œ soldout 체크 μ‹€ν–‰""" + checker = SoldoutChecker(db) + await checker.check_all_cars() + + +@router.post("/admin/check-soldout") +async def trigger_soldout_check( + background_tasks: BackgroundTasks, + db: Session = Depends(get_db) +): + """λͺ¨λ“  μ°¨λŸ‰μ˜ SOLD OUT μƒνƒœ 확인 (Admin, λ°±κ·ΈλΌμš΄λ“œ μ‹€ν–‰) + + Carmodooμ—μ„œ 더 이상 μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ°¨λŸ‰μ„ soldout=True둜 ν‘œμ‹œν•©λ‹ˆλ‹€. + 이 μž‘μ—…μ€ λ°±κ·ΈλΌμš΄λ“œμ—μ„œ μ‹€ν–‰λ˜λ©°, μ‹œκ°„μ΄ 였래 걸릴 수 μžˆμŠ΅λ‹ˆλ‹€. + """ + # ν˜„μž¬ active이고 soldout=False인 μ°¨λŸ‰ 수 확인 + pending_count = db.query(Car).filter( + Car.status == "active", + Car.soldout == False, + Car.source == "carmodoo" + ).count() + + # λ°±κ·ΈλΌμš΄λ“œλ‘œ μ‹€ν–‰ (주의: db session λ¬Έμ œκ°€ μžˆμ„ 수 있음) + # μ‹€μ œλ‘œλŠ” agentμ—μ„œ μŠ€μΌ€μ€„λ‘œ μ‹€ν–‰ν•˜λŠ” 것이 μ’‹μŒ + # background_tasks.add_task(run_soldout_check, db) + + return { + "message": "Soldout check will be performed by nightly agent job", + "pending_cars": pending_count, + "note": "Use carmodoo-agent for scheduled soldout checks" + } + + +@router.get("/admin/soldout-stats") +def get_soldout_stats(db: Session = Depends(get_db)): + """SOLD OUT 톡계 쑰회 (Admin)""" + total = db.query(Car).filter(Car.status == "active").count() + soldout = db.query(Car).filter(Car.status == "active", Car.soldout == True).count() + available = db.query(Car).filter(Car.status == "active", Car.soldout == False).count() + + return { + "total_active": total, + "soldout": soldout, + "available": available, + "soldout_percentage": round(soldout / total * 100, 1) if total > 0 else 0 + } diff --git a/backend/app/api/hero_banners.py b/backend/app/api/hero_banners.py index 604b5e7..af75fc8 100644 --- a/backend/app/api/hero_banners.py +++ b/backend/app/api/hero_banners.py @@ -7,6 +7,7 @@ import aiofiles from ..database import get_db from ..models.hero_banner import HeroBanner, HeroBannerSettings +from ..models.car import Car from ..schemas.hero_banner import ( HeroBannerCreate, HeroBannerUpdate, HeroBannerResponse, HeroBannerListResponse, HeroBannerLocalizedResponse, @@ -264,3 +265,97 @@ async def upload_banner_image( "image_url": image_url, "filename": filename, } + + +# ==================== Banner Toggle & Ordering ==================== + +@router.post("/admin/toggle/{car_id}") +def toggle_banner( + car_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """μ°¨λŸ‰μ˜ λ°°λ„ˆ μƒνƒœ ν† κΈ€ (Admin) + + - is_banner=False β†’ True: HeroBanner 생성 + - is_banner=True β†’ False: HeroBanner μ‚­μ œ + """ + car = db.query(Car).filter(Car.id == car_id).first() + if not car: + raise HTTPException(status_code=404, detail="Car not found") + + if car.is_banner: + # λ°°λ„ˆμ—μ„œ 제거 + banner = db.query(HeroBanner).filter(HeroBanner.car_id == car_id).first() + if banner: + db.delete(banner) + car.is_banner = False + db.commit() + return {"car_id": car_id, "is_banner": False, "message": "Removed from banner"} + else: + # λ°°λ„ˆμ— μΆ”κ°€ + # ν˜„μž¬ μ΅œλŒ€ display_order μ°ΎκΈ° + max_order = db.query(HeroBanner).count() + + # μ°¨λŸ‰ 이미지 URL + image_url = f"/uploads/cars/{car_id}/image_0.jpg" + + # λ°°λ„ˆ 생성 + banner = HeroBanner( + title_ko=car.car_name or "", + title_en=car.car_name or "", # ν”„λ‘ νŠΈμ—”λ“œμ—μ„œ λ²ˆμ—­ + title_mn=car.car_name or "", + title_ru=car.car_name or "", + subtitle_ko=f"{car.year or ''}년식 | {car.mileage:,}km" if car.mileage else f"{car.year or ''}년식", + subtitle_en=f"{car.year or ''} | {car.mileage:,}km" if car.mileage else f"{car.year or ''}", + subtitle_mn=f"{car.year or ''} | {car.mileage:,}km" if car.mileage else f"{car.year or ''}", + subtitle_ru=f"{car.year or ''} | {car.mileage:,}km" if car.mileage else f"{car.year or ''}", + image_url=image_url, + link_url=f"/cars/{car_id}", + car_id=car_id, + display_order=max_order, + is_active=True, + ) + db.add(banner) + car.is_banner = True + db.commit() + db.refresh(banner) + return {"car_id": car_id, "is_banner": True, "banner_id": banner.id, "message": "Added to banner"} + + +@router.put("/admin/reorder") +def reorder_banners( + car_ids: List[int], + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """λ°°λ„ˆ μˆœμ„œ μž¬μ •λ ¬ (Admin) + + car_ids: λ°°λ„ˆ μ°¨λŸ‰ ID λͺ©λ‘ (μ›ν•˜λŠ” μˆœμ„œλŒ€λ‘œ) + """ + for order, car_id in enumerate(car_ids): + banner = db.query(HeroBanner).filter(HeroBanner.car_id == car_id).first() + if banner: + banner.display_order = order + + db.commit() + return {"message": "Banner order updated", "count": len(car_ids)} + + +@router.get("/admin/banner-cars") +def get_banner_cars( + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """λ°°λ„ˆ λ“±λ‘λœ μ°¨λŸ‰ λͺ©λ‘ 쑰회 (Admin) + + display_order 순으둜 μ •λ ¬λœ μ°¨λŸ‰ ID λͺ©λ‘ λ°˜ν™˜ + """ + banners = db.query(HeroBanner).filter( + HeroBanner.car_id.isnot(None) + ).order_by(HeroBanner.display_order.asc()).all() + + return { + "car_ids": [b.car_id for b in banners], + "count": len(banners) + } diff --git a/backend/app/models/car.py b/backend/app/models/car.py index f583b8d..ca48c1b 100644 --- a/backend/app/models/car.py +++ b/backend/app/models/car.py @@ -50,6 +50,8 @@ class Car(Base): margin_mn = Column(BigInteger, default=0) # Mongolian margin amount in KRW price_usd = Column(DECIMAL(12, 2)) is_displayed = Column(Boolean, default=False, index=True) # Show to users + is_banner = Column(Boolean, default=False, index=True) # Registered as hero banner + soldout = Column(Boolean, default=False, index=True) # Sold to another buyer (checked nightly vs Carmodoo) fuel = Column(String(20)) transmission = Column(String(20)) diff --git a/backend/app/schemas/car.py b/backend/app/schemas/car.py index 9aa8d22..be67abb 100644 --- a/backend/app/schemas/car.py +++ b/backend/app/schemas/car.py @@ -135,6 +135,8 @@ class CarUpdate(BaseModel): color: Optional[str] = None status: Optional[str] = None is_displayed: Optional[bool] = None + is_banner: Optional[bool] = None + soldout: Optional[bool] = None class CarResponse(BaseModel): @@ -152,6 +154,8 @@ class CarResponse(BaseModel): final_price_mn: Optional[int] = None # Computed: price_krw + margin_mn (for Mongolian users) price_usd: Optional[Decimal] = None is_displayed: bool = False + is_banner: bool = False + soldout: bool = False fuel: Optional[str] = None transmission: Optional[str] = None color: Optional[str] = None diff --git a/backend/app/services/soldout_service.py b/backend/app/services/soldout_service.py new file mode 100644 index 0000000..8346f4e --- /dev/null +++ b/backend/app/services/soldout_service.py @@ -0,0 +1,121 @@ +""" +Soldout Check Service - Checks if cars are still available on Carmodoo +""" + +import logging +from datetime import datetime +from typing import List, Tuple +from sqlalchemy.orm import Session +import httpx + +from ..models.car import Car + +logger = logging.getLogger(__name__) + + +class SoldoutChecker: + """Carmodooμ—μ„œ μ°¨λŸ‰ 판맀 μ—¬λΆ€λ₯Ό ν™•μΈν•˜λŠ” μ„œλΉ„μŠ€""" + + def __init__(self, db: Session, carmodoo_session_cookie: str = None): + self.db = db + self.carmodoo_session_cookie = carmodoo_session_cookie + self.carmodoo_base_url = "https://dealer.carmodoo.com" + + async def check_car_availability(self, source_id: str) -> bool: + """ + Carmodooμ—μ„œ μ°¨λŸ‰μ΄ 아직 판맀 쀑인지 확인 + + Returns: + True if car is still available, False if sold/removed + """ + try: + async with httpx.AsyncClient(timeout=30.0) as client: + # Carmodoo μ°¨λŸ‰ 상세 νŽ˜μ΄μ§€ μ ‘κ·Ό + url = f"{self.carmodoo_base_url}/car/carPopView.html" + params = {"carNo": source_id} + + headers = {} + if self.carmodoo_session_cookie: + headers["Cookie"] = self.carmodoo_session_cookie + + response = await client.get(url, params=params, headers=headers) + + if response.status_code == 404: + return False + + # νŽ˜μ΄μ§€ λ‚΄μš©μ—μ„œ "νŒλ§€μ™„λ£Œ", "μ‚­μ œ", "μ—†λŠ” μ°¨λŸ‰" λ“± 확인 + content = response.text.lower() + sold_keywords = [ + "νŒλ§€μ™„λ£Œ", "판맀 μ™„λ£Œ", "sold", "μ‚­μ œλœ", "μ—†λŠ” μ°¨λŸ‰", + "μ‘΄μž¬ν•˜μ§€ μ•Š", "찾을 수 μ—†", "not found" + ] + + for keyword in sold_keywords: + if keyword in content: + return False + + return True + + except Exception as e: + logger.error(f"Error checking car {source_id}: {e}") + # μ—λŸ¬ λ°œμƒ μ‹œ available둜 κ°„μ£Ό (μ•ˆμ „ν•˜κ²Œ) + return True + + async def check_all_cars(self) -> Tuple[int, int, List[int]]: + """ + λͺ¨λ“  ν™œμ„± μ°¨λŸ‰μ˜ 판맀 μ—¬λΆ€ 확인 + + Returns: + (checked_count, soldout_count, soldout_car_ids) + """ + # soldout=False인 ν™œμ„± μ°¨λŸ‰λ§Œ 쑰회 + cars = self.db.query(Car).filter( + Car.status == "active", + Car.soldout == False, + Car.source == "carmodoo" + ).all() + + logger.info(f"Checking {len(cars)} cars for soldout status...") + + checked = 0 + soldout_count = 0 + soldout_ids = [] + + for car in cars: + is_available = await self.check_car_availability(car.source_id) + checked += 1 + + if not is_available: + car.soldout = True + soldout_count += 1 + soldout_ids.append(car.id) + logger.info(f"Car {car.id} ({car.car_name}) marked as SOLD OUT") + + # Rate limiting + if checked % 10 == 0: + logger.info(f"Progress: {checked}/{len(cars)} checked, {soldout_count} sold out") + import asyncio + await asyncio.sleep(1) + + self.db.commit() + + logger.info(f"Soldout check completed: {checked} checked, {soldout_count} sold out") + return checked, soldout_count, soldout_ids + + def mark_soldout(self, car_id: int) -> bool: + """μˆ˜λ™μœΌλ‘œ μ°¨λŸ‰μ„ soldout 처리""" + car = self.db.query(Car).filter(Car.id == car_id).first() + if car: + car.soldout = True + self.db.commit() + return True + return False + + def mark_available(self, car_id: int) -> bool: + """μˆ˜λ™μœΌλ‘œ μ°¨λŸ‰μ„ available 처리""" + car = self.db.query(Car).filter(Car.id == car_id).first() + if car: + car.soldout = False + self.db.commit() + return True + return False diff --git a/frontend/src/app/admin/cars/page.tsx b/frontend/src/app/admin/cars/page.tsx index 58c1b47..2ed1591 100644 --- a/frontend/src/app/admin/cars/page.tsx +++ b/frontend/src/app/admin/cars/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import Image from 'next/image'; -import api, { heroBannersApi, carmodooApi, vehicleRequestsApi } from '@/lib/api'; +import api, { heroBannersApi, carmodooApi, vehicleRequestsApi, carsApi } from '@/lib/api'; import { translateCarName } from '@/lib/i18n'; interface CarmodooMaker { @@ -67,6 +67,8 @@ interface LocalCar { final_price_krw?: number; final_price_mn?: number; is_displayed?: boolean; + is_banner?: boolean; + soldout?: boolean; fuel?: string; transmission?: string; color?: string; @@ -133,6 +135,8 @@ export default function CarsAdminPage() { const [currentImageIndex, setCurrentImageIndex] = useState(0); const [selectedLocalCars, setSelectedLocalCars] = useState>(new Set()); const [registeringLocalBanner, setRegisteringLocalBanner] = useState(false); + const [togglingBanner, setTogglingBanner] = useState(null); + const [bannerCarIds, setBannerCarIds] = useState([]); // λ°°λ„ˆ μˆœμ„œλŒ€λ‘œ μ •λ ¬λœ μ°¨λŸ‰ ID // All Cars (public view) state const [allCars, setAllCars] = useState([]); @@ -238,6 +242,7 @@ export default function CarsAdminPage() { loadLocalCars(); loadAllCars(); loadInitialData(); + loadBannerCars(); }, []); // μ œμ‘°μ‚¬ λ³€κ²½ μ‹œ λͺ¨λΈ λͺ©λ‘ λ‘œλ“œ @@ -290,11 +295,67 @@ export default function CarsAdminPage() { } }, [requestId]); + // λ°°λ„ˆ μ°¨λŸ‰ λͺ©λ‘ λ‘œλ“œ + const loadBannerCars = async () => { + try { + const result = await heroBannersApi.adminGetBannerCars(); + setBannerCarIds(result.car_ids); + } catch (err) { + console.error('Failed to load banner cars:', err); + } + }; + + // λ°°λ„ˆ ν† κΈ€ ν•Έλ“€λŸ¬ + 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); + } + }; + + // Soldout ν† κΈ€ ν•Έλ“€λŸ¬ + const handleToggleSoldout = async (carId: number, currentSoldout: boolean, e: React.MouseEvent) => { + e.stopPropagation(); + try { + if (currentSoldout) { + await carsApi.markAvailable(carId); + } else { + await carsApi.markSoldout(carId); + } + // 둜컬 μƒνƒœ μ—…λ°μ΄νŠΈ + setLocalCars(prev => prev.map(car => + car.id === carId ? { ...car, soldout: !currentSoldout } : car + )); + } catch (err) { + console.error('Failed to toggle soldout:', err); + alert('μƒνƒœ 변경에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.'); + } + }; + const loadLocalCars = async (page = 1) => { setLocalLoading(true); try { - const { data } = await api.get('/cars', { params: { page, page_size: 20, admin: true } }); - setLocalCars(data.cars || []); + const { data } = await api.get('/cars', { params: { page, page_size: 100, admin: true } }); + + // λ°°λ„ˆ μ°¨λŸ‰μ„ 맨 μœ„λ‘œ μ •λ ¬ + 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]; + + setLocalCars(sortedCars); setLocalTotal(data.total || 0); setLocalPage(page); @@ -1150,12 +1211,7 @@ export default function CarsAdminPage() { - 0} - onChange={handleSelectAllLocalCars} - className="w-4 h-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500" - /> + Banner Display Image @@ -1167,36 +1223,48 @@ export default function CarsAdminPage() { Final Price Fuel PDF + Status Actions {localCars.map((car) => { 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; return ( handleCarClick(car)} > + {/* Banner μ²΄ν¬λ°•μŠ€ */} e.stopPropagation()}> - { - setSelectedLocalCars(prev => { - const newSet = new Set(prev); - if (newSet.has(car.id)) { - newSet.delete(car.id); - } else { - newSet.add(car.id); - } - return newSet; - }); - }} - className="w-4 h-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500" - /> + + {/* Display ν† κΈ€ */} )} + {/* Status (Soldout) */} + e.stopPropagation()}> + {isSoldout ? ( + + ) : ( + + )} +