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

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