feat: Add bulletin board system

- Add BoardCategory and BoardPost models with multi-language support
- Add bulletin API endpoints (CRUD, notice toggle, pin toggle)
- Add board_enabled setting to control menu visibility
- Create frontend board pages (list, detail, write, edit)
- Create admin board management and category management pages
- Update Header.tsx with conditional Board menu between Inquiry and Contact Us
- Update admin settings with board_enabled toggle
- Add Board menu to admin sidebar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
AutonetSellCar Deploy
2026-01-10 01:34:41 +09:00
parent 04bec0d2c7
commit e0c1f4540b
17 changed files with 2630 additions and 2 deletions

524
backend/app/api/bulletin.py Normal file
View File

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

View File

@@ -9,7 +9,7 @@ import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from .database import engine, Base, SessionLocal 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 .config import get_settings
from .services.exchange_rate_service import update_exchange_rates from .services.exchange_rate_service import update_exchange_rates
from .services.visitor_service import aggregate_daily_stats, cleanup_old_visitor_logs 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(verification.router, prefix="/api")
app.include_router(visitor.router, prefix="/api") app.include_router(visitor.router, prefix="/api")
app.include_router(sns_share.router, prefix="/api") app.include_router(sns_share.router, prefix="/api")
app.include_router(bulletin.router, prefix="/api")
@app.get("/") @app.get("/")

View File

