feat: Add banner toggle and soldout tracking to Cars page

- 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 <noreply@anthropic.com>
This commit is contained in:
AutonetSellCar Deploy
2025-12-31 12:50:40 +09:00
parent 9969554deb
commit c9fd7611a7
10 changed files with 579 additions and 40 deletions

View File

@@ -1,11 +1,13 @@
""" """
Carmodoo Sync Agent - Syncs makers/models from Carmodoo to AutonetSellCar Backend Carmodoo Sync Agent - Syncs makers/models from Carmodoo to AutonetSellCar Backend
Also performs nightly soldout checks.
""" """
import asyncio import asyncio
import os import os
import logging import logging
import httpx import httpx
from datetime import datetime, time
from dotenv import load_dotenv from dotenv import load_dotenv
from .carmodoo_client import CarmodooClient, CarmodooConfig from .carmodoo_client import CarmodooClient, CarmodooConfig
@@ -149,10 +151,139 @@ class SyncAgent:
finally: finally:
await self.stop() 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(): async def main():
agent = SyncAgent() 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__': if __name__ == '__main__':

View File

@@ -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 sqlalchemy.orm import Session, joinedload
from typing import Optional, List from typing import Optional, List
from ..database import get_db from ..database import get_db
from ..models import Car, CarMaker, CarModel, CarImage, CarOption from ..models import Car, CarMaker, CarModel, CarImage, CarOption
from ..services.soldout_service import SoldoutChecker
from ..schemas import ( from ..schemas import (
CarCreate, CarUpdate, CarResponse, CarListResponse, CarCreate, CarUpdate, CarResponse, CarListResponse,
CarMakerCreate, CarMakerResponse, 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), "final_price_mn": (car.price_krw or 0) + (car.margin_mn or 0),
"price_usd": car.price_usd, "price_usd": car.price_usd,
"is_displayed": car.is_displayed or False, "is_displayed": car.is_displayed or False,
"is_banner": car.is_banner or False,
"soldout": car.soldout or False,
"fuel": car.fuel, "fuel": car.fuel,
"transmission": car.transmission, "transmission": car.transmission,
"color": car.color, "color": car.color,
@@ -338,3 +341,78 @@ def create_model(model_data: CarModelCreate, db: Session = Depends(get_db)):
db.commit() db.commit()
db.refresh(model) db.refresh(model)
return 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
}

View File

