diff --git a/backend/app/api/bulletin.py b/backend/app/api/bulletin.py new file mode 100644 index 0000000..12dc220 --- /dev/null +++ b/backend/app/api/bulletin.py @@ -0,0 +1,524 @@ +""" +Bulletin Board API - 게시판 API +""" +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session +from sqlalchemy import func, desc, or_ +from typing import Optional +import math + +from ..database import get_db +from ..models.bulletin import BoardCategory, BoardPost +from ..models.user import User +from ..schemas.bulletin import ( + BoardCategoryCreate, BoardCategoryUpdate, BoardCategoryResponse, BoardCategoryListResponse, + BoardPostCreate, BoardPostUpdate, BoardPostResponse, BoardPostListItem, BoardPostListResponse +) +from .auth import get_current_user, get_current_user_optional + +router = APIRouter(prefix="/board", tags=["board"]) + + +# ============ Category Endpoints ============ + +@router.get("/categories", response_model=BoardCategoryListResponse) +def get_categories( + include_inactive: bool = False, + db: Session = Depends(get_db) +): + """카테고리 목록 조회 (공개)""" + query = db.query(BoardCategory) + + if not include_inactive: + query = query.filter(BoardCategory.is_active == True) + + categories = query.order_by(BoardCategory.sort_order, BoardCategory.id).all() + + # 각 카테고리별 게시물 수 계산 + result = [] + for cat in categories: + post_count = db.query(func.count(BoardPost.id)).filter( + BoardPost.category_id == cat.id, + BoardPost.is_published == True + ).scalar() + + cat_dict = { + "id": cat.id, + "name": cat.name, + "name_en": cat.name_en, + "name_mn": cat.name_mn, + "name_ru": cat.name_ru, + "slug": cat.slug, + "description": cat.description, + "sort_order": cat.sort_order, + "is_active": cat.is_active, + "created_at": cat.created_at, + "updated_at": cat.updated_at, + "post_count": post_count + } + result.append(cat_dict) + + return BoardCategoryListResponse(categories=result, total=len(result)) + + +@router.post("/categories", response_model=BoardCategoryResponse) +def create_category( + category: BoardCategoryCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """카테고리 생성 (관리자만)""" + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only administrators can create categories" + ) + + # slug 중복 체크 + existing = db.query(BoardCategory).filter(BoardCategory.slug == category.slug).first() + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Category slug already exists" + ) + + db_category = BoardCategory(**category.model_dump()) + db.add(db_category) + db.commit() + db.refresh(db_category) + + return {**db_category.__dict__, "post_count": 0} + + +@router.put("/categories/{category_id}", response_model=BoardCategoryResponse) +def update_category( + category_id: int, + category_update: BoardCategoryUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """카테고리 수정 (관리자만)""" + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only administrators can update categories" + ) + + db_category = db.query(BoardCategory).filter(BoardCategory.id == category_id).first() + if not db_category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Category not found" + ) + + # slug 중복 체크 (다른 카테고리) + if category_update.slug: + existing = db.query(BoardCategory).filter( + BoardCategory.slug == category_update.slug, + BoardCategory.id != category_id + ).first() + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Category slug already exists" + ) + + update_data = category_update.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(db_category, key, value) + + db.commit() + db.refresh(db_category) + + post_count = db.query(func.count(BoardPost.id)).filter( + BoardPost.category_id == category_id, + BoardPost.is_published == True + ).scalar() + + return {**db_category.__dict__, "post_count": post_count} + + +@router.delete("/categories/{category_id}") +def delete_category( + category_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """카테고리 삭제 (관리자만)""" + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only administrators can delete categories" + ) + + db_category = db.query(BoardCategory).filter(BoardCategory.id == category_id).first() + if not db_category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Category not found" + ) + + # 해당 카테고리에 게시물이 있는지 확인 + post_count = db.query(func.count(BoardPost.id)).filter( + BoardPost.category_id == category_id + ).scalar() + + if post_count > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Cannot delete category with {post_count} posts. Please delete or move posts first." + ) + + db.delete(db_category) + db.commit() + + return {"message": "Category deleted successfully"} + + +# ============ Post Endpoints ============ + +@router.get("/posts", response_model=BoardPostListResponse) +def get_posts( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + category_id: Optional[int] = None, + search: Optional[str] = None, + db: Session = Depends(get_db) +): + """게시물 목록 조회 (공개)""" + # 공지사항 (항상 상단에) + notices_query = db.query(BoardPost).filter( + BoardPost.is_published == True, + BoardPost.is_notice == True + ).order_by(desc(BoardPost.is_pinned), desc(BoardPost.created_at)) + + if category_id: + notices_query = notices_query.filter(BoardPost.category_id == category_id) + + notices = notices_query.all() + + # 일반 게시물 + query = db.query(BoardPost).filter( + BoardPost.is_published == True, + BoardPost.is_notice == False + ) + + if category_id: + query = query.filter(BoardPost.category_id == category_id) + + if search: + query = query.filter( + or_( + BoardPost.title.ilike(f"%{search}%"), + BoardPost.content.ilike(f"%{search}%") + ) + ) + + # Total count + total = query.count() + total_pages = math.ceil(total / page_size) if total > 0 else 1 + + # Pagination + posts = query.order_by( + desc(BoardPost.is_pinned), + desc(BoardPost.created_at) + ).offset((page - 1) * page_size).limit(page_size).all() + + # Format response + def format_post(post: BoardPost) -> dict: + return { + "id": post.id, + "title": post.title, + "category_id": post.category_id, + "category_name": post.category.name if post.category else None, + "author_id": post.author_id, + "author_name": post.author.name if post.author else None, + "is_notice": post.is_notice, + "is_pinned": post.is_pinned, + "view_count": post.view_count, + "created_at": post.created_at + } + + return BoardPostListResponse( + posts=[format_post(p) for p in posts], + notices=[format_post(n) for n in notices], + total=total, + page=page, + page_size=page_size, + total_pages=total_pages + ) + + +@router.get("/posts/{post_id}", response_model=BoardPostResponse) +def get_post( + post_id: int, + db: Session = Depends(get_db) +): + """게시물 상세 조회 (공개)""" + post = db.query(BoardPost).filter( + BoardPost.id == post_id, + BoardPost.is_published == True + ).first() + + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Post not found" + ) + + # 조회수 증가 + post.view_count += 1 + db.commit() + db.refresh(post) + + return post + + +@router.post("/posts", response_model=BoardPostResponse) +def create_post( + post_data: BoardPostCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """게시물 작성 (회원만)""" + # 카테고리 존재 확인 + category = db.query(BoardCategory).filter( + BoardCategory.id == post_data.category_id, + BoardCategory.is_active == True + ).first() + + if not category: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid category" + ) + + # 공지사항은 관리자만 작성 가능 + is_notice = post_data.is_notice + if is_notice and not current_user.is_admin: + is_notice = False # 관리자가 아니면 공지사항 불가 + + db_post = BoardPost( + title=post_data.title, + content=post_data.content, + category_id=post_data.category_id, + author_id=current_user.id, + is_notice=is_notice, + is_pinned=is_notice # 공지사항은 자동으로 고정 + ) + + db.add(db_post) + db.commit() + db.refresh(db_post) + + return db_post + + +@router.put("/posts/{post_id}", response_model=BoardPostResponse) +def update_post( + post_id: int, + post_update: BoardPostUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """게시물 수정 (작성자 또는 관리자)""" + db_post = db.query(BoardPost).filter(BoardPost.id == post_id).first() + + if not db_post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Post not found" + ) + + # 권한 체크: 작성자 또는 관리자만 + if db_post.author_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to edit this post" + ) + + # 카테고리 변경 시 존재 확인 + if post_update.category_id: + category = db.query(BoardCategory).filter( + BoardCategory.id == post_update.category_id, + BoardCategory.is_active == True + ).first() + if not category: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid category" + ) + + update_data = post_update.model_dump(exclude_unset=True) + + # 공지사항/고정은 관리자만 변경 가능 + if not current_user.is_admin: + update_data.pop("is_notice", None) + update_data.pop("is_pinned", None) + update_data.pop("is_published", None) + + for key, value in update_data.items(): + setattr(db_post, key, value) + + db.commit() + db.refresh(db_post) + + return db_post + + +@router.delete("/posts/{post_id}") +def delete_post( + post_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """게시물 삭제 (작성자 또는 관리자)""" + db_post = db.query(BoardPost).filter(BoardPost.id == post_id).first() + + if not db_post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Post not found" + ) + + # 권한 체크: 작성자 또는 관리자만 + if db_post.author_id != current_user.id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to delete this post" + ) + + db.delete(db_post) + db.commit() + + return {"message": "Post deleted successfully"} + + +# ============ Admin Endpoints ============ + +@router.get("/admin/posts", response_model=BoardPostListResponse) +def admin_get_posts( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + category_id: Optional[int] = None, + is_notice: Optional[bool] = None, + is_published: Optional[bool] = None, + search: Optional[str] = None, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """관리자용 게시물 목록 (모든 게시물 포함)""" + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required" + ) + + query = db.query(BoardPost) + + if category_id: + query = query.filter(BoardPost.category_id == category_id) + + if is_notice is not None: + query = query.filter(BoardPost.is_notice == is_notice) + + if is_published is not None: + query = query.filter(BoardPost.is_published == is_published) + + if search: + query = query.filter( + or_( + BoardPost.title.ilike(f"%{search}%"), + BoardPost.content.ilike(f"%{search}%") + ) + ) + + total = query.count() + total_pages = math.ceil(total / page_size) if total > 0 else 1 + + posts = query.order_by( + desc(BoardPost.is_notice), + desc(BoardPost.is_pinned), + desc(BoardPost.created_at) + ).offset((page - 1) * page_size).limit(page_size).all() + + def format_post(post: BoardPost) -> dict: + return { + "id": post.id, + "title": post.title, + "category_id": post.category_id, + "category_name": post.category.name if post.category else None, + "author_id": post.author_id, + "author_name": post.author.name if post.author else None, + "is_notice": post.is_notice, + "is_pinned": post.is_pinned, + "view_count": post.view_count, + "created_at": post.created_at + } + + # 공지사항 분리 + notices = [p for p in posts if p.is_notice] + regular = [p for p in posts if not p.is_notice] + + return BoardPostListResponse( + posts=[format_post(p) for p in regular], + notices=[format_post(n) for n in notices], + total=total, + page=page, + page_size=page_size, + total_pages=total_pages + ) + + +@router.put("/admin/posts/{post_id}/toggle-notice") +def toggle_notice( + post_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """공지사항 토글 (관리자만)""" + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required" + ) + + db_post = db.query(BoardPost).filter(BoardPost.id == post_id).first() + if not db_post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Post not found" + ) + + db_post.is_notice = not db_post.is_notice + db_post.is_pinned = db_post.is_notice # 공지사항이면 자동 고정 + db.commit() + db.refresh(db_post) + + return {"is_notice": db_post.is_notice, "is_pinned": db_post.is_pinned} + + +@router.put("/admin/posts/{post_id}/toggle-pin") +def toggle_pin( + post_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """상단 고정 토글 (관리자만)""" + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required" + ) + + db_post = db.query(BoardPost).filter(BoardPost.id == post_id).first() + if not db_post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Post not found" + ) + + db_post.is_pinned = not db_post.is_pinned + db.commit() + + return {"is_pinned": db_post.is_pinned} diff --git a/backend/app/main.py b/backend/app/main.py index 574beeb..159b5ab 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,7 @@ import asyncio from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from .database import engine, Base, SessionLocal -from .api import cars, auth, inquiries, hero_banners, carmodoo, translations, cc, settings, vehicle_requests, dealer, vehicle_share, withdrawal, referral, notification, dashboard, push, exchange_rate, verification, visitor, sns_share +from .api import cars, auth, inquiries, hero_banners, carmodoo, translations, cc, settings, vehicle_requests, dealer, vehicle_share, withdrawal, referral, notification, dashboard, push, exchange_rate, verification, visitor, sns_share, bulletin from .config import get_settings from .services.exchange_rate_service import update_exchange_rates from .services.visitor_service import aggregate_daily_stats, cleanup_old_visitor_logs @@ -238,6 +238,7 @@ app.include_router(exchange_rate.router) app.include_router(verification.router, prefix="/api") app.include_router(visitor.router, prefix="/api") app.include_router(sns_share.router, prefix="/api") +app.include_router(bulletin.router, prefix="/api") @app.get("/") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2ee2c8d..61ef2b8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -18,6 +18,7 @@ from .car_specification import CarSpecification from .exchange_rate import ExchangeRate, ExchangeRateHistory from .cc_package import CCPackage, DEFAULT_CC_PACKAGES from .visitor import VisitorLog, VisitorDailyStats, VisitorSession +from .bulletin import BoardCategory, BoardPost __all__ = [ "CarMaker", @@ -63,4 +64,6 @@ __all__ = [ "VisitorLog", "VisitorDailyStats", "VisitorSession", + "BoardCategory", + "BoardPost", ] diff --git a/backend/app/models/bulletin.py b/backend/app/models/bulletin.py new file mode 100644 index 0000000..c345196 --- /dev/null +++ b/backend/app/models/bulletin.py @@ -0,0 +1,59 @@ +""" +Bulletin Board Models - 게시판 모델 +""" +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from ..database import Base + + +class BoardCategory(Base): + """게시판 카테고리""" + __tablename__ = "board_categories" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) # 카테고리명 (한국어) + name_en = Column(String(100), nullable=True) # 영어 + name_mn = Column(String(100), nullable=True) # 몽골어 + name_ru = Column(String(100), nullable=True) # 러시아어 + slug = Column(String(50), unique=True, nullable=False) # URL용 슬러그 + description = Column(String(255), nullable=True) + sort_order = Column(Integer, default=0) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + posts = relationship("BoardPost", back_populates="category") + + +class BoardPost(Base): + """게시판 글""" + __tablename__ = "board_posts" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(255), nullable=False) + content = Column(Text, nullable=False) + + # Category + category_id = Column(Integer, ForeignKey("board_categories.id"), nullable=False) + + # Author + author_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + # Status + is_notice = Column(Boolean, default=False) # 공지사항 여부 (관리자만 설정 가능) + is_pinned = Column(Boolean, default=False) # 상단 고정 여부 + is_published = Column(Boolean, default=True) # 게시 여부 + + # Stats + view_count = Column(Integer, default=0) + + # Timestamps + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + category = relationship("BoardCategory", back_populates="posts") + author = relationship("User", foreign_keys=[author_id]) diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index 51f0bb2..882f3c1 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -63,6 +63,9 @@ class SystemSettings(Base): withdrawal_enabled = Column(Boolean, default=True) # 출금 기능 활성화 min_withdrawal_usd = Column(Float, default=10.0) # 최소 출금 금액 (USD) + # 게시판 설정 + board_enabled = Column(Boolean, default=True) # 게시판 상단 메뉴 표시 여부 + # 차량 판매상태 검증 설정 car_availability_check_enabled = Column(Boolean, default=True) # 자동 검증 활성화 car_availability_check_hour = Column(Integer, default=6) # 검증 시간 (0-23, 기본 새벽 6시) diff --git a/backend/app/schemas/bulletin.py b/backend/app/schemas/bulletin.py new file mode 100644 index 0000000..e0f80ec --- /dev/null +++ b/backend/app/schemas/bulletin.py @@ -0,0 +1,125 @@ +""" +Bulletin Board Schemas - 게시판 스키마 +""" +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel + + +# ============ Category Schemas ============ + +class BoardCategoryBase(BaseModel): + name: str + name_en: Optional[str] = None + name_mn: Optional[str] = None + name_ru: Optional[str] = None + slug: str + description: Optional[str] = None + sort_order: int = 0 + is_active: bool = True + + +class BoardCategoryCreate(BoardCategoryBase): + pass + + +class BoardCategoryUpdate(BaseModel): + name: Optional[str] = None + name_en: Optional[str] = None + name_mn: Optional[str] = None + name_ru: Optional[str] = None + slug: Optional[str] = None + description: Optional[str] = None + sort_order: Optional[int] = None + is_active: Optional[bool] = None + + +class BoardCategoryResponse(BoardCategoryBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + post_count: int = 0 + + class Config: + from_attributes = True + + +# ============ Post Schemas ============ + +class BoardPostBase(BaseModel): + title: str + content: str + category_id: int + + +class BoardPostCreate(BoardPostBase): + is_notice: bool = False # 관리자만 true 가능 + + +class BoardPostUpdate(BaseModel): + title: Optional[str] = None + content: Optional[str] = None + category_id: Optional[int] = None + is_notice: Optional[bool] = None + is_pinned: Optional[bool] = None + is_published: Optional[bool] = None + + +class AuthorResponse(BaseModel): + id: int + name: Optional[str] = None + email: str + is_admin: bool = False + + class Config: + from_attributes = True + + +class BoardPostResponse(BaseModel): + id: int + title: str + content: str + category_id: int + category: Optional[BoardCategoryResponse] = None + author_id: int + author: Optional[AuthorResponse] = None + is_notice: bool + is_pinned: bool + is_published: bool + view_count: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class BoardPostListItem(BaseModel): + """목록에서 보여줄 간략한 정보""" + id: int + title: str + category_id: int + category_name: Optional[str] = None + author_id: int + author_name: Optional[str] = None + is_notice: bool + is_pinned: bool + view_count: int + created_at: datetime + + class Config: + from_attributes = True + + +class BoardPostListResponse(BaseModel): + posts: List[BoardPostListItem] + notices: List[BoardPostListItem] # 공지사항 (상단 고정) + total: int + page: int + page_size: int + total_pages: int + + +class BoardCategoryListResponse(BaseModel): + categories: List[BoardCategoryResponse] + total: int diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 1ba42f9..fe0c24a 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -32,6 +32,9 @@ class SystemSettingsUpdate(BaseModel): withdrawal_enabled: Optional[bool] = None min_withdrawal_usd: Optional[float] = None + # 게시판 설정 + board_enabled: Optional[bool] = None + # 차량 판매상태 검증 설정 car_availability_check_enabled: Optional[bool] = None car_availability_check_hour: Optional[int] = None @@ -67,6 +70,9 @@ class SystemSettingsResponse(BaseModel): withdrawal_enabled: bool = True min_withdrawal_usd: float = 10.0 + # 게시판 설정 + board_enabled: bool = True + # 차량 판매상태 검증 설정 car_availability_check_enabled: bool = True car_availability_check_hour: int = 6 diff --git a/frontend/src/app/admin/board/categories/page.tsx b/frontend/src/app/admin/board/categories/page.tsx new file mode 100644 index 0000000..9db6b87 --- /dev/null +++ b/frontend/src/app/admin/board/categories/page.tsx @@ -0,0 +1,403 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { boardApi, BoardCategory } from '@/lib/api'; +import { useAuthStore } from '@/lib/store'; + +interface CategoryFormData { + name: string; + name_en: string; + name_mn: string; + name_ru: string; + slug: string; + description: string; + sort_order: number; + is_active: boolean; +} + +const initialFormData: CategoryFormData = { + name: '', + name_en: '', + name_mn: '', + name_ru: '', + slug: '', + description: '', + sort_order: 0, + is_active: true, +}; + +export default function AdminBoardCategoriesPage() { + const router = useRouter(); + const { user } = useAuthStore(); + + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [showModal, setShowModal] = useState(false); + const [editingCategory, setEditingCategory] = useState(null); + const [formData, setFormData] = useState(initialFormData); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!user?.is_admin) { + router.push('/admin'); + return; + } + fetchCategories(); + }, [user]); + + const fetchCategories = async () => { + try { + const res = await boardApi.getCategories(true); + setCategories(res.categories); + } catch (err) { + console.error('Failed to fetch categories:', err); + } finally { + setLoading(false); + } + }; + + const generateSlug = (name: string) => { + return name + .toLowerCase() + .replace(/[^a-z0-9가-힣]+/g, '-') + .replace(/^-+|-+$/g, ''); + }; + + const handleOpenModal = (category?: BoardCategory) => { + if (category) { + setEditingCategory(category); + setFormData({ + name: category.name, + name_en: category.name_en || '', + name_mn: category.name_mn || '', + name_ru: category.name_ru || '', + slug: category.slug, + description: category.description || '', + sort_order: category.sort_order, + is_active: category.is_active, + }); + } else { + setEditingCategory(null); + setFormData(initialFormData); + } + setError(null); + setShowModal(true); + }; + + const handleCloseModal = () => { + setShowModal(false); + setEditingCategory(null); + setFormData(initialFormData); + setError(null); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!formData.name.trim()) { + setError('Category name is required'); + return; + } + if (!formData.slug.trim()) { + setError('Slug is required'); + return; + } + + setSubmitting(true); + setError(null); + + try { + if (editingCategory) { + await boardApi.updateCategory(editingCategory.id, formData); + } else { + await boardApi.createCategory(formData); + } + handleCloseModal(); + fetchCategories(); + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to save category'); + } finally { + setSubmitting(false); + } + }; + + const handleDelete = async (categoryId: number) => { + const category = categories.find(c => c.id === categoryId); + if (!category) return; + + if (category.post_count > 0) { + alert(`Cannot delete category with ${category.post_count} posts. Please delete or move posts first.`); + return; + } + + if (!confirm('Are you sure you want to delete this category?')) return; + + try { + await boardApi.deleteCategory(categoryId); + fetchCategories(); + } catch (err: any) { + alert(err.response?.data?.detail || 'Failed to delete category'); + } + }; + + const handleToggleActive = async (category: BoardCategory) => { + try { + await boardApi.updateCategory(category.id, { is_active: !category.is_active }); + fetchCategories(); + } catch (err) { + console.error('Failed to toggle category:', err); + } + }; + + return ( +
+ {/* Header */} +
+
+ + + + + +