@@ -18,6 +18,7 @@ from .car_specification import CarSpecification
from .exchange_rate import ExchangeRate, ExchangeRateHistory from .exchange_rate import ExchangeRate, ExchangeRateHistory
from .cc_package import CCPackage, DEFAULT_CC_PACKAGES from .cc_package import CCPackage, DEFAULT_CC_PACKAGES
from .visitor import VisitorLog, VisitorDailyStats, VisitorSession from .visitor import VisitorLog, VisitorDailyStats, VisitorSession
from .bulletin import BoardCategory, BoardPost
__all__ = [ __all__ = [
"CarMaker", "CarMaker",
@@ -63,4 +64,6 @@ __all__ = [
"VisitorLog", "VisitorLog",
"VisitorDailyStats", "VisitorDailyStats",
"VisitorSession", "VisitorSession",
"BoardCategory",
"BoardPost",
] ]

View File

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

View File

@@ -63,6 +63,9 @@ class SystemSettings(Base):
withdrawal_enabled = Column(Boolean, default=True) # 출금 기능 활성화 withdrawal_enabled = Column(Boolean, default=True) # 출금 기능 활성화
min_withdrawal_usd = Column(Float, default=10.0) # 최소 출금 금액 (USD) 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_enabled = Column(Boolean, default=True) # 자동 검증 활성화
car_availability_check_hour = Column(Integer, default=6) # 검증 시간 (0-23, 기본 새벽 6시) car_availability_check_hour = Column(Integer, default=6) # 검증 시간 (0-23, 기본 새벽 6시)

View File

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

View File

@@ -32,6 +32,9 @@ class SystemSettingsUpdate(BaseModel):
withdrawal_enabled: Optional[bool] = None withdrawal_enabled: Optional[bool] = None
min_withdrawal_usd: Optional[float] = None min_withdrawal_usd: Optional[float] = None
# 게시판 설정
board_enabled: Optional[bool] = None
# 차량 판매상태 검증 설정 # 차량 판매상태 검증 설정
car_availability_check_enabled: Optional[bool] = None car_availability_check_enabled: Optional[bool] = None
car_availability_check_hour: Optional[int] = None car_availability_check_hour: Optional[int] = None
@@ -67,6 +70,9 @@ class SystemSettingsResponse(BaseModel):
withdrawal_enabled: bool = True withdrawal_enabled: bool = True
min_withdrawal_usd: float = 10.0 min_withdrawal_usd: float = 10.0
# 게시판 설정
board_enabled: bool = True
# 차량 판매상태 검증 설정 # 차량 판매상태 검증 설정
car_availability_check_enabled: bool = True car_availability_check_enabled: bool = True
car_availability_check_hour: int = 6 car_availability_check_hour: int = 6

View File

@@ -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<BoardCategory[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingCategory, setEditingCategory] = useState<BoardCategory | null>(null);
const [formData, setFormData] = useState<CategoryFormData>(initialFormData);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Link
href="/admin/board"
className="text-gray-500 hover:text-gray-700"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</Link>
<h1 className="text-2xl font-bold text-gray-900">Board Categories</h1>
</div>
<button
onClick={() => handleOpenModal()}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
>
Add Category
</button>
</div>
{/* Categories Table */}
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
{loading ? (
<div className="p-8 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-blue-600 border-t-transparent"></div>
</div>
) : categories.length === 0 ? (
<div className="p-8 text-center text-gray-500">
No categories yet. Create one to get started.
</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase w-16">Order</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name (KO)</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name (EN)</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase w-32">Slug</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase w-20">Posts</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase w-20">Active</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase w-32">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{categories.map((category) => (
<tr key={category.id} className={`hover:bg-gray-50 ${!category.is_active ? 'opacity-50' : ''}`}>
<td className="px-4 py-3 text-sm text-gray-500">{category.sort_order}</td>
<td className="px-4 py-3 text-sm text-gray-900 font-medium">{category.name}</td>
<td className="px-4 py-3 text-sm text-gray-500">{category.name_en || '-'}</td>
<td className="px-4 py-3 text-sm text-gray-500 font-mono">{category.slug}</td>
<td className="px-4 py-3 text-sm text-gray-500 text-center">{category.post_count}</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleToggleActive(category)}
className={`relative inline-flex items-center h-6 w-11 rounded-full transition-colors ${
category.is_active ? 'bg-green-500' : 'bg-gray-300'
}`}
>
<span
className={`inline-block w-4 h-4 transform bg-white rounded-full transition-transform ${
category.is_active ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</td>
<td className="px-4 py-3 text-center">
<div className="flex justify-center gap-2">
<button
onClick={() => handleOpenModal(category)}
className="p-1 text-blue-600 hover:bg-blue-50 rounded"
title="Edit"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDelete(category.id)}
disabled={category.post_count > 0}
className="p-1 text-red-600 hover:bg-red-50 rounded disabled:opacity-30 disabled:cursor-not-allowed"
title={category.post_count > 0 ? 'Cannot delete: has posts' : 'Delete'}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Modal */}
{showModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4">
<div className="p-6 border-b border-gray-200">
<h2 className="text-lg font-bold text-gray-900">
{editingCategory ? 'Edit Category' : 'Add Category'}
</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name (Korean) <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => {
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
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name (English)
</label>
<input
type="text"
value={formData.name_en}
onChange={(e) => setFormData({ ...formData, name_en: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name (Mongolian)
</label>
<input
type="text"
value={formData.name_mn}
onChange={(e) => setFormData({ ...formData, name_mn: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name (Russian)
</label>
<input
type="text"
value={formData.name_ru}
onChange={(e) => setFormData({ ...formData, name_ru: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Slug <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.slug}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Sort Order
</label>
<input
type="number"
value={formData.sort_order}
onChange={(e) => 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"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<input
type="text"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="Brief description"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_active"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<label htmlFor="is_active" className="text-sm text-gray-700">
Active (visible to users)
</label>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={handleCloseModal}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{submitting ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -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<BoardPostListItem[]>([]);
const [notices, setNotices] = useState<BoardPostListItem[]>([]);
const [categories, setCategories] = useState<BoardCategory[]>([]);
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<HTMLFormElement>) => {
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 (
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Board Management</h1>
<div className="flex gap-3">
<Link
href="/admin/board/categories"
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
>
Manage Categories
</Link>
<Link
href="/board/write"
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
>
New Post
</Link>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white p-4 rounded-lg shadow-sm">
<div className="text-sm text-gray-500">Total Posts</div>
<div className="text-2xl font-bold text-gray-900">{total}</div>
</div>
<div className="bg-white p-4 rounded-lg shadow-sm">
<div className="text-sm text-gray-500">Notices</div>
<div className="text-2xl font-bold text-red-600">{notices.length}</div>
</div>
<div className="bg-white p-4 rounded-lg shadow-sm">
<div className="text-sm text-gray-500">Categories</div>
<div className="text-2xl font-bold text-blue-600">{categories.length}</div>
</div>
<div className="bg-white p-4 rounded-lg shadow-sm">
<div className="text-sm text-gray-500">Active Categories</div>
<div className="text-2xl font-bold text-green-600">
{categories.filter(c => c.is_active).length}
</div>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
{/* Category Filter */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => {
const params = new URLSearchParams();
if (search) params.set('search', search);
router.push(`/admin/board?${params.toString()}`);
}}
className={`px-3 py-1.5 text-sm rounded-full transition-colors ${
!categoryId
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
All
</button>
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => {
const params = new URLSearchParams();
params.set('category', cat.id.toString());
if (search) params.set('search', search);
router.push(`/admin/board?${params.toString()}`);
}}
className={`px-3 py-1.5 text-sm rounded-full transition-colors ${
categoryId === cat.id
? 'bg-blue-600 text-white'
: cat.is_active
? 'bg-gray-100 text-gray-700 hover:bg-gray-200'
: 'bg-gray-50 text-gray-400'
}`}
>
{cat.name} ({cat.post_count})
</button>
))}
</div>
{/* Search */}
<form onSubmit={handleSearch} className="flex gap-2">
<input
type="text"
name="search"
defaultValue={search}
placeholder="Search..."
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm"
/>
<button
type="submit"
className="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
>
Search
</button>
</form>
</div>
</div>
{/* Posts Table */}
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
{loading ? (
<div className="p-8 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-blue-600 border-t-transparent"></div>
</div>
) : allPosts.length === 0 ? (
<div className="p-8 text-center text-gray-500">
No posts found
</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase w-16">ID</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase w-32">Category</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase w-32">Author</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase w-20">Views</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase w-24">Status</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase w-28">Date</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase w-32">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{allPosts.map((post) => (
<tr key={post.id} className={`hover:bg-gray-50 ${post.is_notice ? 'bg-amber-50' : ''}`}>
<td className="px-4 py-3 text-sm text-gray-500">{post.id}</td>
<td className="px-4 py-3">
<Link
href={`/board/${post.id}`}
className="text-sm text-gray-900 hover:text-blue-600"
>
{post.title}
</Link>
</td>
<td className="px-4 py-3 text-sm text-gray-500">{post.category_name || '-'}</td>
<td className="px-4 py-3 text-sm text-gray-500">{post.author_name || 'Unknown'}</td>
<td className="px-4 py-3 text-sm text-gray-500 text-center">{post.view_count}</td>
<td className="px-4 py-3 text-center">
<div className="flex justify-center gap-1">
{post.is_notice && (
<span className="px-1.5 py-0.5 text-xs bg-red-100 text-red-700 rounded">Notice</span>
)}
{post.is_pinned && (
<span className="px-1.5 py-0.5 text-xs bg-amber-100 text-amber-700 rounded">Pinned</span>
)}
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-500 text-center">{formatDate(post.created_at)}</td>
<td className="px-4 py-3 text-center">
<div className="flex justify-center gap-1">
<button
onClick={() => handleToggleNotice(post.id)}
className={`p-1 rounded ${post.is_notice ? 'text-red-600 hover:bg-red-50' : 'text-gray-400 hover:bg-gray-100'}`}
title={post.is_notice ? 'Remove Notice' : 'Set as Notice'}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
</button>
<button
onClick={() => handleTogglePin(post.id)}
className={`p-1 rounded ${post.is_pinned ? 'text-amber-600 hover:bg-amber-50' : 'text-gray-400 hover:bg-gray-100'}`}
title={post.is_pinned ? 'Unpin' : 'Pin'}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</button>
<Link
href={`/board/edit/${post.id}`}
className="p-1 text-blue-600 hover:bg-blue-50 rounded"
title="Edit"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</Link>
<button
onClick={() => handleDelete(post.id)}
className="p-1 text-red-600 hover:bg-red-50 rounded"
title="Delete"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-6 flex justify-center">
<nav className="flex items-center gap-1">
<button
onClick={() => {
const params = new URLSearchParams(searchParams.toString());
params.set('page', (page - 1).toString());
router.push(`/admin/board?${params.toString()}`);
}}
disabled={page <= 1}
className="px-3 py-2 text-sm text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
Prev
</button>
<span className="px-4 py-2 text-sm text-gray-700">
{page} / {totalPages}
</span>
<button
onClick={() => {
const params = new URLSearchParams(searchParams.toString());
params.set('page', (page + 1).toString());
router.push(`/admin/board?${params.toString()}`);
}}
disabled={page >= totalPages}
className="px-3 py-2 text-sm text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
Next
</button>
</nav>
</div>
)}
</div>
);
}

