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
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__':

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

View File

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

View File

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

View File

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

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 { 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<Set<number>>(new Set());
const [registeringLocalBanner, setRegisteringLocalBanner] = useState(false);
const [togglingBanner, setTogglingBanner] = useState<number | null>(null);
const [bannerCarIds, setBannerCarIds] = useState<number[]>([]); // 배너 순서대로 정렬된 차량 ID
// All Cars (public view) state
const [allCars, setAllCars] = useState<LocalCar[]>([]);
@@ -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() {
<thead>
<tr className="border-b border-gray-200">
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">
<input
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"
/>
Banner
</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>
@@ -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-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">Status</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody>
{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 (
<tr
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)}
>
{/* Banner 체크박스 */}
<td className="py-3 px-2 text-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedLocalCars.has(car.id)}
onChange={() => {
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"
/>
<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>
</td>
{/* Display 토글 */}
<td className="py-3 px-2 text-center">
<button
onClick={(e) => handleToggleDisplay(car, e)}
@@ -1276,6 +1344,26 @@ export default function CarsAdminPage() {
</button>
)}
</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">
<button
onClick={(e) => {

View File

@@ -8,7 +8,6 @@ import { useAuthStore } from '@/lib/store';
const menuItems = [
{ href: '/admin', label: 'Dashboard', 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/vehicle-requests', label: 'Vehicle Requests', 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">
<h3 className="font-semibold text-gray-800 mb-4">Quick Actions</h3>
<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
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"

View File

@@ -70,6 +70,22 @@ export const carsApi = {
const { data } = await api.get('/cars/models/', { params });
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
@@ -187,6 +203,22 @@ export const heroBannersApi = {
const { data } = await api.put('/hero-banners/admin/settings', settings);
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