Board Categories

+
+ +
+ + {/* Categories Table */} +
+ {loading ? ( +
+
+
+ ) : categories.length === 0 ? ( +
+ No categories yet. Create one to get started. +
+ ) : ( + + + + + + + + + + + + + + {categories.map((category) => ( + + + + + + + + + + ))} + +
OrderName (KO)Name (EN)SlugPostsActiveActions
{category.sort_order}{category.name}{category.name_en || '-'}{category.slug}{category.post_count} + + +
+ + +
+
+ )} +
+ + {/* Modal */} + {showModal && ( +
+
+
+

+ {editingCategory ? 'Edit Category' : 'Add Category'} +

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+
+ + { + setFormData({ + ...formData, + name: e.target.value, + slug: formData.slug || generateSlug(e.target.value), + }); + }} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" + required + /> +
+
+ + setFormData({ ...formData, name_en: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" + /> +
+
+ +
+
+ + setFormData({ ...formData, name_mn: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" + /> +
+
+ + setFormData({ ...formData, name_ru: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" + /> +
+
+ +
+
+ + setFormData({ ...formData, slug: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono" + placeholder="category-slug" + required + /> +
+
+ + setFormData({ ...formData, sort_order: parseInt(e.target.value) || 0 })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" + min="0" + /> +
+
+ +
+ + setFormData({ ...formData, description: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" + placeholder="Brief description" + /> +
+ +
+ setFormData({ ...formData, is_active: e.target.checked })} + className="h-4 w-4 text-blue-600 border-gray-300 rounded" + /> + +
+ +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/app/admin/board/page.tsx b/frontend/src/app/admin/board/page.tsx new file mode 100644 index 0000000..6ca107b --- /dev/null +++ b/frontend/src/app/admin/board/page.tsx @@ -0,0 +1,330 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { boardApi, BoardPostListItem, BoardCategory, BoardPostListResponse, BoardCategoryListResponse } from '@/lib/api'; +import { useAuthStore } from '@/lib/store'; + +export default function AdminBoardPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { user } = useAuthStore(); + + const [posts, setPosts] = useState([]); + const [notices, setNotices] = useState([]); + const [categories, setCategories] = useState([]); + const [total, setTotal] = useState(0); + const [totalPages, setTotalPages] = useState(1); + const [loading, setLoading] = useState(true); + + const page = parseInt(searchParams.get('page') || '1'); + const categoryId = searchParams.get('category') ? parseInt(searchParams.get('category')!) : undefined; + const search = searchParams.get('search') || ''; + + useEffect(() => { + if (!user?.is_admin) { + router.push('/admin'); + return; + } + fetchData(); + }, [page, categoryId, search, user]); + + const fetchData = async () => { + setLoading(true); + try { + const [postsRes, categoriesRes] = await Promise.all([ + boardApi.getAdminPosts({ page, page_size: 20, category_id: categoryId, search }), + boardApi.getCategories(true), + ]); + setPosts(postsRes.posts); + setNotices(postsRes.notices); + setTotal(postsRes.total); + setTotalPages(postsRes.total_pages); + setCategories(categoriesRes.categories); + } catch (error) { + console.error('Failed to fetch board data:', error); + } finally { + setLoading(false); + } + }; + + const handleToggleNotice = async (postId: number) => { + try { + await boardApi.toggleNotice(postId); + fetchData(); + } catch (error) { + console.error('Failed to toggle notice:', error); + } + }; + + const handleTogglePin = async (postId: number) => { + try { + await boardApi.togglePin(postId); + fetchData(); + } catch (error) { + console.error('Failed to toggle pin:', error); + } + }; + + const handleDelete = async (postId: number) => { + if (!confirm('Are you sure you want to delete this post?')) return; + try { + await boardApi.deletePost(postId); + fetchData(); + } catch (error) { + console.error('Failed to delete post:', error); + } + }; + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const searchValue = formData.get('search') as string; + const params = new URLSearchParams(); + if (categoryId) params.set('category', categoryId.toString()); + if (searchValue) params.set('search', searchValue); + router.push(`/admin/board?${params.toString()}`); + }; + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString(); + }; + + const allPosts = [...notices, ...posts]; + + return ( +
+ {/* Header */} +
+

Board Management

+
+ + Manage Categories + + + New Post + +
+
+ + {/* Stats */} +
+
+
Total Posts
+
{total}
+
+
+
Notices
+
{notices.length}
+
+
+
Categories
+
{categories.length}
+
+
+
Active Categories
+
+ {categories.filter(c => c.is_active).length} +
+
+
+ + {/* Filters */} +
+
+ {/* Category Filter */} +
+ + {categories.map((cat) => ( + + ))} +
+ + {/* Search */} +
+ + +
+
+
+ + {/* Posts Table */} +
+ {loading ? ( +
+
+
+ ) : allPosts.length === 0 ? ( +
+ No posts found +
+ ) : ( + + + + + + + + + + + + + + + {allPosts.map((post) => ( + + + + + + + + + + + ))} + +
IDTitleCategoryAuthorViewsStatusDateActions
{post.id} + + {post.title} + + {post.category_name || '-'}{post.author_name || 'Unknown'}{post.view_count} +
+ {post.is_notice && ( + Notice + )} + {post.is_pinned && ( + Pinned + )} +
+
{formatDate(post.created_at)} +
+ + + + + + + + +
+
+ )} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index ce39fb2..b9b0f7c 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -20,6 +20,7 @@ const menuItems = [ { href: '/admin/dealer-translations', label: 'Dealer Descriptions', icon: '📝' }, { href: '/admin/users', label: 'Users', icon: '👥' }, { href: '/admin/inquiries', label: 'Inquiries', icon: '💬' }, + { href: '/admin/board', label: 'Board', icon: '📌' }, { href: '/admin/settings', label: 'Settings', icon: '⚙️' }, ]; diff --git a/frontend/src/app/admin/settings/page.tsx b/frontend/src/app/admin/settings/page.tsx index 12b26cf..d06e0f6 100644 --- a/frontend/src/app/admin/settings/page.tsx +++ b/frontend/src/app/admin/settings/page.tsx @@ -33,6 +33,8 @@ interface SystemSettings { event_cc_validity_months: number; withdrawal_enabled: boolean; min_withdrawal_usd: number; + // Board settings + board_enabled: boolean; // Car availability check settings car_availability_check_enabled: boolean; car_availability_check_hour: number; @@ -103,6 +105,8 @@ export default function SettingsPage() { event_cc_validity_months: 6, withdrawal_enabled: true, min_withdrawal_usd: 10.0, + // Board settings + board_enabled: true, // Car availability check car_availability_check_enabled: true, car_availability_check_hour: 6, @@ -146,6 +150,8 @@ export default function SettingsPage() { event_cc_validity_months: data.event_cc_validity_months ?? 6, withdrawal_enabled: data.withdrawal_enabled ?? true, min_withdrawal_usd: data.min_withdrawal_usd ?? 10.0, + // Board settings + board_enabled: data.board_enabled ?? true, // Car availability check car_availability_check_enabled: data.car_availability_check_enabled ?? true, car_availability_check_hour: data.car_availability_check_hour ?? 6, @@ -367,6 +373,22 @@ export default function SettingsPage() {

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

+ +
+ +
+ Show Board Menu +

상단 네비게이션에 게시판 메뉴 표시 여부

+
+
diff --git a/frontend/src/app/board/[id]/page.tsx b/frontend/src/app/board/[id]/page.tsx new file mode 100644 index 0000000..f62f43c --- /dev/null +++ b/frontend/src/app/board/[id]/page.tsx @@ -0,0 +1,195 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter, useParams } from 'next/navigation'; +import Link from 'next/link'; +import { boardApi, BoardPost } from '@/lib/api'; +import { useAuthStore } from '@/lib/store'; +import { useTranslate } from '@/lib/useTranslate'; + +export default function BoardPostPage() { + const router = useRouter(); + const params = useParams(); + const postId = parseInt(params.id as string); + const { user, isLoggedIn } = useAuthStore(); + const { translate, language } = useTranslate(); + + const [post, setPost] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [deleting, setDeleting] = useState(false); + + useEffect(() => { + const fetchPost = async () => { + try { + const data = await boardApi.getPost(postId); + setPost(data); + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to load post'); + } finally { + setLoading(false); + } + }; + fetchPost(); + }, [postId]); + + const getCategoryName = (post: BoardPost) => { + if (!post.category) return '-'; + if (language === 'en' && post.category.name_en) return post.category.name_en; + if (language === 'mn' && post.category.name_mn) return post.category.name_mn; + if (language === 'ru' && post.category.name_ru) return post.category.name_ru; + return post.category.name; + }; + + const canEdit = isLoggedIn && post && (user?.id === post.author_id || user?.is_admin); + + const handleDelete = async () => { + if (!confirm(translate('Are you sure you want to delete this post?'))) return; + + setDeleting(true); + try { + await boardApi.deletePost(postId); + router.push('/board'); + } catch (err: any) { + alert(err.response?.data?.detail || 'Failed to delete post'); + } finally { + setDeleting(false); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error || !post) { + return ( +
+
+

{error || 'Post not found'}

+ + {translate('Back to list')} + +
+
+ ); + } + + return ( +
+
+ {/* Back Button */} +
+ + + + + {translate('Back to list')} + +
+ + {/* Post */} +
+ {/* Header */} +
+ {/* Notice Badge */} + {post.is_notice && ( + + Notice + + )} + + {/* Title */} +

+ {post.title} +

+ + {/* Meta */} +
+ {/* Category */} + + + + + {getCategoryName(post)} + + + {/* Author */} + + + + + {post.author?.name || post.author?.email || 'Unknown'} + {post.author?.is_admin && ( + Admin + )} + + + {/* Date */} + + + + + {new Date(post.created_at).toLocaleString()} + + + {/* Views */} + + + + + + {post.view_count} {translate('views')} + +
+
+ + {/* Content */} +
+
+ {post.content} +
+
+ + {/* Actions */} + {canEdit && ( +
+ + {translate('Edit')} + + +
+ )} +
+ + {/* Navigation Buttons */} +
+ + {translate('List')} + +
+
+
+ ); +} diff --git a/frontend/src/app/board/edit/[id]/page.tsx b/frontend/src/app/board/edit/[id]/page.tsx new file mode 100644 index 0000000..af4222d --- /dev/null +++ b/frontend/src/app/board/edit/[id]/page.tsx @@ -0,0 +1,260 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter, useParams } from 'next/navigation'; +import Link from 'next/link'; +import { boardApi, BoardCategory, BoardPost } from '@/lib/api'; +import { useAuthStore } from '@/lib/store'; +import { useTranslate } from '@/lib/useTranslate'; + +export default function BoardEditPage() { + const router = useRouter(); + const params = useParams(); + const postId = parseInt(params.id as string); + const { user, isLoggedIn } = useAuthStore(); + const { translate, language } = useTranslate(); + + const [post, setPost] = useState(null); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const [categoryId, setCategoryId] = useState(''); + const [isNotice, setIsNotice] = useState(false); + const [isPinned, setIsPinned] = useState(false); + + useEffect(() => { + if (!isLoggedIn) { + router.push('/login?redirect=/board'); + return; + } + + const fetchData = async () => { + try { + const [postData, categoriesRes] = await Promise.all([ + boardApi.getPost(postId), + boardApi.getCategories(), + ]); + + // Check permission + if (postData.author_id !== user?.id && !user?.is_admin) { + router.push('/board'); + return; + } + + setPost(postData); + setCategories(categoriesRes.categories); + setTitle(postData.title); + setContent(postData.content); + setCategoryId(postData.category_id); + setIsNotice(postData.is_notice); + setIsPinned(postData.is_pinned); + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to load post'); + } finally { + setLoading(false); + } + }; + fetchData(); + }, [isLoggedIn, postId, user, router]); + + const getCategoryName = (cat: BoardCategory) => { + if (language === 'en' && cat.name_en) return cat.name_en; + if (language === 'mn' && cat.name_mn) return cat.name_mn; + if (language === 'ru' && cat.name_ru) return cat.name_ru; + return cat.name; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!title.trim()) { + setError(translate('Please enter a title')); + return; + } + if (!content.trim()) { + setError(translate('Please enter content')); + return; + } + if (!categoryId) { + setError(translate('Please select a category')); + return; + } + + setSubmitting(true); + setError(null); + + try { + await boardApi.updatePost(postId, { + title: title.trim(), + content: content.trim(), + category_id: categoryId as number, + is_notice: user?.is_admin ? isNotice : undefined, + is_pinned: user?.is_admin ? isPinned : undefined, + }); + router.push(`/board/${postId}`); + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to update post'); + } finally { + setSubmitting(false); + } + }; + + if (!isLoggedIn || loading) { + return ( +
+
+
+ ); + } + + if (error && !post) { + return ( +
+
+

{error}

+ + {translate('Back to list')} + +
+
+ ); + } + + return ( +
+
+ {/* Back Button */} +
+ + + + + {translate('Cancel')} + +
+ + {/* Form */} +
+
+

+ {translate('Edit Post')} +

+
+ +
+ {error && ( +
+ {error} +
+ )} + + {/* Category */} +
+ + +
+ + {/* Title */} +
+ + setTitle(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + placeholder={translate('Enter title')} + maxLength={200} + required + /> +
+ + {/* Content */} +
+ +