View File

@@ -20,6 +20,7 @@ const menuItems = [
{ href: '/admin/dealer-translations', label: 'Dealer Descriptions', icon: '📝' }, { href: '/admin/dealer-translations', label: 'Dealer Descriptions', icon: '📝' },
{ href: '/admin/users', label: 'Users', icon: '👥' }, { href: '/admin/users', label: 'Users', icon: '👥' },
{ href: '/admin/inquiries', label: 'Inquiries', icon: '💬' }, { href: '/admin/inquiries', label: 'Inquiries', icon: '💬' },
{ href: '/admin/board', label: 'Board', icon: '📌' },
{ href: '/admin/settings', label: 'Settings', icon: '⚙️' }, { href: '/admin/settings', label: 'Settings', icon: '⚙️' },
]; ];

View File

@@ -33,6 +33,8 @@ interface SystemSettings {
event_cc_validity_months: number; event_cc_validity_months: number;
withdrawal_enabled: boolean; withdrawal_enabled: boolean;
min_withdrawal_usd: number; min_withdrawal_usd: number;
// Board settings
board_enabled: boolean;
// Car availability check settings // Car availability check settings
car_availability_check_enabled: boolean; car_availability_check_enabled: boolean;
car_availability_check_hour: number; car_availability_check_hour: number;
@@ -103,6 +105,8 @@ export default function SettingsPage() {
event_cc_validity_months: 6, event_cc_validity_months: 6,
withdrawal_enabled: true, withdrawal_enabled: true,
min_withdrawal_usd: 10.0, min_withdrawal_usd: 10.0,
// Board settings
board_enabled: true,
// Car availability check // Car availability check
car_availability_check_enabled: true, car_availability_check_enabled: true,
car_availability_check_hour: 6, car_availability_check_hour: 6,
@@ -146,6 +150,8 @@ export default function SettingsPage() {
event_cc_validity_months: data.event_cc_validity_months ?? 6, event_cc_validity_months: data.event_cc_validity_months ?? 6,
withdrawal_enabled: data.withdrawal_enabled ?? true, withdrawal_enabled: data.withdrawal_enabled ?? true,
min_withdrawal_usd: data.min_withdrawal_usd ?? 10.0, min_withdrawal_usd: data.min_withdrawal_usd ?? 10.0,
// Board settings
board_enabled: data.board_enabled ?? true,
// Car availability check // Car availability check
car_availability_check_enabled: data.car_availability_check_enabled ?? true, car_availability_check_enabled: data.car_availability_check_enabled ?? true,
car_availability_check_hour: data.car_availability_check_hour ?? 6, car_availability_check_hour: data.car_availability_check_hour ?? 6,
@@ -367,6 +373,22 @@ export default function SettingsPage() {
<p className="text-sm text-gray-500"> </p> <p className="text-sm text-gray-500"> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={formData.board_enabled}
onChange={(e) => setFormData(prev => ({ ...prev, board_enabled: e.target.checked }))}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
</label>
<div>
<span className="text-sm font-medium text-gray-700">Show Board Menu</span>
<p className="text-sm text-gray-500"> </p>
</div>
</div>
</div> </div>
</div> </div>

View File

@@ -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<BoardPost | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-blue-600 border-t-transparent"></div>
</div>
);
}
if (error || !post) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<p className="text-red-600 mb-4">{error || 'Post not found'}</p>
<Link href="/board" className="text-blue-600 hover:underline">
{translate('Back to list')}
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto px-4 py-8">
{/* Back Button */}
<div className="mb-6">
<Link
href="/board"
className="inline-flex items-center text-gray-600 hover:text-gray-900"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{translate('Back to list')}
</Link>
</div>
{/* Post */}
<article className="bg-white rounded-lg shadow-sm overflow-hidden">
{/* Header */}
<div className="p-6 border-b border-gray-200">
{/* Notice Badge */}
{post.is_notice && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 mb-3">
Notice
</span>
)}
{/* Title */}
<h1 className="text-2xl font-bold text-gray-900 mb-4">
{post.title}
</h1>
{/* Meta */}
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500">
{/* Category */}
<span className="inline-flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
{getCategoryName(post)}
</span>
{/* Author */}
<span className="inline-flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
{post.author?.name || post.author?.email || 'Unknown'}
{post.author?.is_admin && (
<span className="ml-1 px-1.5 py-0.5 text-xs bg-blue-100 text-blue-800 rounded">Admin</span>
)}
</span>
{/* Date */}
<span className="inline-flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{new Date(post.created_at).toLocaleString()}
</span>
{/* Views */}
<span className="inline-flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
{post.view_count} {translate('views')}
</span>
</div>
</div>
{/* Content */}
<div className="p-6">
<div
className="prose prose-sm max-w-none text-gray-700"
style={{ whiteSpace: 'pre-wrap' }}
>
{post.content}
</div>
</div>
{/* Actions */}
{canEdit && (
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200 flex justify-end gap-3">
<Link
href={`/board/edit/${post.id}`}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
{translate('Edit')}
</Link>
<button
onClick={handleDelete}
disabled={deleting}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
>
{deleting ? translate('Deleting...') : translate('Delete')}
</button>
</div>
)}
</article>
{/* Navigation Buttons */}
<div className="mt-6 flex justify-center">
<Link
href="/board"
className="px-6 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
{translate('List')}
</Link>
</div>
</div>
</div>
);
}

