- wing + wing_auth DB를 wing 단일 DB로 통합 (wing/auth 스키마 분리) - wingPool 단일 Pool + search_path 설정, authPool 하위 호환 유지 - 게시판 BOARD_POST DDL + 초기 데이터 10건 마이그레이션 - boardService/boardRouter CRUD 구현 (페이징, 검색, 소유자 검증, 논리삭제) - requirePermission 카테고리별 서브리소스 동적 적용 (board:notice, board:qna 등) - 프론트엔드 boardApi 서비스 + BoardListTable mock→API 전환 - CRUD-API-GUIDE (범용 가이드 + 게시판 튜토리얼) 문서 작성 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
246 lines
8.9 KiB
TypeScript
Executable File
246 lines
8.9 KiB
TypeScript
Executable File
import { useState, useEffect, useCallback } from 'react';
|
|
import { useAuthStore } from '@common/store/authStore';
|
|
import { fetchBoardPosts, type BoardPostItem } from '../services/boardApi';
|
|
|
|
// 카테고리 코드 ↔ 표시명 매핑
|
|
const CATEGORY_MAP: Record<string, string> = {
|
|
NOTICE: '공지사항',
|
|
DATA: '자료실',
|
|
QNA: 'Q&A',
|
|
MANUAL: '해경매뉴얼',
|
|
};
|
|
|
|
const CATEGORY_FILTER: { label: string; code: string | null }[] = [
|
|
{ label: '전체', code: null },
|
|
{ label: '공지사항', code: 'NOTICE' },
|
|
{ label: '자료실', code: 'DATA' },
|
|
{ label: 'Q&A', code: 'QNA' },
|
|
];
|
|
|
|
const CATEGORY_STYLE: Record<string, string> = {
|
|
NOTICE: 'bg-red-500/20 text-red-400',
|
|
DATA: 'bg-blue-500/20 text-blue-400',
|
|
QNA: 'bg-green-500/20 text-green-400',
|
|
MANUAL: 'bg-yellow-500/20 text-yellow-400',
|
|
};
|
|
|
|
const PAGE_SIZE = 20;
|
|
|
|
interface BoardListTableProps {
|
|
onPostClick: (id: number) => void;
|
|
onWriteClick: () => void;
|
|
}
|
|
|
|
export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProps) {
|
|
const hasPermission = useAuthStore((s) => s.hasPermission);
|
|
|
|
const [posts, setPosts] = useState<BoardPostItem[]>([]);
|
|
const [totalCount, setTotalCount] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [searchInput, setSearchInput] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
|
|
|
|
// 카테고리별 서브리소스 권한 확인 (전체 선택 시 board CREATE)
|
|
const canWrite = selectedCategory
|
|
? hasPermission(`board:${selectedCategory.toLowerCase()}`, 'CREATE')
|
|
: hasPermission('board', 'CREATE');
|
|
|
|
const loadPosts = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const result = await fetchBoardPosts({
|
|
categoryCd: selectedCategory || undefined,
|
|
search: searchTerm || undefined,
|
|
page,
|
|
size: PAGE_SIZE,
|
|
});
|
|
setPosts(result.items);
|
|
setTotalCount(result.totalCount);
|
|
} catch {
|
|
setPosts([]);
|
|
setTotalCount(0);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [selectedCategory, searchTerm, page]);
|
|
|
|
useEffect(() => {
|
|
loadPosts();
|
|
}, [loadPosts]);
|
|
|
|
const handleCategoryChange = (code: string | null) => {
|
|
setSelectedCategory(code);
|
|
setPage(1);
|
|
};
|
|
|
|
const handleSearch = () => {
|
|
setSearchTerm(searchInput);
|
|
setPage(1);
|
|
};
|
|
|
|
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter') handleSearch();
|
|
};
|
|
|
|
const formatDate = (dtm: string) => {
|
|
return new Date(dtm).toLocaleDateString('ko-KR', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-bg-0">
|
|
{/* Header with Search and Write Button */}
|
|
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex gap-2">
|
|
{CATEGORY_FILTER.map((cat) => (
|
|
<button
|
|
key={cat.label}
|
|
onClick={() => handleCategoryChange(cat.code)}
|
|
className={`px-4 py-2 text-sm font-semibold rounded transition-all ${
|
|
selectedCategory === cat.code
|
|
? 'bg-primary-cyan text-bg-0'
|
|
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1'
|
|
}`}
|
|
>
|
|
{cat.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="text"
|
|
placeholder="제목, 작성자 검색..."
|
|
value={searchInput}
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
onKeyDown={handleSearchKeyDown}
|
|
className="px-4 py-2 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none w-64"
|
|
/>
|
|
|
|
{canWrite && (
|
|
<button
|
|
onClick={onWriteClick}
|
|
className="px-6 py-2 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity flex items-center gap-2"
|
|
>
|
|
<span>+</span>
|
|
<span>글쓰기</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Board List Table */}
|
|
<div className="flex-1 overflow-auto px-8 py-6">
|
|
{loading ? (
|
|
<div className="text-center py-20">
|
|
<p className="text-text-3 text-sm">불러오는 중...</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<table className="w-full border-collapse">
|
|
<thead>
|
|
<tr className="border-b-2 border-border">
|
|
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-20">번호</th>
|
|
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">분류</th>
|
|
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2">제목</th>
|
|
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">작성자</th>
|
|
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">작성일</th>
|
|
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-24">조회수</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{posts.map((post) => (
|
|
<tr
|
|
key={post.sn}
|
|
onClick={() => onPostClick(post.sn)}
|
|
className="border-b border-border hover:bg-bg-2 cursor-pointer transition-colors"
|
|
>
|
|
<td className="px-4 py-4 text-sm text-text-1">
|
|
{post.pinnedYn === 'Y' ? (
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-500/20 text-red-400">
|
|
공지
|
|
</span>
|
|
) : (
|
|
post.sn
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-4">
|
|
<span
|
|
className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${
|
|
CATEGORY_STYLE[post.categoryCd] || 'bg-gray-500/20 text-gray-400'
|
|
}`}
|
|
>
|
|
{CATEGORY_MAP[post.categoryCd] || post.categoryCd}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-4">
|
|
<span
|
|
className={`text-sm ${
|
|
post.pinnedYn === 'Y' ? 'font-semibold text-text-1' : 'text-text-1'
|
|
} hover:text-primary-cyan transition-colors`}
|
|
>
|
|
{post.title}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-4 text-sm text-text-2">{post.authorName}</td>
|
|
<td className="px-4 py-4 text-sm text-text-3">{formatDate(post.regDtm)}</td>
|
|
<td className="px-4 py-4 text-sm text-text-3">{post.viewCnt}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
{posts.length === 0 && (
|
|
<div className="text-center py-20">
|
|
<p className="text-text-3 text-sm">검색 결과가 없습니다.</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-center gap-2 px-8 py-4 border-t border-border bg-bg-1">
|
|
<button
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
disabled={page <= 1}
|
|
className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors disabled:opacity-40"
|
|
>
|
|
이전
|
|
</button>
|
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
|
<button
|
|
key={p}
|
|
onClick={() => setPage(p)}
|
|
className={`px-3 py-1.5 text-sm rounded ${
|
|
page === p
|
|
? 'bg-primary-cyan text-bg-0 font-semibold'
|
|
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors'
|
|
}`}
|
|
>
|
|
{p}
|
|
</button>
|
|
))}
|
|
<button
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
disabled={page >= totalPages}
|
|
className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors disabled:opacity-40"
|
|
>
|
|
다음
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|