fix(frontend): 게시판 CRUD mock 제거 → 실제 API 연동
- BoardView.tsx: initialPosts mock 제거, fetchBoardPosts API로 전환 - 서브탭별 카테고리 필터링 (NOTICE/DATA/QNA) - 실제 페이지네이션 (totalCount 기반) - hasPermission 기반 글쓰기 버튼 조건부 노출 - BoardWriteForm.tsx: createBoardPost/updateBoardPost API 직접 호출 - 카테고리 코드 DB 규격 (NOTICE/DATA/QNA) 사용 - 작성자 입력 필드 제거 (JWT 인증 사용자 자동 설정) - BoardDetailView.tsx: fetchBoardPost API로 상세 조회 - 본인 게시글만 수정/삭제 버튼 노출 (authorId 비교) - 댓글 mock 제거, 향후 구현 예정 안내 표시 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
79124ad002
커밋
6bdea97b49
@ -1,33 +1,69 @@
|
||||
interface BoardPost {
|
||||
id: number
|
||||
category: string
|
||||
title: string
|
||||
author: string
|
||||
organization: string
|
||||
date: string
|
||||
views: number
|
||||
content: string
|
||||
hasAttachment?: boolean
|
||||
isNotice?: boolean
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuthStore } from '@common/store/authStore'
|
||||
import { fetchBoardPost, type BoardPostDetail } from '../services/boardApi'
|
||||
|
||||
// 카테고리 코드 → 표시명
|
||||
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',
|
||||
}
|
||||
|
||||
interface BoardDetailViewProps {
|
||||
post: BoardPost
|
||||
postSn: number
|
||||
onBack: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
export function BoardDetailView({ post, onBack, onEdit, onDelete }: BoardDetailViewProps) {
|
||||
const handleDelete = () => {
|
||||
if (window.confirm('정말로 이 게시글을 삭제하시겠습니까?')) {
|
||||
onDelete()
|
||||
export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetailViewProps) {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const [post, setPost] = useState<BoardPostDetail | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const load = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await fetchBoardPost(postSn)
|
||||
if (!cancelled) setPost(data)
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
alert('게시글을 불러오는데 실패했습니다.')
|
||||
onBack()
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
return () => { cancelled = true }
|
||||
}, [postSn, onBack])
|
||||
|
||||
if (isLoading || !post) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-0 items-center justify-center">
|
||||
<p className="text-text-3 text-sm">게시글을 불러오는 중...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 본인 게시글 여부
|
||||
const isAuthor = user?.id === post.authorId
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-0">
|
||||
{/* Header */}
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
|
||||
<button
|
||||
onClick={onBack}
|
||||
@ -36,123 +72,68 @@ export function BoardDetailView({ post, onBack, onEdit, onDelete }: BoardDetailV
|
||||
<span>←</span>
|
||||
<span>목록으로</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="px-4 py-2 text-sm font-semibold rounded bg-bg-2 text-text-1 border border-border hover:bg-bg-3 transition-colors"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-4 py-2 text-sm font-semibold rounded bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30 transition-colors"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
{isAuthor && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="px-4 py-2 text-sm font-semibold rounded bg-bg-2 text-text-1 border border-border hover:bg-bg-3 transition-colors"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="px-4 py-2 text-sm font-semibold rounded bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30 transition-colors"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Post Content */}
|
||||
{/* 게시글 내용 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="max-w-4xl mx-auto px-8 py-8">
|
||||
{/* Post Header */}
|
||||
{/* 게시글 헤더 */}
|
||||
<div className="pb-6 border-b border-border">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${
|
||||
post.category === '공지'
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: post.category === '자료'
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: post.category === 'Q&A'
|
||||
? 'bg-purple-500/20 text-purple-400'
|
||||
: 'bg-blue-500/20 text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{post.category}
|
||||
<span className={`inline-flex items-center px-2.5 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>
|
||||
{post.isNotice && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold bg-red-500/20 text-red-400">
|
||||
공지
|
||||
{post.pinnedYn === 'Y' && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold bg-yellow-500/20 text-yellow-400">
|
||||
📌 고정
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-text-1 mb-4">{post.title}</h1>
|
||||
<div className="flex items-center gap-4 text-sm text-text-3">
|
||||
<span>작성자: <span className="text-text-2 font-semibold">{post.author}</span></span>
|
||||
<span>작성자: <span className="text-text-2 font-semibold">{post.authorName}</span></span>
|
||||
<span>|</span>
|
||||
<span>소속: <span className="text-text-2 font-semibold">{post.organization}</span></span>
|
||||
<span>작성일: {new Date(post.regDtm).toLocaleDateString('ko-KR')}</span>
|
||||
<span>|</span>
|
||||
<span>작성일: {post.date}</span>
|
||||
<span>|</span>
|
||||
<span>조회수: {post.views}</span>
|
||||
<span>조회수: {post.viewCnt}</span>
|
||||
{post.mdfcnDtm && (
|
||||
<>
|
||||
<span>|</span>
|
||||
<span>수정일: {new Date(post.mdfcnDtm).toLocaleDateString('ko-KR')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Post Content */}
|
||||
{/* 본문 */}
|
||||
<div className="py-8">
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<div className="text-text-1 text-[15px] leading-relaxed whitespace-pre-wrap">
|
||||
{post.content}
|
||||
{post.content || '(내용 없음)'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attachments (if any) */}
|
||||
{/* 댓글 섹션 (향후 구현 예정) */}
|
||||
<div className="py-6 border-t border-border">
|
||||
<h3 className="text-sm font-semibold text-text-2 mb-3">첨부파일</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 p-3 bg-bg-2 border border-border rounded hover:bg-bg-3 cursor-pointer transition-colors">
|
||||
<span className="text-text-3">📎</span>
|
||||
<span className="text-sm text-text-1">방제_매뉴얼_2025.pdf</span>
|
||||
<span className="text-xs text-text-3 ml-auto">(2.3 MB)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments Section */}
|
||||
<div className="py-6 border-t border-border">
|
||||
<h3 className="text-base font-semibold text-text-1 mb-4">댓글 (3)</h3>
|
||||
|
||||
{/* Comment Input */}
|
||||
<div className="mb-6">
|
||||
<textarea
|
||||
placeholder="댓글을 입력하세요..."
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none resize-none"
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button className="px-4 py-2 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity">
|
||||
댓글 작성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments List */}
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-bg-2 border border-border rounded">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-semibold text-text-1">홍길동</span>
|
||||
<span className="text-xs text-text-3">2025-02-16 10:30</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-2">유용한 자료 감사합니다!</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-bg-2 border border-border rounded">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-semibold text-text-1">김영수</span>
|
||||
<span className="text-xs text-text-3">2025-02-15 14:20</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-2">질문이 있는데 연락 가능할까요?</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-bg-2 border border-border rounded">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-semibold text-text-1">이민지</span>
|
||||
<span className="text-xs text-text-3">2025-02-15 09:15</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-2">잘 봤습니다. 도움이 많이 되었어요.</p>
|
||||
</div>
|
||||
<div className="text-center py-8">
|
||||
<p className="text-text-3 text-sm">댓글 기능은 향후 업데이트 예정입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,24 +1,43 @@
|
||||
import { useState } from 'react'
|
||||
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,
|
||||
type BoardPostItem,
|
||||
} from '../services/boardApi'
|
||||
|
||||
interface BoardPost {
|
||||
id: number
|
||||
category: string
|
||||
title: string
|
||||
author: string
|
||||
organization: string
|
||||
date: string
|
||||
views: number
|
||||
content: string
|
||||
hasAttachment?: boolean
|
||||
isNotice?: boolean
|
||||
type ViewMode = 'list' | 'detail' | 'write'
|
||||
|
||||
// 서브탭 → DB 카테고리 코드 매핑
|
||||
const SUB_TAB_TO_CATEGORY: Record<string, string | undefined> = {
|
||||
all: undefined,
|
||||
notice: 'NOTICE',
|
||||
data: 'DATA',
|
||||
qna: 'QNA',
|
||||
}
|
||||
|
||||
type ViewMode = 'list' | 'detail' | 'write' | 'manual'
|
||||
// 카테고리 코드 → 표시명
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
NOTICE: '공지사항',
|
||||
DATA: '자료실',
|
||||
QNA: 'Q&A',
|
||||
MANUAL: '해경매뉴얼',
|
||||
}
|
||||
|
||||
/* ── 해경매뉴얼 Mock 데이터 ── */
|
||||
// 카테고리별 배지 색상
|
||||
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
|
||||
|
||||
/* ── 해경매뉴얼 Mock 데이터 (향후 API 전환 예정) ── */
|
||||
interface ManualFile {
|
||||
id: number
|
||||
category: string
|
||||
@ -46,245 +65,120 @@ const manualFiles: ManualFile[] = [
|
||||
{ id: 12, category: '법령·규정', title: '방제선·방제정 운용 및 관리 규정', version: '2026', fileType: 'PDF', fileSize: '7.2 MB', uploadDate: '2026-01-05', author: '장비관리과', downloads: 478 },
|
||||
]
|
||||
|
||||
// Mock data for posts
|
||||
const initialPosts: BoardPost[] = [
|
||||
{
|
||||
id: 10,
|
||||
category: '공지',
|
||||
title: '⭐2026년 해양오염 방제 워크샵 개최 안내',
|
||||
author: '방제과',
|
||||
organization: '본청',
|
||||
date: '2026-02-16',
|
||||
views: 342,
|
||||
content: `2026년 해양오염 방제 워크샵을 개최합니다.`,
|
||||
hasAttachment: true,
|
||||
isNotice: true
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
category: '공지',
|
||||
title: '⭐WING 시스템 업데이트 v2.5 패치 안내',
|
||||
author: '관리팀',
|
||||
organization: '시스템',
|
||||
date: '2026-02-08',
|
||||
views: 256,
|
||||
content: `WING 시스템 v2.5 업데이트 안내`,
|
||||
hasAttachment: true,
|
||||
isNotice: true
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
category: '일반',
|
||||
title: '울릉도 방문객 관광 명소 보고',
|
||||
author: '방제과',
|
||||
organization: '동해청',
|
||||
date: '2026-02-03',
|
||||
views: 128,
|
||||
content: `울릉도 방문 보고서`,
|
||||
hasAttachment: true
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
category: '자료',
|
||||
title: '2025년 해양오염 사고 통계 분석대책',
|
||||
author: '남해청',
|
||||
organization: '남해청',
|
||||
date: '2026-02-03',
|
||||
views: 215,
|
||||
content: `2025년 통계 분석 자료`,
|
||||
hasAttachment: true
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
category: 'Q&A',
|
||||
title: 'OpenDrift 모델 파라미터 설정 관련 문의',
|
||||
author: '서해청',
|
||||
organization: '군산서',
|
||||
date: '2026-01-30',
|
||||
views: 87,
|
||||
content: `OpenDrift 모델 파라미터 문의`,
|
||||
hasAttachment: false
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
category: '자료',
|
||||
title: 'Pre-SCAT 조사 양식 및 작성 가이드 v3.0',
|
||||
author: '본청',
|
||||
organization: '방제과',
|
||||
date: '2026-01-28',
|
||||
views: 198,
|
||||
content: `Pre-SCAT 조사 가이드`,
|
||||
hasAttachment: true
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
category: '일반',
|
||||
title: '여수 유류유출 사고 대응 공유보고',
|
||||
author: '남해청',
|
||||
organization: '여수서',
|
||||
date: '2026-01-25',
|
||||
views: 312,
|
||||
content: `여수 사고 대응 보고`,
|
||||
hasAttachment: true
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
category: 'Q&A',
|
||||
title: '항만자치 역할 리포트 오류 해결 방법',
|
||||
author: '중부청',
|
||||
organization: '인천서',
|
||||
date: '2026-01-22',
|
||||
views: 64,
|
||||
content: `리포트 오류 해결`,
|
||||
hasAttachment: false
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category: '자료',
|
||||
title: 'AEGL 기준 나라별 데이터 가이드라인',
|
||||
author: '본청',
|
||||
organization: '방제과',
|
||||
date: '2026-01-18',
|
||||
views: 176,
|
||||
content: `AEGL 가이드라인`,
|
||||
hasAttachment: true
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
category: '일반',
|
||||
title: '제주 해역 동절기 해류 관측 데이터 공유',
|
||||
author: '제주청',
|
||||
organization: '제주서',
|
||||
date: '2026-01-15',
|
||||
views: 143,
|
||||
content: `해류 관측 데이터`,
|
||||
hasAttachment: true
|
||||
}
|
||||
]
|
||||
|
||||
export function BoardView() {
|
||||
const { activeSubTab } = useSubMenu('board')
|
||||
const [posts, setPosts] = useState<BoardPost[]>(initialPosts)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list')
|
||||
const [selectedPostId, setSelectedPostId] = useState<number | null>(null)
|
||||
const [editingPost, setEditingPost] = useState<BoardPost | null>(null)
|
||||
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 selectedPost = posts.find((p) => p.id === selectedPostId)
|
||||
// 뷰 상태
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list')
|
||||
const [selectedPostSn, setSelectedPostSn] = useState<number | null>(null)
|
||||
const [editingPostSn, setEditingPostSn] = useState<number | null>(null)
|
||||
|
||||
// Get category filter based on active sub tab
|
||||
const getCategoryFilter = (subTab: string): string | null => {
|
||||
switch (subTab) {
|
||||
case 'all':
|
||||
return null // Show all
|
||||
case 'notice':
|
||||
return '공지'
|
||||
case 'data':
|
||||
return '자료'
|
||||
case 'qna':
|
||||
return 'Q&A'
|
||||
default:
|
||||
return 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])
|
||||
|
||||
const categoryFilter = getCategoryFilter(activeSubTab)
|
||||
useEffect(() => {
|
||||
if (activeSubTab !== 'manual') {
|
||||
loadPosts()
|
||||
}
|
||||
}, [loadPosts, activeSubTab])
|
||||
|
||||
// Filter posts by current category and search term
|
||||
const filteredPosts = posts.filter((post) => {
|
||||
const matchesCategory = !categoryFilter || post.category === categoryFilter
|
||||
const matchesSearch =
|
||||
post.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
post.author.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
post.organization.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
return matchesCategory && matchesSearch
|
||||
})
|
||||
// 필터 변경 시 페이지 초기화
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [activeSubTab])
|
||||
|
||||
// Handle post click to view detail
|
||||
const handlePostClick = (id: number) => {
|
||||
setSelectedPostId(id)
|
||||
// 상세 보기
|
||||
const handlePostClick = (sn: number) => {
|
||||
setSelectedPostSn(sn)
|
||||
setViewMode('detail')
|
||||
// Increment view count
|
||||
setPosts(posts.map((p) => (p.id === id ? { ...p, views: p.views + 1 } : p)))
|
||||
}
|
||||
|
||||
// Handle write button click
|
||||
// 글쓰기
|
||||
const handleWriteClick = () => {
|
||||
setEditingPostSn(null)
|
||||
setViewMode('write')
|
||||
setEditingPost(null)
|
||||
}
|
||||
|
||||
// Handle edit button click
|
||||
const handleEditClick = () => {
|
||||
if (selectedPost) {
|
||||
setEditingPost(selectedPost)
|
||||
setViewMode('write')
|
||||
}
|
||||
// 상세에서 수정
|
||||
const handleEditFromDetail = (sn: number) => {
|
||||
setEditingPostSn(sn)
|
||||
setViewMode('write')
|
||||
}
|
||||
|
||||
// Handle delete button click (from table)
|
||||
const handleDeleteClick = (id: number) => {
|
||||
if (window.confirm('정말로 이 게시글을 삭제하시겠습니까?')) {
|
||||
setPosts(posts.filter((p) => p.id !== id))
|
||||
// 삭제 (목록 또는 상세에서)
|
||||
const handleDelete = async (sn: number) => {
|
||||
if (!window.confirm('정말로 이 게시글을 삭제하시겠습니까?')) return
|
||||
try {
|
||||
await deleteBoardPost(sn)
|
||||
alert('게시글이 삭제되었습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle delete from detail view
|
||||
const handleDeleteFromDetail = () => {
|
||||
if (selectedPostId) {
|
||||
setPosts(posts.filter((p) => p.id !== selectedPostId))
|
||||
setViewMode('list')
|
||||
setSelectedPostId(null)
|
||||
alert('게시글이 삭제되었습니다.')
|
||||
setSelectedPostSn(null)
|
||||
loadPosts()
|
||||
} catch (err) {
|
||||
alert((err as { message?: string })?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle back to list
|
||||
// 목록으로 돌아가기
|
||||
const handleBackToList = () => {
|
||||
setViewMode('list')
|
||||
setSelectedPostId(null)
|
||||
setSelectedPostSn(null)
|
||||
setEditingPostSn(null)
|
||||
loadPosts()
|
||||
}
|
||||
|
||||
// Handle save post (create or update)
|
||||
const handleSavePost = (postData: Omit<BoardPost, 'id'>) => {
|
||||
if (editingPost) {
|
||||
// Update existing post
|
||||
setPosts(
|
||||
posts.map((p) =>
|
||||
p.id === editingPost.id
|
||||
? {
|
||||
...p,
|
||||
...postData,
|
||||
date: new Date().toISOString().split('T')[0]
|
||||
}
|
||||
: p
|
||||
)
|
||||
)
|
||||
alert('게시글이 수정되었습니다.')
|
||||
} else {
|
||||
// Create new post
|
||||
const newPost: BoardPost = {
|
||||
...postData,
|
||||
id: Math.max(...posts.map((p) => p.id)) + 1,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
views: 0
|
||||
}
|
||||
setPosts([newPost, ...posts])
|
||||
alert('게시글이 등록되었습니다.')
|
||||
// 저장 완료 후 목록으로
|
||||
const handleSaveComplete = () => {
|
||||
setViewMode('list')
|
||||
setEditingPostSn(null)
|
||||
loadPosts()
|
||||
}
|
||||
|
||||
// 검색 Enter 키
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
setPage(1)
|
||||
loadPosts()
|
||||
}
|
||||
|
||||
setEditingPost(null)
|
||||
setViewMode('list')
|
||||
}
|
||||
|
||||
// Handle cancel
|
||||
const handleCancel = () => {
|
||||
setEditingPost(null)
|
||||
setViewMode('list')
|
||||
// 현재 서브탭 기준 글쓰기 권한 리소스
|
||||
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<ManualFile[]>(manualFiles)
|
||||
@ -309,7 +203,6 @@ export function BoardView() {
|
||||
}
|
||||
}
|
||||
|
||||
// 해경매뉴얼 탭이면 매뉴얼 뷰 표시
|
||||
if (activeSubTab === 'manual') {
|
||||
return (
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
@ -362,7 +255,6 @@ export function BoardView() {
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = 'rgba(6,182,212,.4)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--bd)' }}
|
||||
>
|
||||
{/* 카테고리 + 버전 */}
|
||||
<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.category}
|
||||
@ -371,13 +263,9 @@ export function BoardView() {
|
||||
{file.version}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<div className="text-[12px] font-bold mb-3 leading-[1.5]" style={{ color: 'var(--t1)', fontFamily: 'var(--fK)' }}>
|
||||
{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>
|
||||
@ -385,8 +273,6 @@ export function BoardView() {
|
||||
</div>
|
||||
<span className="text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{file.fileSize}</span>
|
||||
</div>
|
||||
|
||||
{/* 관리 버튼 (수정/삭제) */}
|
||||
<div className="flex items-center justify-end gap-1 mb-2">
|
||||
<button onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
@ -417,8 +303,6 @@ export function BoardView() {
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 메타 정보 */}
|
||||
<div className="flex items-center justify-between pt-3" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<div className="flex items-center gap-3 text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fK)' }}>
|
||||
<span>{file.author}</span>
|
||||
@ -430,9 +314,7 @@ export function BoardView() {
|
||||
</span>
|
||||
<button onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// 다운로드 수 증가
|
||||
setManualList(prev => prev.map(f => f.id === file.id ? { ...f, downloads: f.downloads + 1 } : f))
|
||||
// 파일 다운로드 생성
|
||||
const content = [
|
||||
`═══════════════════════════════════════════`,
|
||||
` ${file.title}`,
|
||||
@ -481,13 +363,12 @@ export function BoardView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 업로드 모달 ── */}
|
||||
{/* 업로드 모달 */}
|
||||
{showUploadModal && (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,.55)' }}
|
||||
onClick={() => { setShowUploadModal(false); setEditingManualId(null) }}>
|
||||
<div style={{ width: 480, background: 'var(--bg1)', border: '1px solid var(--bd)', borderRadius: 12, overflow: 'hidden' }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
{/* 모달 헤더 */}
|
||||
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--bd)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 16 }}>{editingManualId ? '✏️' : '📤'}</span>
|
||||
@ -496,10 +377,7 @@ export function BoardView() {
|
||||
<span onClick={() => { setShowUploadModal(false); setEditingManualId(null) }}
|
||||
style={{ cursor: 'pointer', color: 'var(--t3)', fontSize: 16, lineHeight: 1 }}>✕</span>
|
||||
</div>
|
||||
|
||||
{/* 모달 바디 */}
|
||||
<div style={{ padding: 20, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* 카테고리 선택 */}
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 6 }}>카테고리</label>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
@ -521,24 +399,18 @@ export function BoardView() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 6 }}>매뉴얼 제목</label>
|
||||
<input type="text" placeholder="매뉴얼 제목을 입력하세요" value={uploadForm.title}
|
||||
onChange={e => setUploadForm(prev => ({ ...prev, title: e.target.value }))}
|
||||
style={{ width: '100%', padding: '10px 12px', borderRadius: 6, fontSize: 12, background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', fontFamily: 'var(--fK)', outline: 'none', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
|
||||
{/* 버전 */}
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 6 }}>버전</label>
|
||||
<input type="text" placeholder="예: v1.0" value={uploadForm.version}
|
||||
onChange={e => setUploadForm(prev => ({ ...prev, version: e.target.value }))}
|
||||
style={{ width: '100%', padding: '10px 12px', borderRadius: 6, fontSize: 12, background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', fontFamily: 'var(--fK)', outline: 'none', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
|
||||
{/* 파일 첨부 영역 */}
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 6 }}>파일 첨부</label>
|
||||
<div style={{
|
||||
@ -553,10 +425,7 @@ export function BoardView() {
|
||||
const file = (ev.target as HTMLInputElement).files?.[0]
|
||||
if (file) {
|
||||
const sizeMB = (file.size / (1024 * 1024)).toFixed(1)
|
||||
const ext = file.name.split('.').pop()?.toUpperCase() || 'FILE'
|
||||
setUploadForm(prev => ({ ...prev, fileName: file.name, fileSize: `${sizeMB} MB` }))
|
||||
// fileType will be derived from extension
|
||||
void ext
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
@ -581,8 +450,6 @@ export function BoardView() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--bd)', display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={() => { setShowUploadModal(false); setEditingManualId(null) }}
|
||||
style={{ padding: '8px 20px', borderRadius: 6, fontSize: 12, fontWeight: 600, background: 'var(--bg3)', border: '1px solid var(--bd)', color: 'var(--t3)', fontFamily: 'var(--fK)', cursor: 'pointer' }}>
|
||||
@ -592,9 +459,7 @@ export function BoardView() {
|
||||
if (!uploadForm.title.trim()) { alert('제목을 입력하세요.'); return }
|
||||
if (!uploadForm.fileName) { alert('파일을 선택하세요.'); return }
|
||||
const ext = uploadForm.fileName.split('.').pop()?.toUpperCase() || 'PDF'
|
||||
|
||||
if (editingManualId) {
|
||||
// 수정 모드
|
||||
setManualList(prev => prev.map(f => f.id === editingManualId ? {
|
||||
...f,
|
||||
category: uploadForm.category,
|
||||
@ -605,7 +470,6 @@ export function BoardView() {
|
||||
uploadDate: new Date().toISOString().split('T')[0],
|
||||
} : f))
|
||||
} else {
|
||||
// 새로 업로드
|
||||
const newFile: ManualFile = {
|
||||
id: Math.max(...manualList.map(f => f.id)) + 1,
|
||||
category: uploadForm.category,
|
||||
@ -619,7 +483,6 @@ export function BoardView() {
|
||||
}
|
||||
setManualList(prev => [newFile, ...prev])
|
||||
}
|
||||
|
||||
setUploadForm({ category: '방제매뉴얼', title: '', version: '', fileName: '', fileSize: '' })
|
||||
setEditingManualId(null)
|
||||
setShowUploadModal(false)
|
||||
@ -631,163 +494,156 @@ export function BoardView() {
|
||||
</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">
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
{viewMode === 'list' ? (
|
||||
<div className="flex flex-col h-full bg-bg-0">
|
||||
{/* Header with Search and Write Button */}
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Search Input */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="px-4 py-2 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none w-64"
|
||||
/>
|
||||
|
||||
{/* Write Button */}
|
||||
<div className="flex flex-col h-full bg-bg-0">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
|
||||
<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="px-4 py-2 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none w-64"
|
||||
/>
|
||||
{hasPermission(getWriteResource(), 'CREATE') && (
|
||||
<button
|
||||
onClick={handleWriteClick}
|
||||
className="px-6 py-2 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
✏️ 글쓰기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Board List Table */}
|
||||
<div className="flex-1 overflow-auto px-8 py-6">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-border">
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-16">
|
||||
번호
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-24">
|
||||
분류
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2">제목</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-24">
|
||||
소속
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-24">
|
||||
작성자
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-28">
|
||||
작성일
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-16">
|
||||
조회
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-16">
|
||||
첨부
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-16">
|
||||
관리
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredPosts.map((post) => (
|
||||
<tr
|
||||
key={post.id}
|
||||
className="border-b border-border hover:bg-bg-2 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-4 text-sm text-text-1 text-center">{post.id}</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 ${
|
||||
post.category === '공지'
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: post.category === '자료'
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: post.category === 'Q&A'
|
||||
? 'bg-purple-500/20 text-purple-400'
|
||||
: 'bg-blue-500/20 text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{post.category}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className="px-4 py-4 cursor-pointer"
|
||||
onClick={() => handlePostClick(post.id)}
|
||||
>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
post.isNotice ? 'font-semibold text-text-1' : 'text-text-1'
|
||||
} hover:text-primary-cyan transition-colors`}
|
||||
>
|
||||
{post.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-text-2 text-center">
|
||||
{post.organization}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-text-2 text-center">{post.author}</td>
|
||||
<td className="px-4 py-4 text-sm text-text-3 text-center">{post.date}</td>
|
||||
<td className="px-4 py-4 text-sm text-text-3 text-center">{post.views}</td>
|
||||
<td className="px-4 py-4 text-center">
|
||||
{post.hasAttachment && (
|
||||
<span className="text-text-3 text-sm">📎</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-center">
|
||||
<button
|
||||
onClick={() => handleDeleteClick(post.id)}
|
||||
className="text-text-3 hover:text-red-400 transition-colors"
|
||||
title="삭제"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{filteredPosts.length === 0 && (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-text-3 text-sm">게시글이 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-center gap-2 px-8 py-4 border-t border-border bg-bg-1">
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors">
|
||||
1
|
||||
</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors">
|
||||
2
|
||||
</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors">
|
||||
3
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : viewMode === 'detail' && selectedPost ? (
|
||||
<BoardDetailView
|
||||
post={selectedPost}
|
||||
onBack={handleBackToList}
|
||||
onEdit={handleEditClick}
|
||||
onDelete={handleDeleteFromDetail}
|
||||
/>
|
||||
) : viewMode === 'write' ? (
|
||||
<BoardWriteForm post={editingPost} onSave={handleSavePost} onCancel={handleCancel} />
|
||||
) : null}
|
||||
|
||||
{/* 테이블 */}
|
||||
<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="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-border">
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-16">번호</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-24">분류</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2">제목</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-24">작성자</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-28">작성일</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-16">조회</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{posts.map((post) => (
|
||||
<tr
|
||||
key={post.sn}
|
||||
className="border-b border-border hover:bg-bg-2 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-4 text-sm text-text-1 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-text-1' : 'text-text-1'} hover:text-primary-cyan transition-colors`}>
|
||||
{post.pinnedYn === 'Y' && '📌 '}{post.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-text-2 text-center">{post.authorName}</td>
|
||||
<td className="px-4 py-4 text-sm text-text-3 text-center">
|
||||
{new Date(post.regDtm).toLocaleDateString('ko-KR')}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm 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={`px-3 py-1.5 text-sm rounded transition-colors ${
|
||||
p === page
|
||||
? 'bg-primary-cyan/20 text-primary-cyan font-semibold'
|
||||
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,55 +1,69 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { sanitizeInput } from '@common/utils/sanitize'
|
||||
|
||||
interface BoardPost {
|
||||
id?: number
|
||||
category: string
|
||||
title: string
|
||||
content: string
|
||||
author: string
|
||||
organization: string
|
||||
hasAttachment?: boolean
|
||||
}
|
||||
import {
|
||||
fetchBoardPost,
|
||||
createBoardPost,
|
||||
updateBoardPost,
|
||||
} from '../services/boardApi'
|
||||
|
||||
interface BoardWriteFormProps {
|
||||
post?: BoardPost | null
|
||||
onSave: (post: Omit<BoardPost, 'id'>) => void
|
||||
postSn?: number | null
|
||||
defaultCategoryCd: string
|
||||
onSaveComplete: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
// 허용된 카테고리 (화이트리스트)
|
||||
const ALLOWED_CATEGORIES = ['공지', '일반', '자료', 'Q&A']
|
||||
// DB 카테고리 코드 ↔ 표시명
|
||||
const CATEGORY_OPTIONS: Array<{ code: string; label: string }> = [
|
||||
{ code: 'NOTICE', label: '공지사항' },
|
||||
{ code: 'DATA', label: '자료실' },
|
||||
{ code: 'QNA', label: 'Q&A' },
|
||||
]
|
||||
|
||||
// 허용된 파일 확장자
|
||||
const ALLOWED_FILE_EXTENSIONS = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.hwp', '.png', '.jpg', '.jpeg']
|
||||
const MAX_FILE_SIZE_MB = 10
|
||||
|
||||
export function BoardWriteForm({ post, onSave, onCancel }: BoardWriteFormProps) {
|
||||
const [category, setCategory] = useState(post?.category || '일반')
|
||||
const [title, setTitle] = useState(post?.title || '')
|
||||
const [content, setContent] = useState(post?.content || '')
|
||||
const [author, setAuthor] = useState(post?.author || '관리자')
|
||||
const [organization, setOrganization] = useState(post?.organization || '본청')
|
||||
export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCancel }: BoardWriteFormProps) {
|
||||
const isEditMode = postSn != null
|
||||
|
||||
const [categoryCd, setCategoryCd] = useState(defaultCategoryCd)
|
||||
const [title, setTitle] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
|
||||
// 수정 모드: 기존 게시글 데이터 로드
|
||||
useEffect(() => {
|
||||
if (post) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setCategory(post.category)
|
||||
setTitle(post.title)
|
||||
setContent(post.content)
|
||||
setAuthor(post.author)
|
||||
setOrganization(post.organization)
|
||||
if (!isEditMode) return
|
||||
let cancelled = false
|
||||
const load = async () => {
|
||||
setIsFetching(true)
|
||||
try {
|
||||
const post = await fetchBoardPost(postSn!)
|
||||
if (cancelled) return
|
||||
setCategoryCd(post.categoryCd)
|
||||
setTitle(post.title)
|
||||
setContent(post.content || '')
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
alert('게시글을 불러오는데 실패했습니다.')
|
||||
onCancel()
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsFetching(false)
|
||||
}
|
||||
}
|
||||
}, [post])
|
||||
load()
|
||||
return () => { cancelled = true }
|
||||
}, [postSn, isEditMode, onCancel])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// 입력값 살균 (XSS 방지)
|
||||
const safeTitle = sanitizeInput(title, 200)
|
||||
const safeContent = sanitizeInput(content, 10000)
|
||||
const safeAuthor = sanitizeInput(author, 50)
|
||||
const safeOrg = sanitizeInput(organization, 100)
|
||||
|
||||
if (!safeTitle) {
|
||||
alert('제목을 입력해주세요.')
|
||||
@ -59,25 +73,22 @@ export function BoardWriteForm({ post, onSave, onCancel }: BoardWriteFormProps)
|
||||
alert('내용을 입력해주세요.')
|
||||
return
|
||||
}
|
||||
if (!safeAuthor) {
|
||||
alert('작성자를 입력해주세요.')
|
||||
return
|
||||
}
|
||||
|
||||
// 카테고리 화이트리스트 검증
|
||||
if (!ALLOWED_CATEGORIES.includes(category)) {
|
||||
alert('유효하지 않은 분류입니다.')
|
||||
return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
if (isEditMode) {
|
||||
await updateBoardPost(postSn!, { title: safeTitle, content: safeContent })
|
||||
alert('게시글이 수정되었습니다.')
|
||||
} else {
|
||||
await createBoardPost({ categoryCd, title: safeTitle, content: safeContent })
|
||||
alert('게시글이 등록되었습니다.')
|
||||
}
|
||||
onSaveComplete()
|
||||
} catch (err) {
|
||||
alert((err as { message?: string })?.message || '저장에 실패했습니다.')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
onSave({
|
||||
category,
|
||||
title: safeTitle,
|
||||
content: safeContent,
|
||||
author: safeAuthor,
|
||||
organization: safeOrg,
|
||||
hasAttachment: false
|
||||
})
|
||||
}
|
||||
|
||||
// 파일 업로드 보안 검증
|
||||
@ -99,37 +110,45 @@ export function BoardWriteForm({ post, onSave, onCancel }: BoardWriteFormProps)
|
||||
}
|
||||
}
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-0 items-center justify-center">
|
||||
<p className="text-text-3 text-sm">게시글을 불러오는 중...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-0">
|
||||
{/* Header */}
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
|
||||
<h2 className="text-lg font-semibold text-text-1">
|
||||
{post ? '게시글 수정' : '게시글 작성'}
|
||||
{isEditMode ? '게시글 수정' : '게시글 작성'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
{/* 폼 */}
|
||||
<form onSubmit={handleSubmit} className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-auto px-8 py-6">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Category Selection */}
|
||||
{/* 분류 선택 */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-2 mb-2">
|
||||
분류 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full px-4 py-2.5 text-sm bg-bg-2 border border-border rounded text-text-1 focus:border-primary-cyan focus:outline-none"
|
||||
value={categoryCd}
|
||||
onChange={(e) => setCategoryCd(e.target.value)}
|
||||
disabled={isEditMode}
|
||||
className="w-full px-4 py-2.5 text-sm bg-bg-2 border border-border rounded text-text-1 focus:border-primary-cyan focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
<option value="공지">공지</option>
|
||||
<option value="일반">일반</option>
|
||||
<option value="자료">자료</option>
|
||||
<option value="Q&A">Q&A</option>
|
||||
{CATEGORY_OPTIONS.map(opt => (
|
||||
<option key={opt.code} value={opt.code}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Title Input */}
|
||||
{/* 제목 */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-2 mb-2">
|
||||
제목 <span className="text-red-500">*</span>
|
||||
@ -144,37 +163,7 @@ export function BoardWriteForm({ post, onSave, onCancel }: BoardWriteFormProps)
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Author Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-2 mb-2">
|
||||
작성자 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
maxLength={50}
|
||||
placeholder="작성자명을 입력하세요"
|
||||
className="w-full px-4 py-2.5 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Organization Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-2 mb-2">
|
||||
소속 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={organization}
|
||||
onChange={(e) => setOrganization(e.target.value)}
|
||||
maxLength={100}
|
||||
placeholder="소속을 입력하세요"
|
||||
className="w-full px-4 py-2.5 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content Textarea */}
|
||||
{/* 내용 */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-2 mb-2">
|
||||
내용 <span className="text-red-500">*</span>
|
||||
@ -189,7 +178,7 @@ export function BoardWriteForm({ post, onSave, onCancel }: BoardWriteFormProps)
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File Upload (Optional) */}
|
||||
{/* 파일 첨부 (향후 API 연동 예정) */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-2 mb-2">첨부파일</label>
|
||||
<div className="flex items-center gap-3">
|
||||
@ -213,7 +202,7 @@ export function BoardWriteForm({ post, onSave, onCancel }: BoardWriteFormProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Buttons */}
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex items-center justify-end gap-3 px-8 py-4 border-t border-border bg-bg-1">
|
||||
<button
|
||||
type="button"
|
||||
@ -224,9 +213,10 @@ export function BoardWriteForm({ post, onSave, onCancel }: BoardWriteFormProps)
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2.5 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity"
|
||||
disabled={isLoading}
|
||||
className="px-6 py-2.5 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{post ? '수정하기' : '등록하기'}
|
||||
{isLoading ? '저장 중...' : isEditMode ? '수정하기' : '등록하기'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user