655 lines
31 KiB
TypeScript
Executable File
655 lines
31 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 {
|
||
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 CATEGORY_COLORS: Record<string, string> = {
|
||
NOTICE: 'bg-red-500/20 text-red-400',
|
||
DATA: 'bg-green-500/20 text-green-400',
|
||
QNA: 'bg-purple-500/20 text-purple-400',
|
||
MANUAL: 'bg-blue-500/20 text-blue-400',
|
||
}
|
||
|
||
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
|
||
|
||
const catColor = (cat: string) => {
|
||
switch (cat) {
|
||
case '방제매뉴얼': return { bg: 'rgba(6,182,212,.15)', text: '#22d3ee' }
|
||
case '대응매뉴얼': return { bg: 'rgba(249,115,22,.15)', text: '#f97316' }
|
||
case '교육자료': return { bg: 'rgba(34,197,94,.15)', text: '#22c55e' }
|
||
case '법령·규정': return { bg: 'rgba(168,85,247,.15)', text: '#a855f7' }
|
||
default: return { bg: 'rgba(100,100,100,.15)', text: '#999' }
|
||
}
|
||
}
|
||
|
||
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-base">
|
||
{/* 헤더 */}
|
||
<div className="flex items-center justify-between px-8 py-4 border-b" style={{ borderColor: 'var(--stroke-default)', background: 'var(--bg-surface)' }}>
|
||
<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-fg-disabled">총 {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"
|
||
style={{
|
||
background: manualCategory === cat ? 'rgba(6,182,212,.15)' : 'var(--bg-card)',
|
||
border: manualCategory === cat ? '1px solid rgba(6,182,212,.3)' : '1px solid var(--stroke-default)',
|
||
color: manualCategory === cat ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||
}}>
|
||
{cat}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<input type="text" placeholder="매뉴얼 검색..." value={manualSearch} onChange={e => setManualSearch(e.target.value)}
|
||
className="px-4 py-2 text-sm rounded w-64" style={{ background: 'var(--bg-elevated)', border: '1px solid var(--stroke-default)', outline: 'none' }} />
|
||
<button onClick={() => setShowUploadModal(true)}
|
||
className="px-5 py-2 text-[12px] font-semibold rounded-md transition-all flex items-center gap-1.5"
|
||
style={{ background: 'rgba(6,182,212,.15)', border: '1px solid rgba(6,182,212,.3)', color: '#22d3ee', cursor: 'pointer' }}>
|
||
📤 새로 업로드
|
||
</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-fg-disabled">로딩 중...</p>
|
||
</div>
|
||
) : (
|
||
<div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))' }}>
|
||
{filteredManuals.map(file => {
|
||
const cc = catColor(file.catgNm)
|
||
return (
|
||
<div key={file.manualSn} className="rounded-xl p-4 transition-all" style={{
|
||
background: 'var(--bg-card)', border: '1px solid var(--stroke-default)',
|
||
cursor: 'pointer',
|
||
}}
|
||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = 'rgba(6,182,212,.4)' }}
|
||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--stroke-default)' }}
|
||
>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<span className="px-2 py-0.5 rounded text-[10px] font-semibold" style={{ background: cc.bg, color: cc.text }}>
|
||
{file.catgNm}
|
||
</span>
|
||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded" style={{ background: 'rgba(59,130,246,.1)', color: '#3b82f6' }}>
|
||
{file.version}
|
||
</span>
|
||
</div>
|
||
<div className="text-[12px] font-bold mb-3 leading-[1.5]">
|
||
{file.title}
|
||
</div>
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<div className="flex items-center gap-1.5 px-2 py-1 rounded" style={{ background: 'rgba(239,68,68,.08)' }}>
|
||
<span style={{ fontSize: 12 }}>📄</span>
|
||
<span className="text-[10px] font-semibold" style={{ color: '#ef4444' }}>{file.fileTp || 'PDF'}</span>
|
||
</div>
|
||
<span className="text-[10px]" style={{ color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)' }}>{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="px-2 py-0.5 rounded text-[10px] font-semibold transition-all"
|
||
style={{ background: 'rgba(59,130,246,.1)', border: '1px solid rgba(59,130,246,.2)', color: '#3b82f6', cursor: 'pointer' }}
|
||
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="px-2 py-0.5 rounded text-[10px] font-semibold transition-all"
|
||
style={{ background: 'rgba(239,68,68,.1)', border: '1px solid rgba(239,68,68,.2)', color: '#ef4444', cursor: 'pointer' }}
|
||
title="삭제">
|
||
🗑️ 삭제
|
||
</button>
|
||
</div>
|
||
<div className="flex items-center justify-between pt-3" style={{ borderTop: '1px solid var(--stroke-default)' }}>
|
||
<div className="flex items-center gap-3 text-[10px] text-fg-disabled">
|
||
<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]" style={{ color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)' }}>
|
||
⬇ {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="px-3 py-1 rounded text-[10px] font-semibold transition-all" style={{
|
||
background: 'rgba(6,182,212,.1)', border: '1px solid rgba(6,182,212,.25)',
|
||
color: '#22d3ee', cursor: 'pointer',
|
||
}}>
|
||
📥 다운로드
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</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-fg-disabled">검색 결과가 없습니다.</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-surface border border-stroke rounded-xl overflow-hidden"
|
||
onClick={e => e.stopPropagation()}>
|
||
<div className="px-5 py-4 border-b border-stroke 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-fg-disabled text-base leading-none">✕</span>
|
||
</div>
|
||
<div className="p-5 flex flex-col gap-4">
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-fg-sub mb-1.5">카테고리</label>
|
||
<div className="flex gap-1.5">
|
||
{['방제매뉴얼', '대응매뉴얼', '교육자료', '법령·규정'].map(cat => {
|
||
const cc = catColor(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"
|
||
style={{
|
||
background: isActive ? cc.bg : 'var(--bg-card)',
|
||
border: isActive ? `1px solid ${cc.text}40` : '1px solid var(--stroke-default)',
|
||
color: isActive ? cc.text : 'var(--fg-disabled)',
|
||
}}>
|
||
{cat}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-fg-sub mb-1.5">매뉴얼 제목</label>
|
||
<input type="text" placeholder="매뉴얼 제목을 입력하세요" value={uploadForm.title}
|
||
onChange={e => setUploadForm(prev => ({ ...prev, title: e.target.value }))}
|
||
className="w-full px-3 py-2.5 rounded-md text-xs bg-bg-elevated border border-stroke outline-none" style={{ boxSizing: 'border-box' }} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-fg-sub mb-1.5">버전</label>
|
||
<input type="text" placeholder="예: v1.0" value={uploadForm.version}
|
||
onChange={e => setUploadForm(prev => ({ ...prev, version: e.target.value }))}
|
||
className="w-full px-3 py-2.5 rounded-md text-xs bg-bg-elevated border border-stroke outline-none" style={{ boxSizing: 'border-box' }} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-semibold text-fg-sub mb-1.5">파일 첨부</label>
|
||
<div className="border-2 border-dashed border-stroke rounded-md py-6 px-4 text-center bg-bg-elevated 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-fg-disabled font-mono">{uploadForm.fileSize}</div>
|
||
</div>
|
||
<span onClick={(e) => { e.stopPropagation(); setUploadForm(prev => ({ ...prev, fileName: '', fileSize: '' })) }}
|
||
className="text-xs text-fg-disabled cursor-pointer ml-2">✕</span>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="text-[28px] opacity-30 mb-1.5">📁</div>
|
||
<div className="text-[11px] text-fg-disabled">클릭하여 파일을 선택하세요</div>
|
||
<div className="text-[9px] text-fg-disabled font-mono mt-1">PDF, DOC, HWP, XLSX (최대 100MB)</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="px-5 py-3 border-t border-stroke flex justify-end gap-2">
|
||
<button onClick={() => { setShowUploadModal(false); setEditingManualId(null) }}
|
||
className="px-5 py-2 rounded-md text-xs font-semibold bg-bg-card border border-stroke text-fg-disabled cursor-pointer">
|
||
취소
|
||
</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="px-6 py-2 rounded-md text-xs font-semibold cursor-pointer" style={{ background: 'rgba(6,182,212,.2)', border: '1px solid rgba(6,182,212,.35)', color: '#22d3ee' }}>
|
||
{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-base">
|
||
{/* 헤더 */}
|
||
<div className="flex items-center justify-between px-8 py-4 border-b border-stroke bg-bg-surface">
|
||
<div className="text-sm text-fg-disabled">
|
||
총 <span className="text-fg 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="px-4 py-2 text-sm bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none w-64"
|
||
/>
|
||
{hasPermission(getWriteResource(), 'CREATE') && (
|
||
<button
|
||
onClick={handleWriteClick}
|
||
className="px-6 py-2 text-sm font-semibold rounded bg-color-accent text-bg-0 hover:opacity-90 transition-opacity"
|
||
>
|
||
✏️ 글쓰기
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 테이블 */}
|
||
<div className="flex-1 overflow-auto px-8 py-6">
|
||
{isLoading ? (
|
||
<div className="text-center py-20">
|
||
<p className="text-fg-disabled text-sm">로딩 중...</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<table className="w-full border-collapse">
|
||
<thead>
|
||
<tr className="border-b-2 border-stroke">
|
||
<th className="px-4 py-3 text-center text-sm font-semibold text-fg-sub w-16">번호</th>
|
||
<th className="px-4 py-3 text-center text-sm font-semibold text-fg-sub w-24">분류</th>
|
||
<th className="px-4 py-3 text-left text-sm font-semibold text-fg-sub">제목</th>
|
||
<th className="px-4 py-3 text-center text-sm font-semibold text-fg-sub w-24">작성자</th>
|
||
<th className="px-4 py-3 text-center text-sm font-semibold text-fg-sub w-28">작성일</th>
|
||
<th className="px-4 py-3 text-center text-sm font-semibold text-fg-sub w-16">조회</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{posts.map((post) => (
|
||
<tr
|
||
key={post.sn}
|
||
className="border-b border-stroke hover:bg-bg-elevated transition-colors"
|
||
>
|
||
<td className="px-4 py-4 text-sm text-fg text-center">{post.sn}</td>
|
||
<td className="px-4 py-4 text-center">
|
||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold ${CATEGORY_COLORS[post.categoryCd] || 'bg-blue-500/20 text-blue-400'}`}>
|
||
{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}
|
||
</span>
|
||
</td>
|
||
<td
|
||
className="px-4 py-4 cursor-pointer"
|
||
onClick={() => handlePostClick(post.sn)}
|
||
>
|
||
<span className={`text-sm ${post.pinnedYn === 'Y' ? 'font-semibold text-fg' : 'text-fg'} hover:text-color-accent transition-colors`}>
|
||
{post.pinnedYn === 'Y' && '📌 '}{post.title}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-4 text-sm text-fg-sub text-center">{post.authorName}</td>
|
||
<td className="px-4 py-4 text-sm text-fg-disabled text-center">
|
||
{new Date(post.regDtm).toLocaleDateString('ko-KR')}
|
||
</td>
|
||
<td className="px-4 py-4 text-sm text-fg-disabled text-center">{post.viewCnt}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
|
||
{posts.length === 0 && (
|
||
<div className="text-center py-20">
|
||
<p className="text-fg-disabled text-sm">게시글이 없습니다.</p>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* 페이지네이션 */}
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center justify-center gap-2 px-8 py-4 border-t border-stroke bg-bg-surface">
|
||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||
<button
|
||
key={p}
|
||
onClick={() => setPage(p)}
|
||
className={`px-3 py-1.5 text-sm rounded transition-colors ${
|
||
p === page
|
||
? 'bg-color-accent/20 text-color-accent font-semibold'
|
||
: 'bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg'
|
||
}`}
|
||
>
|
||
{p}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|