wing-ops/frontend/src/tabs/board/components/BoardListTable.tsx
htlee 2b88455a30 feat(backend): DB 통합 + 게시판 CRUD API + RBAC 적용 가이드
- 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>
2026-02-28 18:37:14 +09:00

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>
);
}