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

613 lines
27 KiB
TypeScript
Executable File
Raw Blame 히스토리

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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