wing-ops/frontend/src/tabs/board/components/BoardListTable.tsx

232 lines
7.5 KiB
TypeScript
Executable File

import { useState, useEffect, useCallback } from 'react';
import { useAuthStore } from '@common/store/authStore';
import { fetchBoardPosts, type BoardPostItem } from '../services/boardApi';
import Badge from '@common/components/ui/Badge';
// 카테고리 코드 ↔ 표시명 매핑
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 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="wing-header-bar">
<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="wing-input-search"
/>
{canWrite && (
<button
onClick={onWriteClick}
className="wing-btn wing-btn-primary 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="wing-table">
<thead>
<tr className="border-b-2 border-border">
<th className="wing-table-head w-20"></th>
<th className="wing-table-head w-32"></th>
<th className="wing-table-head"></th>
<th className="wing-table-head w-32"></th>
<th className="wing-table-head w-32"></th>
<th className="wing-table-head w-24"></th>
</tr>
</thead>
<tbody>
{posts.map((post) => (
<tr
key={post.sn}
onClick={() => onPostClick(post.sn)}
className="wing-table-row"
>
<td className="wing-table-cell text-text-1">
{post.pinnedYn === 'Y' ? (
<Badge color="cyan"></Badge>
) : (
post.sn
)}
</td>
<td className="wing-table-cell">
<Badge>{CATEGORY_MAP[post.categoryCd] || post.categoryCd}</Badge>
</td>
<td className="wing-table-cell">
<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="wing-table-cell">{post.authorName}</td>
<td className="wing-table-cell text-text-3">{formatDate(post.regDtm)}</td>
<td className="wing-table-cell 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="wing-btn wing-btn-secondary disabled:opacity-40"
>
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<button
key={p}
onClick={() => setPage(p)}
className={`wing-btn ${
page === p
? 'wing-btn-primary font-semibold'
: 'wing-btn-secondary'
}`}
>
{p}
</button>
))}
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="wing-btn wing-btn-secondary disabled:opacity-40"
>
</button>
</div>
)}
</div>
);
}