613 lines
27 KiB
TypeScript
Executable File
613 lines
27 KiB
TypeScript
Executable File
import { useState, useEffect, useCallback } from 'react'
|
||
import { useSubMenu } from '@common/hooks/useSubMenu'
|
||
import { useAuthStore } from '@common/store/authStore'
|
||
import { BoardWriteForm } from './BoardWriteForm'
|
||
import { BoardDetailView } from './BoardDetailView'
|
||
import Badge from '@common/components/ui/Badge'
|
||
import Card from '@common/components/ui/Card'
|
||
import {
|
||
fetchBoardPosts,
|
||
deleteBoardPost,
|
||
fetchManuals,
|
||
createManual,
|
||
updateManual,
|
||
deleteManual,
|
||
incrementManualDownload,
|
||
type BoardPostItem,
|
||
type ManualItem,
|
||
} from '../services/boardApi'
|
||
|
||
type ViewMode = 'list' | 'detail' | 'write'
|
||
|
||
// 서브탭 → DB 카테고리 코드 매핑
|
||
const SUB_TAB_TO_CATEGORY: Record<string, string | undefined> = {
|
||
all: undefined,
|
||
notice: 'NOTICE',
|
||
data: 'DATA',
|
||
qna: 'QNA',
|
||
}
|
||
|
||
// 카테고리 코드 → 표시명
|
||
const CATEGORY_LABELS: Record<string, string> = {
|
||
NOTICE: '공지사항',
|
||
DATA: '자료실',
|
||
QNA: 'Q&A',
|
||
MANUAL: '해경매뉴얼',
|
||
}
|
||
|
||
const PAGE_SIZE = 20
|
||
|
||
export function BoardView() {
|
||
const { activeSubTab } = useSubMenu('board')
|
||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||
|
||
// 목록 상태
|
||
const [posts, setPosts] = useState<BoardPostItem[]>([])
|
||
const [totalCount, setTotalCount] = useState(0)
|
||
const [page, setPage] = useState(1)
|
||
const [searchTerm, setSearchTerm] = useState('')
|
||
const [isLoading, setIsLoading] = useState(false)
|
||
|
||
// 뷰 상태
|
||
const [viewMode, setViewMode] = useState<ViewMode>('list')
|
||
const [selectedPostSn, setSelectedPostSn] = useState<number | null>(null)
|
||
const [editingPostSn, setEditingPostSn] = useState<number | null>(null)
|
||
|
||
const categoryCd = SUB_TAB_TO_CATEGORY[activeSubTab]
|
||
|
||
// 게시글 목록 조회
|
||
const loadPosts = useCallback(async () => {
|
||
setIsLoading(true)
|
||
try {
|
||
const result = await fetchBoardPosts({
|
||
categoryCd,
|
||
search: searchTerm || undefined,
|
||
page,
|
||
size: PAGE_SIZE,
|
||
})
|
||
setPosts(result.items)
|
||
setTotalCount(result.totalCount)
|
||
} catch (err) {
|
||
console.error('[board] 목록 조회 실패:', err)
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}, [categoryCd, searchTerm, page])
|
||
|
||
useEffect(() => {
|
||
if (activeSubTab !== 'manual') {
|
||
loadPosts()
|
||
}
|
||
}, [loadPosts, activeSubTab])
|
||
|
||
// 필터 변경 시 페이지 초기화
|
||
useEffect(() => {
|
||
setPage(1)
|
||
}, [activeSubTab])
|
||
|
||
// 상세 보기
|
||
const handlePostClick = (sn: number) => {
|
||
setSelectedPostSn(sn)
|
||
setViewMode('detail')
|
||
}
|
||
|
||
// 글쓰기
|
||
const handleWriteClick = () => {
|
||
setEditingPostSn(null)
|
||
setViewMode('write')
|
||
}
|
||
|
||
// 상세에서 수정
|
||
const handleEditFromDetail = (sn: number) => {
|
||
setEditingPostSn(sn)
|
||
setViewMode('write')
|
||
}
|
||
|
||
// 삭제 (목록 또는 상세에서)
|
||
const handleDelete = async (sn: number) => {
|
||
if (!window.confirm('정말로 이 게시글을 삭제하시겠습니까?')) return
|
||
try {
|
||
await deleteBoardPost(sn)
|
||
alert('게시글이 삭제되었습니다.')
|
||
setViewMode('list')
|
||
setSelectedPostSn(null)
|
||
loadPosts()
|
||
} catch (err) {
|
||
alert((err as { message?: string })?.message || '삭제에 실패했습니다.')
|
||
}
|
||
}
|
||
|
||
// 목록으로 돌아가기
|
||
const handleBackToList = () => {
|
||
setViewMode('list')
|
||
setSelectedPostSn(null)
|
||
setEditingPostSn(null)
|
||
loadPosts()
|
||
}
|
||
|
||
// 저장 완료 후 목록으로
|
||
const handleSaveComplete = () => {
|
||
setViewMode('list')
|
||
setEditingPostSn(null)
|
||
loadPosts()
|
||
}
|
||
|
||
// 검색 Enter 키
|
||
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'Enter') {
|
||
setPage(1)
|
||
loadPosts()
|
||
}
|
||
}
|
||
|
||
// 현재 서브탭 기준 글쓰기 권한 리소스
|
||
const getWriteResource = () => {
|
||
if (activeSubTab === 'all') return 'board'
|
||
return `board:${activeSubTab}`
|
||
}
|
||
|
||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
||
|
||
/* ══════════════════════════════════════════════
|
||
해경매뉴얼 탭 (mock, 향후 API 전환 예정)
|
||
══════════════════════════════════════════════ */
|
||
const [manualCategory, setManualCategory] = useState<string>('전체')
|
||
const [manualSearch, setManualSearch] = useState('')
|
||
const [manualList, setManualList] = useState<ManualItem[]>([])
|
||
const [manualLoading, setManualLoading] = useState(false)
|
||
const [showUploadModal, setShowUploadModal] = useState(false)
|
||
const [editingManualId, setEditingManualId] = useState<number | null>(null)
|
||
const [uploadForm, setUploadForm] = useState({ category: '방제매뉴얼', title: '', version: '', fileName: '', fileSize: '' })
|
||
|
||
const manualCategories = ['전체', '방제매뉴얼', '대응매뉴얼', '교육자료', '법령·규정']
|
||
|
||
const loadManuals = useCallback(async () => {
|
||
setManualLoading(true)
|
||
try {
|
||
const items = await fetchManuals({
|
||
category: manualCategory !== '전체' ? manualCategory : undefined,
|
||
search: manualSearch || undefined,
|
||
})
|
||
setManualList(items)
|
||
} catch (err) {
|
||
console.error('[board] 매뉴얼 목록 조회 실패:', err)
|
||
} finally {
|
||
setManualLoading(false)
|
||
}
|
||
}, [manualCategory, manualSearch])
|
||
|
||
useEffect(() => {
|
||
if (activeSubTab === 'manual') {
|
||
loadManuals()
|
||
}
|
||
}, [loadManuals, activeSubTab])
|
||
|
||
const filteredManuals = manualList
|
||
|
||
|
||
if (activeSubTab === 'manual') {
|
||
return (
|
||
<div className="flex flex-1 overflow-hidden">
|
||
<div className="flex-1 relative overflow-hidden">
|
||
<div className="flex flex-col h-full bg-bg-0">
|
||
{/* 헤더 */}
|
||
<div className="wing-header-bar">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-lg">📘</span>
|
||
<span className="text-[15px] font-bold">해경매뉴얼</span>
|
||
<span className="text-[10px] ml-1 text-text-3">총 {filteredManuals.length}건</span>
|
||
</div>
|
||
<div className="flex gap-1 ml-4">
|
||
{manualCategories.map(cat => (
|
||
<button key={cat} onClick={() => setManualCategory(cat)}
|
||
className={`px-3 py-1.5 text-[11px] font-semibold rounded-md transition-all border ${
|
||
manualCategory === cat
|
||
? 'bg-primary-cyan/15 border-primary-cyan/30 text-primary-cyan'
|
||
: 'bg-bg-3 border-border text-text-3'
|
||
}`}>
|
||
{cat}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<input type="text" placeholder="매뉴얼 검색..." value={manualSearch} onChange={e => setManualSearch(e.target.value)}
|
||
className="wing-input-search" />
|
||
<button onClick={() => setShowUploadModal(true)}
|
||
className="wing-btn wing-btn-primary flex items-center gap-1.5">
|
||
📤 새로 업로드
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 그리드 */}
|
||
<div className="flex-1 overflow-auto px-8 py-6">
|
||
{manualLoading ? (
|
||
<div className="text-center py-20">
|
||
<p className="text-sm text-text-3">로딩 중...</p>
|
||
</div>
|
||
) : (
|
||
<div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))' }}>
|
||
{filteredManuals.map(file => (
|
||
<Card key={file.manualSn}>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<Badge>{file.catgNm}</Badge>
|
||
<Badge>{file.version}</Badge>
|
||
</div>
|
||
<div className="text-[12px] font-bold mb-3 leading-[1.5]">
|
||
{file.title}
|
||
</div>
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<Badge>📄 {file.fileTp || 'PDF'}</Badge>
|
||
<span className="text-[10px] text-text-3" style={{ fontFamily: 'var(--fM)' }}>{file.fileSz}</span>
|
||
</div>
|
||
<div className="flex items-center justify-end gap-1 mb-2">
|
||
<button onClick={(e) => {
|
||
e.stopPropagation()
|
||
setEditingManualId(file.manualSn)
|
||
setUploadForm({
|
||
category: file.catgNm,
|
||
title: file.title,
|
||
version: file.version || '',
|
||
fileName: `${file.title}.${(file.fileTp || 'pdf').toLowerCase()}`,
|
||
fileSize: file.fileSz || '',
|
||
})
|
||
setShowUploadModal(true)
|
||
}}
|
||
className="wing-btn wing-btn-secondary text-[10px]"
|
||
title="수정">
|
||
✏️ 수정
|
||
</button>
|
||
<button onClick={async (e) => {
|
||
e.stopPropagation()
|
||
if (window.confirm(`"${file.title}" 매뉴얼을 삭제하시겠습니까?`)) {
|
||
try {
|
||
await deleteManual(file.manualSn)
|
||
loadManuals()
|
||
} catch (err) {
|
||
alert((err as { message?: string })?.message || '삭제에 실패했습니다.')
|
||
}
|
||
}
|
||
}}
|
||
className="wing-btn text-[10px] bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20"
|
||
title="삭제">
|
||
🗑️ 삭제
|
||
</button>
|
||
</div>
|
||
<div className="flex items-center justify-between pt-3 border-t border-border">
|
||
<div className="flex items-center gap-3 text-[10px] text-text-3">
|
||
<span>{file.authorNm}</span>
|
||
<span>{new Date(file.regDtm).toLocaleDateString('ko-KR')}</span>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-[10px] text-text-3" style={{ fontFamily: 'var(--fM)' }}>
|
||
⬇ {file.dwnldCnt}
|
||
</span>
|
||
<button onClick={async (e) => {
|
||
e.stopPropagation()
|
||
try {
|
||
await incrementManualDownload(file.manualSn)
|
||
setManualList(prev => prev.map(f => f.manualSn === file.manualSn ? { ...f, dwnldCnt: f.dwnldCnt + 1 } : f))
|
||
} catch { /* ignore */ }
|
||
const content = [
|
||
`═══════════════════════════════════════════`,
|
||
` ${file.title}`,
|
||
`═══════════════════════════════════════════`,
|
||
``,
|
||
` 카테고리: ${file.catgNm}`,
|
||
` 버전: ${file.version}`,
|
||
` 작성자: ${file.authorNm}`,
|
||
` 등록일: ${new Date(file.regDtm).toLocaleDateString('ko-KR')}`,
|
||
` 파일크기: ${file.fileSz}`,
|
||
``,
|
||
`───────────────────────────────────────────`,
|
||
` 본 문서는 해양경찰청 WING 시스템에서`,
|
||
` 제공하는 매뉴얼 샘플 문서입니다.`,
|
||
`───────────────────────────────────────────`,
|
||
].join('\n')
|
||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `${file.title}.txt`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
URL.revokeObjectURL(url)
|
||
}}
|
||
className="wing-btn wing-btn-primary text-[10px]">
|
||
📥 다운로드
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{!manualLoading && filteredManuals.length === 0 && (
|
||
<div className="text-center py-20">
|
||
<div style={{ fontSize: 32, opacity: 0.3, marginBottom: 8 }}>📘</div>
|
||
<p className="text-sm text-text-3">검색 결과가 없습니다.</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 업로드 모달 */}
|
||
{showUploadModal && (
|
||
<div className="fixed inset-0 z-[9999] flex items-center justify-center" style={{ background: 'rgba(0,0,0,.55)' }}
|
||
onClick={() => { setShowUploadModal(false); setEditingManualId(null) }}>
|
||
<div className="w-[480px] bg-bg-1 border border-border rounded-xl overflow-hidden"
|
||
onClick={e => e.stopPropagation()}>
|
||
<div className="px-5 py-4 border-b border-border flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-base">{editingManualId ? '✏️' : '📤'}</span>
|
||
<span className="text-sm font-bold">{editingManualId ? '매뉴얼 수정' : '매뉴얼 업로드'}</span>
|
||
</div>
|
||
<span onClick={() => { setShowUploadModal(false); setEditingManualId(null) }}
|
||
className="cursor-pointer text-text-3 text-base leading-none">✕</span>
|
||
</div>
|
||
<div className="p-5 flex flex-col gap-4">
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">카테고리</label>
|
||
<div className="flex gap-1.5">
|
||
{['방제매뉴얼', '대응매뉴얼', '교육자료', '법령·규정'].map(cat => {
|
||
const isActive = uploadForm.category === cat
|
||
return (
|
||
<button key={cat} onClick={() => setUploadForm(prev => ({ ...prev, category: cat }))}
|
||
className={`flex-1 py-2 px-1 rounded-md text-[11px] font-semibold cursor-pointer border ${
|
||
isActive
|
||
? 'bg-primary-cyan/15 border-primary-cyan/30 text-primary-cyan'
|
||
: 'bg-bg-3 border-border text-text-3'
|
||
}`}>
|
||
{cat}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">매뉴얼 제목</label>
|
||
<input type="text" placeholder="매뉴얼 제목을 입력하세요" value={uploadForm.title}
|
||
onChange={e => setUploadForm(prev => ({ ...prev, title: e.target.value }))}
|
||
className="wing-input w-full" style={{ boxSizing: 'border-box' }} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">버전</label>
|
||
<input type="text" placeholder="예: v1.0" value={uploadForm.version}
|
||
onChange={e => setUploadForm(prev => ({ ...prev, version: e.target.value }))}
|
||
className="wing-input w-full" style={{ boxSizing: 'border-box' }} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">파일 첨부</label>
|
||
<div className="border-2 border-dashed border-border rounded-md py-6 px-4 text-center bg-bg-2 cursor-pointer relative"
|
||
onClick={() => {
|
||
const input = document.createElement('input')
|
||
input.type = 'file'
|
||
input.accept = '.pdf,.doc,.docx,.hwp,.xlsx'
|
||
input.onchange = (ev) => {
|
||
const f = (ev.target as HTMLInputElement).files?.[0]
|
||
if (f) {
|
||
const sizeMB = (f.size / (1024 * 1024)).toFixed(1)
|
||
setUploadForm(prev => ({ ...prev, fileName: f.name, fileSize: `${sizeMB} MB` }))
|
||
}
|
||
}
|
||
input.click()
|
||
}}>
|
||
{uploadForm.fileName ? (
|
||
<div className="flex items-center justify-center gap-2">
|
||
<span className="text-xl">📄</span>
|
||
<div className="text-left">
|
||
<div className="text-xs font-semibold">{uploadForm.fileName}</div>
|
||
<div className="text-[10px] text-text-3 font-mono">{uploadForm.fileSize}</div>
|
||
</div>
|
||
<span onClick={(e) => { e.stopPropagation(); setUploadForm(prev => ({ ...prev, fileName: '', fileSize: '' })) }}
|
||
className="text-xs text-text-3 cursor-pointer ml-2">✕</span>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="text-[28px] opacity-30 mb-1.5">📁</div>
|
||
<div className="text-[11px] text-text-3">클릭하여 파일을 선택하세요</div>
|
||
<div className="text-[9px] text-text-3 font-mono mt-1">PDF, DOC, HWP, XLSX (최대 100MB)</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="px-5 py-3 border-t border-border flex justify-end gap-2">
|
||
<button onClick={() => { setShowUploadModal(false); setEditingManualId(null) }}
|
||
className="wing-btn wing-btn-secondary">
|
||
취소
|
||
</button>
|
||
<button onClick={async () => {
|
||
if (!uploadForm.title.trim()) { alert('제목을 입력하세요.'); return }
|
||
if (!editingManualId && !uploadForm.fileName) { alert('파일을 선택하세요.'); return }
|
||
const ext = uploadForm.fileName ? uploadForm.fileName.split('.').pop()?.toUpperCase() || 'PDF' : undefined
|
||
try {
|
||
if (editingManualId) {
|
||
await updateManual(editingManualId, {
|
||
catgNm: uploadForm.category,
|
||
title: uploadForm.title,
|
||
version: uploadForm.version || undefined,
|
||
fileTp: ext,
|
||
fileSz: uploadForm.fileSize || undefined,
|
||
})
|
||
} else {
|
||
await createManual({
|
||
catgNm: uploadForm.category,
|
||
title: uploadForm.title,
|
||
version: uploadForm.version || 'v1.0',
|
||
fileTp: ext,
|
||
fileSz: uploadForm.fileSize,
|
||
})
|
||
}
|
||
setUploadForm({ category: '방제매뉴얼', title: '', version: '', fileName: '', fileSize: '' })
|
||
setEditingManualId(null)
|
||
setShowUploadModal(false)
|
||
loadManuals()
|
||
} catch (err) {
|
||
alert((err as { message?: string })?.message || '저장에 실패했습니다.')
|
||
}
|
||
}}
|
||
className="wing-btn wing-btn-primary px-6 py-2">
|
||
{editingManualId ? '✏️ 수정' : '📤 업로드'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════
|
||
상세 보기
|
||
══════════════════════════════════════════════ */
|
||
if (viewMode === 'detail' && selectedPostSn) {
|
||
return (
|
||
<div className="flex flex-1 overflow-hidden">
|
||
<div className="flex-1 relative overflow-hidden">
|
||
<BoardDetailView
|
||
postSn={selectedPostSn}
|
||
onBack={handleBackToList}
|
||
onEdit={() => handleEditFromDetail(selectedPostSn)}
|
||
onDelete={() => handleDelete(selectedPostSn)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════
|
||
글쓰기 / 수정
|
||
══════════════════════════════════════════════ */
|
||
if (viewMode === 'write') {
|
||
return (
|
||
<div className="flex flex-1 overflow-hidden">
|
||
<div className="flex-1 relative overflow-hidden">
|
||
<BoardWriteForm
|
||
postSn={editingPostSn}
|
||
defaultCategoryCd={categoryCd || 'NOTICE'}
|
||
onSaveComplete={handleSaveComplete}
|
||
onCancel={handleBackToList}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════
|
||
목록 보기
|
||
══════════════════════════════════════════════ */
|
||
return (
|
||
<div className="flex flex-1 overflow-hidden">
|
||
<div className="flex-1 relative overflow-hidden">
|
||
<div className="flex flex-col h-full bg-bg-0">
|
||
{/* 헤더 */}
|
||
<div className="wing-header-bar">
|
||
<div className="text-sm text-text-3">
|
||
총 <span className="text-text-1 font-semibold">{totalCount}</span>건
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<input
|
||
type="text"
|
||
placeholder="검색..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
onKeyDown={handleSearchKeyDown}
|
||
className="wing-input-search"
|
||
/>
|
||
{hasPermission(getWriteResource(), 'CREATE') && (
|
||
<button
|
||
onClick={handleWriteClick}
|
||
className="wing-btn wing-btn-primary"
|
||
>
|
||
✏️ 글쓰기
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 테이블 */}
|
||
<div className="flex-1 overflow-auto px-8 py-6">
|
||
{isLoading ? (
|
||
<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 text-center w-16">번호</th>
|
||
<th className="wing-table-head text-center w-24">분류</th>
|
||
<th className="wing-table-head">제목</th>
|
||
<th className="wing-table-head text-center w-24">작성자</th>
|
||
<th className="wing-table-head text-center w-28">작성일</th>
|
||
<th className="wing-table-head text-center w-16">조회</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{posts.map((post) => (
|
||
<tr
|
||
key={post.sn}
|
||
className="wing-table-row"
|
||
>
|
||
<td className="wing-table-cell text-text-1 text-center">{post.sn}</td>
|
||
<td className="wing-table-cell text-center">
|
||
<Badge>{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}</Badge>
|
||
</td>
|
||
<td
|
||
className="wing-table-cell cursor-pointer"
|
||
onClick={() => handlePostClick(post.sn)}
|
||
>
|
||
<span className={`text-sm ${post.pinnedYn === 'Y' ? 'font-semibold text-text-1' : 'text-text-1'} hover:text-primary-cyan transition-colors`}>
|
||
{post.pinnedYn === 'Y' && '📌 '}{post.title}
|
||
</span>
|
||
</td>
|
||
<td className="wing-table-cell text-center">{post.authorName}</td>
|
||
<td className="wing-table-cell text-text-3 text-center">
|
||
{new Date(post.regDtm).toLocaleDateString('ko-KR')}
|
||
</td>
|
||
<td className="wing-table-cell text-text-3 text-center">{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>
|
||
|
||
{/* 페이지네이션 */}
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center justify-center gap-2 px-8 py-4 border-t border-border bg-bg-1">
|
||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||
<button
|
||
key={p}
|
||
onClick={() => setPage(p)}
|
||
className={`wing-btn ${
|
||
p === page
|
||
? 'wing-btn-primary font-semibold'
|
||
: 'wing-btn-secondary'
|
||
}`}
|
||
>
|
||
{p}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|