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

655 lines
31 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 {
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>
)
}