@@ -7,6 +7,7 @@ import aiofiles
from ..database import get_db from ..database import get_db
from ..models.hero_banner import HeroBanner, HeroBannerSettings from ..models.hero_banner import HeroBanner, HeroBannerSettings
from ..models.car import Car
from ..schemas.hero_banner import ( from ..schemas.hero_banner import (
HeroBannerCreate, HeroBannerUpdate, HeroBannerResponse, HeroBannerCreate, HeroBannerUpdate, HeroBannerResponse,
HeroBannerListResponse, HeroBannerLocalizedResponse, HeroBannerListResponse, HeroBannerLocalizedResponse,
@@ -264,3 +265,97 @@ async def upload_banner_image(
"image_url": image_url, "image_url": image_url,
"filename": filename, "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)
}

View File

@@ -50,6 +50,8 @@ class Car(Base):
margin_mn = Column(BigInteger, default=0) # Mongolian margin amount in KRW margin_mn = Column(BigInteger, default=0) # Mongolian margin amount in KRW
price_usd = Column(DECIMAL(12, 2)) price_usd = Column(DECIMAL(12, 2))
is_displayed = Column(Boolean, default=False, index=True) # Show to users 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)) fuel = Column(String(20))
transmission = Column(String(20)) transmission = Column(String(20))

View File

@@ -135,6 +135,8 @@ class CarUpdate(BaseModel):
color: Optional[str] = None color: Optional[str] = None
status: Optional[str] = None status: Optional[str] = None
is_displayed: Optional[bool] = None is_displayed: Optional[bool] = None
is_banner: Optional[bool] = None
soldout: Optional[bool] = None
class CarResponse(BaseModel): class CarResponse(BaseModel):
@@ -152,6 +154,8 @@ class CarResponse(BaseModel):
final_price_mn: Optional[int] = None # Computed: price_krw + margin_mn (for Mongolian users) final_price_mn: Optional[int] = None # Computed: price_krw + margin_mn (for Mongolian users)
price_usd: Optional[Decimal] = None price_usd: Optional[Decimal] = None
is_displayed: bool = False is_displayed: bool = False
is_banner: bool = False
soldout: bool = False
fuel: Optional[str] = None fuel: Optional[str] = None
transmission: Optional[str] = None transmission: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None

View File

@@ -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

View File

@@ -3,7 +3,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } 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 api, { heroBannersApi, carmodooApi, vehicleRequestsApi } from '@/lib/api'; import api, { heroBannersApi, carmodooApi, vehicleRequestsApi, carsApi } from '@/lib/api';
import { translateCarName } from '@/lib/i18n'; import { translateCarName } from '@/lib/i18n';
interface CarmodooMaker { interface CarmodooMaker {
@@ -67,6 +67,8 @@ interface LocalCar {
final_price_krw?: number; final_price_krw?: number;
final_price_mn?: number; final_price_mn?: number;
is_displayed?: boolean; is_displayed?: boolean;
is_banner?: boolean;
soldout?: boolean;
fuel?: string; fuel?: string;
transmission?: string; transmission?: string;
color?: string; color?: string;
@@ -133,6 +135,8 @@ export default function CarsAdminPage() {
const [currentImageIndex, setCurrentImageIndex] = useState(0); const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [selectedLocalCars, setSelectedLocalCars] = useState<Set<number>>(new Set()); const [selectedLocalCars, setSelectedLocalCars] = useState<Set<number>>(new Set());
const [registeringLocalBanner, setRegisteringLocalBanner] = useState(false); const [registeringLocalBanner, setRegisteringLocalBanner] = useState(false);
const [togglingBanner, setTogglingBanner] = useState<number | null>(null);
const [bannerCarIds, setBannerCarIds] = useState<number[]>([]); // 배너 순서대로 정렬된 차량 ID
// All Cars (public view) state // All Cars (public view) state
const [allCars, setAllCars] = useState<LocalCar[]>([]); const [allCars, setAllCars] = useState<LocalCar[]>([]);
@@ -238,6 +242,7 @@ export default function CarsAdminPage() {
loadLocalCars(); loadLocalCars();
loadAllCars(); loadAllCars();
loadInitialData(); loadInitialData();
loadBannerCars();
}, []); }, []);
// 제조사 변경 시 모델 목록 로드 // 제조사 변경 시 모델 목록 로드
@@ -290,11 +295,67 @@ export default function CarsAdminPage() {
} }
}, [requestId]); }, [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) => { const loadLocalCars = async (page = 1) => {
setLocalLoading(true); setLocalLoading(true);
try { try {
const { data } = await api.get('/cars', { params: { page, page_size: 20, admin: true } }); const { data } = await api.get('/cars', { params: { page, page_size: 100, admin: true } });
setLocalCars(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];
setLocalCars(sortedCars);
setLocalTotal(data.total || 0); setLocalTotal(data.total || 0);
setLocalPage(page); setLocalPage(page);
@@ -1150,12 +1211,7 @@ export default function CarsAdminPage() {
<thead> <thead>
<tr className="border-b border-gray-200"> <tr className="border-b border-gray-200">
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600"> <th className="py-3 px-2 text-center text-sm font-medium text-gray-600">
<input Banner
type="checkbox"
checked={selectedLocalCars.size === localCars.length && localCars.length > 0}
onChange={handleSelectAllLocalCars}
className="w-4 h-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500"
/>
</th> </th>
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">Display</th> <th className="py-3 px-2 text-center text-sm font-medium text-gray-600">Display</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Image</th> <th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Image</th>
@@ -1167,36 +1223,48 @@ export default function CarsAdminPage() {
<th className="py-3 px-4 text-right text-sm font-medium text-gray-600">Final Price</th> <th className="py-3 px-4 text-right text-sm font-medium text-gray-600">Final Price</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Fuel</th> <th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Fuel</th>
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">PDF</th> <th className="py-3 px-2 text-center text-sm font-medium text-gray-600">PDF</th>
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">Status</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Actions</th> <th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{localCars.map((car) => { {localCars.map((car) => {
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 isBanner = car.is_banner || false;
return ( return (
<tr <tr
key={car.id} key={car.id}
className={`border-b border-gray-100 hover:bg-gray-50 cursor-pointer ${!car.is_displayed ? 'opacity-60' : ''}`} className={`border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-all ${
isSoldout ? 'bg-gray-100 opacity-50' : ''
} ${isBanner ? 'bg-purple-50' : ''}`}
onClick={() => handleCarClick(car)} onClick={() => handleCarClick(car)}
> >
{/* 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()}>
<input <button
type="checkbox" onClick={(e) => handleToggleBanner(car.id, e)}
checked={selectedLocalCars.has(car.id)} disabled={togglingBanner === car.id}
onChange={() => { className={`w-6 h-6 rounded border-2 flex items-center justify-center transition-all ${
setSelectedLocalCars(prev => { isBanner
const newSet = new Set(prev); ? 'bg-purple-600 border-purple-600 text-white'
if (newSet.has(car.id)) { : 'border-gray-300 hover:border-purple-400'
newSet.delete(car.id); } ${togglingBanner === car.id ? 'opacity-50' : ''}`}
} else { title={isBanner ? 'Remove from banner' : 'Add to banner'}
newSet.add(car.id); >
} {togglingBanner === car.id ? (
return newSet; <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>
className="w-4 h-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500" </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>
</td> </td>
{/* Display 토글 */}
<td className="py-3 px-2 text-center"> <td className="py-3 px-2 text-center">
<button <button
onClick={(e) => handleToggleDisplay(car, e)} onClick={(e) => handleToggleDisplay(car, e)}
@@ -1276,6 +1344,26 @@ export default function CarsAdminPage() {
</button> </button>
)} )}
</td> </td>
{/* Status (Soldout) */}
<td className="py-3 px-2 text-center" onClick={(e) => e.stopPropagation()}>
{isSoldout ? (
<button
onClick={(e) => handleToggleSoldout(car.id, true, e)}
className="px-2 py-1 text-xs rounded-full bg-gray-600 text-white hover:bg-gray-700"
title="Click to mark as available"
>
SOLD
</button>
) : (
<button
onClick={(e) => handleToggleSoldout(car.id, false, e)}
className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700 hover:bg-green-200"
title="Click to mark as sold out"
>
Available
</button>
)}
</td>
<td className="py-3 px-4"> <td className="py-3 px-4">
<button <button
onClick={(e) => { onClick={(e) => {

View File

@@ -8,7 +8,6 @@ import { useAuthStore } from '@/lib/store';
const menuItems = [ const menuItems = [
{ href: '/admin', label: 'Dashboard', icon: '📊' }, { href: '/admin', label: 'Dashboard', icon: '📊' },
{ href: '/admin/visitor-stats', label: 'Visitor Stats', icon: '👁️' }, { href: '/admin/visitor-stats', label: 'Visitor Stats', icon: '👁️' },
{ href: '/admin/hero-banners', label: 'Hero Banners', icon: '🖼️' },
{ href: '/admin/cars', label: 'Cars', icon: '🚗' }, { href: '/admin/cars', label: 'Cars', icon: '🚗' },
{ href: '/admin/vehicle-requests', label: 'Vehicle Requests', icon: '📋' }, { href: '/admin/vehicle-requests', label: 'Vehicle Requests', icon: '📋' },
{ href: '/admin/purchased', label: 'Purchased Vehicles', icon: '📦' }, { href: '/admin/purchased', label: 'Purchased Vehicles', icon: '📦' },

View File

@@ -358,17 +358,6 @@ export default function AdminDashboard() {
<div className="bg-white rounded-xl shadow-sm p-5"> <div className="bg-white rounded-xl shadow-sm p-5">
<h3 className="font-semibold text-gray-800 mb-4">Quick Actions</h3> <h3 className="font-semibold text-gray-800 mb-4">Quick Actions</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Link
href="/admin/hero-banners"
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-colors"
>
<span className="text-2xl">🖼</span>
<div>
<p className="font-medium text-gray-800">Hero Banners</p>
<p className="text-xs text-gray-500">Manage slider</p>
</div>
</Link>
<Link <Link
href="/admin/cars" href="/admin/cars"
className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-colors" className="flex items-center gap-3 p-4 border border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-colors"

View File

@@ -70,6 +70,22 @@ export const carsApi = {
const { data } = await api.get('/cars/models/', { params }); const { data } = await api.get('/cars/models/', { params });
return data; return data;
}, },
// Soldout APIs
markSoldout: async (carId: number): Promise<{ car_id: number; soldout: boolean; message: string }> => {
const { data } = await api.post(`/cars/${carId}/soldout`);
return data;
},
markAvailable: async (carId: number): Promise<{ car_id: number; soldout: boolean; message: string }> => {
const { data } = await api.delete(`/cars/${carId}/soldout`);
return data;
},
getSoldoutStats: async (): Promise<{ total_active: number; soldout: number; available: number; soldout_percentage: number }> => {
const { data } = await api.get('/cars/admin/soldout-stats');
return data;
},
}; };
// Auth API // Auth API
@@ -187,6 +203,22 @@ export const heroBannersApi = {
const { data } = await api.put('/hero-banners/admin/settings', settings); const { data } = await api.put('/hero-banners/admin/settings', settings);
return data; return data;
}, },
// Banner Toggle & Ordering
adminToggleBanner: async (carId: number): Promise<{ car_id: number; is_banner: boolean; banner_id?: number; message: string }> => {
const { data } = await api.post(`/hero-banners/admin/toggle/${carId}`);
return data;
},
adminReorderBanners: async (carIds: number[]): Promise<{ message: string; count: number }> => {
const { data } = await api.put('/hero-banners/admin/reorder', carIds);
return data;
},
adminGetBannerCars: async (): Promise<{ car_ids: number[]; count: number }> => {
const { data } = await api.get('/hero-banners/admin/banner-cars');
return data;
},
}; };
// Translations API // Translations API