diff --git a/CLAUDE.md b/CLAUDE.md index acd18b8..d7d33f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -660,6 +660,11 @@ Import 시 자동 번역 (Azure API) | 날짜 | 변경 내용 | |------|----------| +| 2024-12-31 | **Admin Settings 기능 추가**: Show Dealer Comment 토글, Korean Domestic + Export Customs 금액 설정 | +| 2024-12-31 | Cost 페이지에서 국내비용+수출통관비용 동적 적용 (settings API 연동) | +| 2024-12-31 | **Visitor Stats 국가별 통계 강화**: 국기 이모지 추가, 전용 Country Stats 카드 추가 | +| 2024-12-31 | Hero Banners API 라우트 순서 수정 (422 에러 해결) | +| 2024-12-31 | Banner Toggle 로직 수정 (HeroBanner 테이블 기준으로 변경) | | 2024-12-27 | **딜러 설명 번역 시스템 추가**: Azure Translator API 연동, 한국어→영어/몽골어/러시아어 직접 번역 | | 2024-12-27 | 관리자 번역 관리 페이지 추가 (`/admin/dealer-translations`) | | 2024-12-27 | DB 스키마 확장: `dealer_description_en/mn/ru` 컬럼 추가 | diff --git a/backend/app/api/hero_banners.py b/backend/app/api/hero_banners.py index a28fc52..4dbf8c7 100644 --- a/backend/app/api/hero_banners.py +++ b/backend/app/api/hero_banners.py @@ -143,6 +143,46 @@ def get_banner_cars( } +@router.put("/admin/reorder") +def reorder_banners( + request: BannerReorderRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """배너 순서 재정렬 (Admin) + + car_ids: 배너 차량 ID 목록 (원하는 순서대로) + """ + for order, car_id in enumerate(request.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(request.car_ids)} + + +@router.put("/admin/settings", response_model=HeroBannerSettingsResponse) +def update_banner_settings( + settings_data: HeroBannerSettingsUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user) +): + """배너 슬라이더 설정 수정 (Admin)""" + settings_obj = db.query(HeroBannerSettings).first() + if not settings_obj: + settings_obj = HeroBannerSettings() + db.add(settings_obj) + + update_data = settings_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(settings_obj, field, value) + + db.commit() + db.refresh(settings_obj) + return settings_obj + + @router.get("/admin/{banner_id}", response_model=HeroBannerResponse) def admin_get_banner( banner_id: int, @@ -218,27 +258,6 @@ def delete_banner( return {"message": "Banner deleted successfully"} -@router.put("/admin/settings", response_model=HeroBannerSettingsResponse) -def update_banner_settings( - settings_data: HeroBannerSettingsUpdate, - db: Session = Depends(get_db), - current_user: User = Depends(get_admin_user) -): - """배너 슬라이더 설정 수정 (Admin)""" - settings_obj = db.query(HeroBannerSettings).first() - if not settings_obj: - settings_obj = HeroBannerSettings() - db.add(settings_obj) - - update_data = settings_data.model_dump(exclude_unset=True) - for field, value in update_data.items(): - setattr(settings_obj, field, value) - - db.commit() - db.refresh(settings_obj) - return settings_obj - - # ==================== Image Upload ==================== @router.post("/admin/upload-image") @@ -297,18 +316,19 @@ def toggle_banner( ): """차량의 배너 상태 토글 (Admin) - - is_banner=False → True: HeroBanner 생성 - - is_banner=True → False: HeroBanner 삭제 + - HeroBanner 존재 → 삭제 + - 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: + # HeroBanner 테이블을 기준으로 판단 (car.is_banner 필드 대신) + existing_banner = db.query(HeroBanner).filter(HeroBanner.car_id == car_id).first() + + if existing_banner: # 배너에서 제거 - banner = db.query(HeroBanner).filter(HeroBanner.car_id == car_id).first() - if banner: - db.delete(banner) + db.delete(existing_banner) car.is_banner = False db.commit() return {"car_id": car_id, "is_banner": False, "message": "Removed from banner"} @@ -341,22 +361,3 @@ def toggle_banner( 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( - request: BannerReorderRequest, - db: Session = Depends(get_db), - current_user: User = Depends(get_admin_user) -): - """배너 순서 재정렬 (Admin) - - car_ids: 배너 차량 ID 목록 (원하는 순서대로) - """ - for order, car_id in enumerate(request.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(request.car_ids)} diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index f39681d..4d6186a 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -29,6 +29,12 @@ class SystemSettings(Base): container_logistics_usd = Column(Integer, default=3600) # 컨테이너 물류비 $3,600 shoring_cost_usd = Column(Integer, default=300) # 쇼링비 (컨테이너 고정) $300 + # 국내비용 + 수출통관비용 (KRW) + domestic_export_customs_krw = Column(Integer, default=1150000) # ₩1,150,000 + + # 딜러 코멘트 표시 설정 + show_dealer_comment = Column(Boolean, default=True) # 딜러 코멘트 표시 여부 + # 레퍼럴 보상 설정 referral_reward_enabled = Column(Boolean, default=True) # 레퍼럴 보상 활성화 referral_reward_percent = Column(Float, default=10.0) # 보상 비율 (기본 10%) diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index d08a75b..93d3267 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -15,6 +15,11 @@ class SystemSettingsUpdate(BaseModel): cache_ttl_hours: Optional[int] = None container_logistics_usd: Optional[int] = None shoring_cost_usd: Optional[int] = None + domestic_export_customs_krw: Optional[int] = None + show_dealer_comment: Optional[bool] = None + referral_reward_enabled: Optional[bool] = None + referral_reward_percent: Optional[float] = None + referral_reward_type: Optional[str] = None class SystemSettingsResponse(BaseModel): @@ -30,6 +35,11 @@ class SystemSettingsResponse(BaseModel): cache_ttl_hours: int container_logistics_usd: int shoring_cost_usd: int + domestic_export_customs_krw: int = 1150000 + show_dealer_comment: bool = True + referral_reward_enabled: bool = True + referral_reward_percent: float = 10.0 + referral_reward_type: str = "one_time" created_at: Optional[datetime] = None updated_at: Optional[datetime] = None diff --git a/frontend/src/app/admin/settings/page.tsx b/frontend/src/app/admin/settings/page.tsx index a7cde09..98085ed 100644 --- a/frontend/src/app/admin/settings/page.tsx +++ b/frontend/src/app/admin/settings/page.tsx @@ -14,6 +14,8 @@ interface SystemSettings { cache_ttl_hours: number; container_logistics_usd: number; shoring_cost_usd: number; + domestic_export_customs_krw: number; + show_dealer_comment: boolean; referral_reward_enabled: boolean; referral_reward_percent: number; referral_reward_type: string; @@ -57,6 +59,8 @@ export default function SettingsPage() { cache_ttl_hours: 2, container_logistics_usd: 3600, shoring_cost_usd: 300, + domestic_export_customs_krw: 1150000, + show_dealer_comment: true, referral_reward_enabled: true, referral_reward_percent: 10.0, referral_reward_type: 'one_time', @@ -84,6 +88,8 @@ export default function SettingsPage() { cache_ttl_hours: data.cache_ttl_hours, container_logistics_usd: data.container_logistics_usd || 3600, shoring_cost_usd: data.shoring_cost_usd || 300, + domestic_export_customs_krw: data.domestic_export_customs_krw || 1150000, + show_dealer_comment: data.show_dealer_comment ?? true, referral_reward_enabled: data.referral_reward_enabled ?? true, referral_reward_percent: data.referral_reward_percent ?? 10.0, referral_reward_type: data.referral_reward_type || 'one_time', @@ -234,6 +240,31 @@ export default function SettingsPage() { + {/* Display Settings */} +
+

+ Display Settings +

+ +
+
+ +
+ Show Dealer Comment +

차량 상세 페이지에서 딜러 코멘트 표시 여부

+
+
+
+
+ {/* Margin Settings */}

@@ -388,6 +419,22 @@ export default function SettingsPage() { />

쇼링비 - 컨테이너 고정 비용 (기본값: $300)

+ +
+ + handleChange('domestic_export_customs_krw', parseInt(e.target.value) || 1150000)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + /> +

국내비용 + 수출통관비용 (기본값: ₩1,150,000)

+
@@ -396,6 +443,7 @@ export default function SettingsPage() {

Total Container Cost: ${formData.container_logistics_usd + formData.shoring_cost_usd}

Small Car (5.5/10): ${((formData.container_logistics_usd + formData.shoring_cost_usd) * 0.275).toFixed(0)} per car

Compact Car (4.5/10): ${((formData.container_logistics_usd + formData.shoring_cost_usd) * 0.225).toFixed(0)} per car

+

Korean Domestic + Export Customs: ₩{formData.domestic_export_customs_krw.toLocaleString()}

diff --git a/frontend/src/app/admin/visitor-stats/page.tsx b/frontend/src/app/admin/visitor-stats/page.tsx index 4c8f420..83b083b 100644 --- a/frontend/src/app/admin/visitor-stats/page.tsx +++ b/frontend/src/app/admin/visitor-stats/page.tsx @@ -3,22 +3,39 @@ import { useState, useEffect } from 'react'; import { visitorApi, VisitorStatsOverview, TopPage, TopReferrer, ChartData, RealtimeStats } from '@/lib/api'; -// Country code to name mapping -const countryNames: Record = { - MN: 'Mongolia', - RU: 'Russia', - KR: 'Korea', - US: 'United States', - CN: 'China', - JP: 'Japan', - DE: 'Germany', - FR: 'France', - GB: 'United Kingdom', - AU: 'Australia', - unknown: 'Unknown', - LO: 'Local', +// Country code to name and flag mapping +const countryInfo: Record = { + MN: { name: 'Mongolia', flag: '🇲🇳' }, + RU: { name: 'Russia', flag: '🇷🇺' }, + KR: { name: 'Korea', flag: '🇰🇷' }, + US: { name: 'United States', flag: '🇺🇸' }, + CN: { name: 'China', flag: '🇨🇳' }, + JP: { name: 'Japan', flag: '🇯🇵' }, + DE: { name: 'Germany', flag: '🇩🇪' }, + FR: { name: 'France', flag: '🇫🇷' }, + GB: { name: 'United Kingdom', flag: '🇬🇧' }, + AU: { name: 'Australia', flag: '🇦🇺' }, + CA: { name: 'Canada', flag: '🇨🇦' }, + IN: { name: 'India', flag: '🇮🇳' }, + SG: { name: 'Singapore', flag: '🇸🇬' }, + HK: { name: 'Hong Kong', flag: '🇭🇰' }, + TW: { name: 'Taiwan', flag: '🇹🇼' }, + VN: { name: 'Vietnam', flag: '🇻🇳' }, + TH: { name: 'Thailand', flag: '🇹🇭' }, + MY: { name: 'Malaysia', flag: '🇲🇾' }, + ID: { name: 'Indonesia', flag: '🇮🇩' }, + PH: { name: 'Philippines', flag: '🇵🇭' }, + KZ: { name: 'Kazakhstan', flag: '🇰🇿' }, + UZ: { name: 'Uzbekistan', flag: '🇺🇿' }, + UA: { name: 'Ukraine', flag: '🇺🇦' }, + unknown: { name: 'Unknown', flag: '🌐' }, + LO: { name: 'Local', flag: '🏠' }, }; +const countryNames: Record = Object.fromEntries( + Object.entries(countryInfo).map(([code, info]) => [code, info.name]) +); + // Simple bar chart component const SimpleBarChart = ({ data, color = 'bg-primary-500', height = 120 }: { data: ChartData | null; @@ -104,6 +121,61 @@ const BreakdownCard = ({ title, data, icon, nameMap }: { ); }; +// Country Stats Card with flags +const CountryStatsCard = ({ data }: { data: Record }) => { + const total = Object.values(data).reduce((a, b) => a + b, 0); + const sortedEntries = Object.entries(data).sort((a, b) => b[1] - a[1]).slice(0, 10); + + if (total === 0) { + return ( +
+
+ 🌍 +

Visitors by Country

+
+
No country data available
+
+ ); + } + + return ( +
+
+
+ 🌍 +

Visitors by Country

+
+ Total: {total.toLocaleString()} +
+
+ {sortedEntries.map(([code, value], index) => { + const info = countryInfo[code] || { name: code, flag: '🌐' }; + const percentage = ((value / total) * 100).toFixed(1); + + return ( +
+ {info.flag} +
+
+ {info.name} + {value.toLocaleString()} +
+
+
+
+ {percentage}% of total +
+
+ ); + })} +
+
+ ); +}; + export default function VisitorStatsPage() { const [overview, setOverview] = useState(null); const [visitsChart, setVisitsChart] = useState(null); @@ -315,8 +387,11 @@ export default function VisitorStatsPage() {
- {/* Breakdowns */} -
+ {/* Country Stats - Full Width */} + + + {/* Device & Browser Breakdowns */} +
-
{/* Tables */} diff --git a/frontend/src/app/cars/[id]/page.tsx b/frontend/src/app/cars/[id]/page.tsx index 13debf6..1b67734 100644 --- a/frontend/src/app/cars/[id]/page.tsx +++ b/frontend/src/app/cars/[id]/page.tsx @@ -51,10 +51,26 @@ export default function CarDetailPage() { // Collapsible sections const [showDealerComment, setShowDealerComment] = useState(false); + // System settings + const [showDealerCommentSetting, setShowDealerCommentSetting] = useState(true); + useEffect(() => { if (params.id) { loadCar(Number(params.id)); } + // Fetch system settings for dealer comment visibility + const fetchSettings = async () => { + try { + const response = await fetch(`${API_BASE_URL}/api/settings/`); + if (response.ok) { + const data = await response.json(); + setShowDealerCommentSetting(data.show_dealer_comment ?? true); + } + } catch (error) { + console.error('Failed to fetch settings:', error); + } + }; + fetchSettings(); }, [params.id]); useEffect(() => { @@ -458,7 +474,7 @@ export default function CarDetailPage() { )} {/* Dealer's Comment - Left Column (Collapsible) - Requires 0.1 CC */} - {hasPerformanceCheckAccess && car.dealer_description && ( + {showDealerCommentSetting && hasPerformanceCheckAccess && car.dealer_description && (