View File

@@ -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<BoardPost | null>(null);
const [categories, setCategories] = useState<BoardCategory[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [categoryId, setCategoryId] = useState<number | ''>('');
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 (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-blue-600 border-t-transparent"></div>
</div>
);
}
if (error && !post) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<p className="text-red-600 mb-4">{error}</p>
<Link href="/board" className="text-blue-600 hover:underline">
{translate('Back to list')}
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto px-4 py-8">
{/* Back Button */}
<div className="mb-6">
<Link
href={`/board/${postId}`}
className="inline-flex items-center text-gray-600 hover:text-gray-900"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{translate('Cancel')}
</Link>
</div>
{/* Form */}
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<div className="p-6 border-b border-gray-200">
<h1 className="text-xl font-bold text-gray-900">
{translate('Edit Post')}
</h1>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{error && (
<div className="p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
{error}
</div>
)}
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{translate('Category')} <span className="text-red-500">*</span>
</label>
<select
value={categoryId}
onChange={(e) => setCategoryId(e.target.value ? parseInt(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"
required
>
<option value="">{translate('Select a category')}</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{getCategoryName(cat)}
</option>
))}
</select>
</div>
{/* Title */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{translate('Title')} <span className="text-red-500">*</span>
</label>
<input
type="text"
value={title}
onChange={(e) => 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
/>
</div>
{/* Content */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{translate('Content')} <span className="text-red-500">*</span>
</label>
<textarea
value={content}
onChange={(e) => setContent(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 min-h-[300px]"
placeholder={translate('Enter content')}
required
/>
</div>
{/* Admin Options */}
{user?.is_admin && (
<div className="space-y-3 p-4 bg-gray-50 rounded-lg">
<h3 className="text-sm font-medium text-gray-700">{translate('Admin Options')}</h3>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isNotice"
checked={isNotice}
onChange={(e) => setIsNotice(e.target.checked)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<label htmlFor="isNotice" className="text-sm text-gray-700">
{translate('Notice')}
</label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isPinned"
checked={isPinned}
onChange={(e) => setIsPinned(e.target.checked)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<label htmlFor="isPinned" className="text-sm text-gray-700">
{translate('Pin to top')}
</label>
</div>
</div>
)}
{/* Submit Buttons */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
<Link
href={`/board/${postId}`}
className="px-6 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
{translate('Cancel')}
</Link>
<button
type="submit"
disabled={submitting}
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
>
{submitting ? translate('Saving...') : translate('Save')}
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,293 @@
'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';
import { useTranslate } from '@/lib/useTranslate';
export default function BoardPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { user, isLoggedIn } = useAuthStore();
const { translate, language } = useTranslate();
const [posts, setPosts] = useState<BoardPostListItem[]>([]);
const [notices, setNotices] = useState<BoardPostListItem[]>([]);
const [categories, setCategories] = useState<BoardCategory[]>([]);
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') || '';
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;
};
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const [postsRes, categoriesRes] = await Promise.all([
boardApi.getPosts({ page, page_size: 20, category_id: categoryId, search }),
boardApi.getCategories(),
]);
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);
}
};
fetchData();
}, [page, categoryId, search]);
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (days < 7) {
return `${days}d ago`;
} else {
return date.toLocaleDateString();
}
};
const handleCategoryChange = (catId: number | undefined) => {
const params = new URLSearchParams();
if (catId) params.set('category', catId.toString());
if (search) params.set('search', search);
router.push(`/board?${params.toString()}`);
};
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
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(`/board?${params.toString()}`);
};
const renderPostRow = (post: BoardPostListItem, isNotice: boolean = false) => (
<tr
key={post.id}
className={`hover:bg-gray-50 cursor-pointer ${isNotice ? 'bg-amber-50' : ''}`}
onClick={() => router.push(`/board/${post.id}`)}
>
<td className="px-4 py-3 text-center text-sm text-gray-500">
{isNotice ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
Notice
</span>
) : (
post.id
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{post.is_pinned && !isNotice && (
<span className="text-amber-500" title="Pinned">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M5.5 16a3.5 3.5 0 01-.369-6.98 4 4 0 117.753-1.977A4.5 4.5 0 1113.5 16h-8z" />
</svg>
</span>
)}
<span className={`text-sm ${isNotice ? 'font-semibold text-red-700' : 'text-gray-900'}`}>
{post.title}
</span>
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-500 hidden md:table-cell">
{post.category_name || '-'}
</td>
<td className="px-4 py-3 text-sm text-gray-500 hidden sm:table-cell">
{post.author_name || 'Unknown'}
</td>
<td className="px-4 py-3 text-sm text-gray-500 text-center hidden sm:table-cell">
{post.view_count}
</td>
<td className="px-4 py-3 text-sm text-gray-500 text-right">
{formatDate(post.created_at)}
</td>
</tr>
);
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-6xl mx-auto px-4 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">
{translate('Board')}
</h1>
{isLoggedIn && (
<Link
href="/board/write"
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{translate('Write')}
</Link>
)}
</div>
{/* Categories & Search */}
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
{/* Category Tabs */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => handleCategoryChange(undefined)}
className={`px-3 py-1.5 text-sm rounded-full transition-colors ${
!categoryId
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{translate('All')}
</button>
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => handleCategoryChange(cat.id)}
className={`px-3 py-1.5 text-sm rounded-full transition-colors ${
categoryId === cat.id
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{getCategoryName(cat)} ({cat.post_count})
</button>
))}
</div>
{/* Search */}
<form onSubmit={handleSearch} className="flex gap-2">
<input
type="text"
name="search"
defaultValue={search}
placeholder={translate('Search...')}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<button
type="submit"
className="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
</form>
</div>
</div>
{/* Posts Table */}
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
{loading ? (
<div className="p-8 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-blue-600 border-t-transparent"></div>
</div>
) : notices.length === 0 && posts.length === 0 ? (
<div className="p-8 text-center text-gray-500">
{translate('No posts yet')}
</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider w-20">
#
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{translate('Title')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider hidden md:table-cell w-32">
{translate('Category')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider hidden sm:table-cell w-32">
{translate('Author')}
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider hidden sm:table-cell w-20">
{translate('Views')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider w-28">
{translate('Date')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{notices.map((notice) => renderPostRow(notice, true))}
{posts.map((post) => renderPostRow(post, false))}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-6 flex justify-center">
<nav className="flex items-center gap-1">
<button
onClick={() => {
const params = new URLSearchParams(searchParams.toString());
params.set('page', (page - 1).toString());
router.push(`/board?${params.toString()}`);
}}
disabled={page <= 1}
className="px-3 py-2 text-sm text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Prev
</button>
<span className="px-4 py-2 text-sm text-gray-700">
{page} / {totalPages}
</span>
<button
onClick={() => {
const params = new URLSearchParams(searchParams.toString());
params.set('page', (page + 1).toString());
router.push(`/board?${params.toString()}`);
}}
disabled={page >= totalPages}
className="px-3 py-2 text-sm text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</nav>
</div>
)}
{/* Login prompt for non-logged in users */}
{!isLoggedIn && (
<div className="mt-6 p-4 bg-blue-50 rounded-lg text-center">
<p className="text-sm text-blue-800">
{translate('Please login to write a post')}.{' '}
<Link href="/login" className="font-medium underline">
{translate('Login')}
</Link>
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,218 @@
'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';
import { useTranslate } from '@/lib/useTranslate';
export default function BoardWritePage() {
const router = useRouter();
const { user, isLoggedIn } = useAuthStore();
const { translate, language } = useTranslate();
const [categories, setCategories] = useState<BoardCategory[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [categoryId, setCategoryId] = useState<number | ''>('');
const [isNotice, setIsNotice] = useState(false);
useEffect(() => {
if (!isLoggedIn) {
router.push('/login?redirect=/board/write');
return;
}
const fetchCategories = async () => {
try {
const res = await boardApi.getCategories();
setCategories(res.categories);
if (res.categories.length > 0) {
setCategoryId(res.categories[0].id);
}
} catch (err) {
console.error('Failed to fetch categories:', err);
} finally {
setLoading(false);
}
};
fetchCategories();
}, [isLoggedIn, 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 {
const post = await boardApi.createPost({
title: title.trim(),
content: content.trim(),
category_id: categoryId as number,
is_notice: user?.is_admin ? isNotice : false,
});
router.push(`/board/${post.id}`);
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to create post');
} finally {
setSubmitting(false);
}
};
if (!isLoggedIn) {
return null;
}
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-blue-600 border-t-transparent"></div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto px-4 py-8">
{/* Back Button */}
<div className="mb-6">
<Link
href="/board"
className="inline-flex items-center text-gray-600 hover:text-gray-900"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{translate('Back to list')}
</Link>
</div>
{/* Form */}
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<div className="p-6 border-b border-gray-200">
<h1 className="text-xl font-bold text-gray-900">
{translate('Write Post')}
</h1>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{error && (
<div className="p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
{error}
</div>
)}
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{translate('Category')} <span className="text-red-500">*</span>
</label>
<select
value={categoryId}
onChange={(e) => setCategoryId(e.target.value ? parseInt(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"
required
>
<option value="">{translate('Select a category')}</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{getCategoryName(cat)}
</option>
))}
</select>
</div>
{/* Title */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{translate('Title')} <span className="text-red-500">*</span>
</label>
<input
type="text"
value={title}
onChange={(e) => 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
/>
</div>
{/* Content */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{translate('Content')} <span className="text-red-500">*</span>
</label>
<textarea
value={content}
onChange={(e) => setContent(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 min-h-[300px]"
placeholder={translate('Enter content')}
required
/>
</div>
{/* Notice Option (Admin Only) */}
{user?.is_admin && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isNotice"
checked={isNotice}
onChange={(e) => setIsNotice(e.target.checked)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<label htmlFor="isNotice" className="text-sm font-medium text-gray-700">
{translate('Post as Notice')} ({translate('Admin only')})
</label>
</div>
)}
{/* Submit Buttons */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
<Link
href="/board"
className="px-6 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
{translate('Cancel')}
</Link>
<button
type="submit"
disabled={submitting}
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
>
{submitting ? translate('Submitting...') : translate('Submit')}
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -5,7 +5,7 @@ import { useState, useEffect, useRef } from 'react';
import { useRouter, usePathname } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import { useAuthStore } from '@/lib/store'; import { useAuthStore } from '@/lib/store';
import { useTranslation } from '@/lib/i18n'; import { useTranslation } from '@/lib/i18n';
import { notificationApi, Notification } from '@/lib/api'; import { notificationApi, Notification, settingsApi } from '@/lib/api';
import LanguageSelector from './LanguageSelector'; import LanguageSelector from './LanguageSelector';
// Menu groups for sidebar // Menu groups for sidebar
@@ -63,9 +63,23 @@ export default function Header() {
const [loadingNotifications, setLoadingNotifications] = useState(false); const [loadingNotifications, setLoadingNotifications] = useState(false);
const notificationRef = useRef<HTMLDivElement>(null); const notificationRef = useRef<HTMLDivElement>(null);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [boardEnabled, setBoardEnabled] = useState(true);
const activeGroup = getActiveMenuGroup(pathname); const activeGroup = getActiveMenuGroup(pathname);
// Fetch settings to check if board is enabled
useEffect(() => {
const fetchSettings = async () => {
try {
const settings = await settingsApi.getSettings();
setBoardEnabled(settings.board_enabled ?? true);
} catch (error) {
console.error('Failed to fetch settings:', error);
}
};
fetchSettings();
}, []);
// Fetch unread count periodically // Fetch unread count periodically
useEffect(() => { useEffect(() => {
if (!user) return; if (!user) return;
@@ -188,6 +202,7 @@ export default function Header() {
{ href: MENU_GROUPS.billing.basePath, label: MENU_GROUPS.billing.label, group: 'billing' }, { href: MENU_GROUPS.billing.basePath, label: MENU_GROUPS.billing.label, group: 'billing' },
{ href: '/exchange-rate', label: { ko: '환율', en: 'Exchange', mn: 'Ханш', ru: 'Курс' } }, { href: '/exchange-rate', label: { ko: '환율', en: 'Exchange', mn: 'Ханш', ru: 'Курс' } },
{ href: MENU_GROUPS.inquiry.basePath, label: MENU_GROUPS.inquiry.label, group: 'inquiry' }, { href: MENU_GROUPS.inquiry.basePath, label: MENU_GROUPS.inquiry.label, group: 'inquiry' },
...(boardEnabled ? [{ href: '/board', label: { ko: '게시판', en: 'Board', mn: 'Самбар', ru: 'Доска' } }] : []),
{ href: '/contact', label: { ko: '연락처', en: 'Contact Us', mn: 'Холбоо барих', ru: 'Контакты' } }, { href: '/contact', label: { ko: '연락처', en: 'Contact Us', mn: 'Холбоо барих', ru: 'Контакты' } },
]; ];

View File

@@ -1765,4 +1765,174 @@ export const snsShareApi = {
}, },
}; };
// Board (Bulletin) API Types
export interface BoardCategory {
id: number;
name: string;
name_en?: string;
name_mn?: string;
name_ru?: string;
slug: string;
description?: string;
sort_order: number;
is_active: boolean;
created_at: string;
updated_at?: string;
post_count: number;
}
export interface BoardPostListItem {
id: number;
title: string;
category_id: number;
category_name?: string;
author_id: number;
author_name?: string;
is_notice: boolean;
is_pinned: boolean;
view_count: number;
created_at: string;
}
export interface BoardPost {
id: number;
title: string;
content: string;
category_id: number;
category?: BoardCategory;
author_id: number;
author?: {
id: number;
name?: string;
email: string;
is_admin: boolean;
};
is_notice: boolean;
is_pinned: boolean;
is_published: boolean;
view_count: number;
created_at: string;
updated_at?: string;
}
export interface BoardPostListResponse {
posts: BoardPostListItem[];
notices: BoardPostListItem[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
export interface BoardCategoryListResponse {
categories: BoardCategory[];
total: number;
}
// Board API
export const boardApi = {
// Categories
getCategories: async (includeInactive: boolean = false): Promise<BoardCategoryListResponse> => {
const { data } = await api.get('/board/categories', { params: { include_inactive: includeInactive } });
return data;
},
createCategory: async (category: {
name: string;
name_en?: string;
name_mn?: string;
name_ru?: string;
slug: string;
description?: string;
sort_order?: number;
is_active?: boolean;
}): Promise<BoardCategory> => {
const { data } = await api.post('/board/categories', category);
return data;
},
updateCategory: async (categoryId: number, category: {
name?: string;
name_en?: string;
name_mn?: string;
name_ru?: string;
slug?: string;
description?: string;
sort_order?: number;
is_active?: boolean;
}): Promise<BoardCategory> => {
const { data } = await api.put(`/board/categories/${categoryId}`, category);
return data;
},
deleteCategory: async (categoryId: number): Promise<void> => {
await api.delete(`/board/categories/${categoryId}`);
},
// Posts
getPosts: async (params: {
page?: number;
page_size?: number;
category_id?: number;
search?: string;
}): Promise<BoardPostListResponse> => {
const { data } = await api.get('/board/posts', { params });
return data;
},
getPost: async (postId: number): Promise<BoardPost> => {
const { data } = await api.get(`/board/posts/${postId}`);
return data;
},
createPost: async (post: {
title: string;
content: string;
category_id: number;
is_notice?: boolean;
}): Promise<BoardPost> => {
const { data } = await api.post('/board/posts', post);
return data;
},
updatePost: async (postId: number, post: {
title?: string;
content?: string;
category_id?: number;
is_notice?: boolean;
is_pinned?: boolean;
is_published?: boolean;
}): Promise<BoardPost> => {
const { data } = await api.put(`/board/posts/${postId}`, post);
return data;
},
deletePost: async (postId: number): Promise<void> => {
await api.delete(`/board/posts/${postId}`);
},
// Admin
getAdminPosts: async (params: {
page?: number;
page_size?: number;
category_id?: number;
is_notice?: boolean;
is_published?: boolean;
search?: string;
}): Promise<BoardPostListResponse> => {
const { data } = await api.get('/board/admin/posts', { params });
return data;
},
toggleNotice: async (postId: number): Promise<{ is_notice: boolean; is_pinned: boolean }> => {
const { data } = await api.put(`/board/admin/posts/${postId}/toggle-notice`);
return data;
},
togglePin: async (postId: number): Promise<{ is_pinned: boolean }> => {
const { data } = await api.put(`/board/admin/posts/${postId}/toggle-pin`);
return data;
},
};
export default api; export default api;