feat: Phase 5 View 분할 + RBAC 2차원 권한 + 게시판 CRUD API 연동 #29

병합
htlee feature/refactor-phase5-view-decomposition 에서 develop 로 2 commits 를 머지했습니다 2026-02-28 19:38:01 +09:00
4개의 변경된 파일435개의 추가작업 그리고 605개의 파일을 삭제

파일 보기

@ -1387,21 +1387,22 @@ export async function createBoardPost(input: CreateBoardPostInput): Promise<{ sn
#### 권한 기반 UI 분기 #### 권한 기반 UI 분기
**파일**: `frontend/src/tabs/board/components/BoardListTable.tsx` **파일**: `frontend/src/tabs/board/components/BoardView.tsx`
```tsx ```tsx
import { useAuthStore } from '@common/store/authStore'; import { useAuthStore } from '@common/store/authStore';
const hasPermission = useAuthStore((s) => s.hasPermission); const hasPermission = useAuthStore((s) => s.hasPermission);
// 카테고리별 서브리소스 CREATE 권한 확인 // 서브탭 기준 글쓰기 권한 리소스 결정
const canWrite = selectedCategory const getWriteResource = () => {
? hasPermission(`board:${selectedCategory.toLowerCase()}`, 'CREATE') if (activeSubTab === 'all') return 'board';
: hasPermission('board', 'CREATE'); return `board:${activeSubTab}`;
};
// 글쓰기 버튼 조건부 렌더링 // 글쓰기 버튼 조건부 렌더링
{canWrite && ( {hasPermission(getWriteResource(), 'CREATE') && (
<button onClick={onWriteClick}>글쓰기</button> <button onClick={handleWriteClick}>글쓰기</button>
)} )}
``` ```
@ -1430,4 +1431,6 @@ const canWrite = selectedCategory
| 백엔드 | `backend/src/board/boardRouter.ts` | 라우터 + requirePermission | | 백엔드 | `backend/src/board/boardRouter.ts` | 라우터 + requirePermission |
| 백엔드 | `backend/src/server.ts` | boardRouter 등록 | | 백엔드 | `backend/src/server.ts` | boardRouter 등록 |
| 프론트 | `frontend/src/tabs/board/services/boardApi.ts` | API 서비스 | | 프론트 | `frontend/src/tabs/board/services/boardApi.ts` | API 서비스 |
| 프론트 | `frontend/src/tabs/board/components/BoardListTable.tsx` | 목록 UI (API 연동) | | 프론트 | `frontend/src/tabs/board/components/BoardView.tsx` | 목록/상세/작성 통합 뷰 (API 연동) |
| 프론트 | `frontend/src/tabs/board/components/BoardWriteForm.tsx` | 게시글 작성/수정 폼 (API 호출) |
| 프론트 | `frontend/src/tabs/board/components/BoardDetailView.tsx` | 게시글 상세 보기 (API 호출) |

파일 보기

@ -1,33 +1,69 @@
interface BoardPost { import { useState, useEffect } from 'react'
id: number import { useAuthStore } from '@common/store/authStore'
category: string import { fetchBoardPost, type BoardPostDetail } from '../services/boardApi'
title: string
author: string // 카테고리 코드 → 표시명
organization: string const CATEGORY_LABELS: Record<string, string> = {
date: string NOTICE: '공지사항',
views: number DATA: '자료실',
content: string QNA: 'Q&A',
hasAttachment?: boolean MANUAL: '해경매뉴얼',
isNotice?: boolean }
// 카테고리별 배지 색상
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 { interface BoardDetailViewProps {
post: BoardPost postSn: number
onBack: () => void onBack: () => void
onEdit: () => void onEdit: () => void
onDelete: () => void onDelete: () => void
} }
export function BoardDetailView({ post, onBack, onEdit, onDelete }: BoardDetailViewProps) { export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetailViewProps) {
const handleDelete = () => { const user = useAuthStore((s) => s.user)
if (window.confirm('정말로 이 게시글을 삭제하시겠습니까?')) { const [post, setPost] = useState<BoardPostDetail | null>(null)
onDelete() 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 ( return (
<div className="flex flex-col h-full bg-bg-0"> <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"> <div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
<button <button
onClick={onBack} onClick={onBack}
@ -36,123 +72,68 @@ export function BoardDetailView({ post, onBack, onEdit, onDelete }: BoardDetailV
<span></span> <span></span>
<span></span> <span></span>
</button> </button>
<div className="flex items-center gap-2"> {isAuthor && (
<button <div className="flex items-center gap-2">
onClick={onEdit} <button
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" 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 </button>
onClick={handleDelete} <button
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" 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> </button>
</div>
)}
</div> </div>
{/* Post Content */} {/* 게시글 내용 */}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<div className="max-w-4xl mx-auto px-8 py-8"> <div className="max-w-4xl mx-auto px-8 py-8">
{/* Post Header */} {/* 게시글 헤더 */}
<div className="pb-6 border-b border-border"> <div className="pb-6 border-b border-border">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<span <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'}`}>
className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${ {CATEGORY_LABELS[post.categoryCd] || post.categoryCd}
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> </span>
{post.isNotice && ( {post.pinnedYn === 'Y' && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold bg-red-500/20 text-red-400"> <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> </span>
)} )}
</div> </div>
<h1 className="text-2xl font-bold text-text-1 mb-4">{post.title}</h1> <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"> <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>
<span>: <span className="text-text-2 font-semibold">{post.organization}</span></span> <span>: {new Date(post.regDtm).toLocaleDateString('ko-KR')}</span>
<span>|</span> <span>|</span>
<span>: {post.date}</span> <span>: {post.viewCnt}</span>
<span>|</span> {post.mdfcnDtm && (
<span>: {post.views}</span> <>
<span>|</span>
<span>: {new Date(post.mdfcnDtm).toLocaleDateString('ko-KR')}</span>
</>
)}
</div> </div>
</div> </div>
{/* Post Content */} {/* 본문 */}
<div className="py-8"> <div className="py-8">
<div className="prose prose-invert max-w-none"> <div className="prose prose-invert max-w-none">
<div className="text-text-1 text-[15px] leading-relaxed whitespace-pre-wrap"> <div className="text-text-1 text-[15px] leading-relaxed whitespace-pre-wrap">
{post.content} {post.content || '(내용 없음)'}
</div> </div>
</div> </div>
</div> </div>
{/* Attachments (if any) */} {/* 댓글 섹션 (향후 구현 예정) */}
<div className="py-6 border-t border-border"> <div className="py-6 border-t border-border">
<h3 className="text-sm font-semibold text-text-2 mb-3"></h3> <div className="text-center py-8">
<div className="space-y-2"> <p className="text-text-3 text-sm"> .</p>
<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> </div>
</div> </div>
</div> </div>

파일 보기

@ -1,24 +1,43 @@
import { useState } from 'react' import { useState, useEffect, useCallback } from 'react'
import { useSubMenu } from '@common/hooks/useSubMenu' import { useSubMenu } from '@common/hooks/useSubMenu'
import { useAuthStore } from '@common/store/authStore'
import { BoardWriteForm } from './BoardWriteForm' import { BoardWriteForm } from './BoardWriteForm'
import { BoardDetailView } from './BoardDetailView' import { BoardDetailView } from './BoardDetailView'
import {
fetchBoardPosts,
deleteBoardPost,
type BoardPostItem,
} from '../services/boardApi'
interface BoardPost { type ViewMode = 'list' | 'detail' | 'write'
id: number
category: string // 서브탭 → DB 카테고리 코드 매핑
title: string const SUB_TAB_TO_CATEGORY: Record<string, string | undefined> = {
author: string all: undefined,
organization: string notice: 'NOTICE',
date: string data: 'DATA',
views: number qna: 'QNA',
content: string
hasAttachment?: boolean
isNotice?: boolean
} }
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 { interface ManualFile {
id: number id: number
category: string 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 }, { 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() { export function BoardView() {
const { activeSubTab } = useSubMenu('board') const { activeSubTab } = useSubMenu('board')
const [posts, setPosts] = useState<BoardPost[]>(initialPosts) const hasPermission = useAuthStore((s) => s.hasPermission)
const [viewMode, setViewMode] = useState<ViewMode>('list')
const [selectedPostId, setSelectedPostId] = useState<number | null>(null) // 목록 상태
const [editingPost, setEditingPost] = useState<BoardPost | null>(null) const [posts, setPosts] = useState<BoardPostItem[]>([])
const [totalCount, setTotalCount] = useState(0)
const [page, setPage] = useState(1)
const [searchTerm, setSearchTerm] = useState('') 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 categoryCd = SUB_TAB_TO_CATEGORY[activeSubTab]
const getCategoryFilter = (subTab: string): string | null => {
switch (subTab) { // 게시글 목록 조회
case 'all': const loadPosts = useCallback(async () => {
return null // Show all setIsLoading(true)
case 'notice': try {
return '공지' const result = await fetchBoardPosts({
case 'data': categoryCd,
return '자료' search: searchTerm || undefined,
case 'qna': page,
return 'Q&A' size: PAGE_SIZE,
default: })
return null 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) => { useEffect(() => {
const matchesCategory = !categoryFilter || post.category === categoryFilter setPage(1)
const matchesSearch = }, [activeSubTab])
post.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
post.author.toLowerCase().includes(searchTerm.toLowerCase()) ||
post.organization.toLowerCase().includes(searchTerm.toLowerCase())
return matchesCategory && matchesSearch
})
// Handle post click to view detail // 상세 보기
const handlePostClick = (id: number) => { const handlePostClick = (sn: number) => {
setSelectedPostId(id) setSelectedPostSn(sn)
setViewMode('detail') setViewMode('detail')
// Increment view count
setPosts(posts.map((p) => (p.id === id ? { ...p, views: p.views + 1 } : p)))
} }
// Handle write button click // 글쓰기
const handleWriteClick = () => { const handleWriteClick = () => {
setEditingPostSn(null)
setViewMode('write') setViewMode('write')
setEditingPost(null)
} }
// Handle edit button click // 상세에서 수정
const handleEditClick = () => { const handleEditFromDetail = (sn: number) => {
if (selectedPost) { setEditingPostSn(sn)
setEditingPost(selectedPost) setViewMode('write')
setViewMode('write')
}
} }
// Handle delete button click (from table) // 삭제 (목록 또는 상세에서)
const handleDeleteClick = (id: number) => { const handleDelete = async (sn: number) => {
if (window.confirm('정말로 이 게시글을 삭제하시겠습니까?')) { if (!window.confirm('정말로 이 게시글을 삭제하시겠습니까?')) return
setPosts(posts.filter((p) => p.id !== id)) try {
await deleteBoardPost(sn)
alert('게시글이 삭제되었습니다.') alert('게시글이 삭제되었습니다.')
}
}
// Handle delete from detail view
const handleDeleteFromDetail = () => {
if (selectedPostId) {
setPosts(posts.filter((p) => p.id !== selectedPostId))
setViewMode('list') setViewMode('list')
setSelectedPostId(null) setSelectedPostSn(null)
alert('게시글이 삭제되었습니다.') loadPosts()
} catch (err) {
alert((err as { message?: string })?.message || '삭제에 실패했습니다.')
} }
} }
// Handle back to list // 목록으로 돌아가기
const handleBackToList = () => { const handleBackToList = () => {
setViewMode('list') setViewMode('list')
setSelectedPostId(null) setSelectedPostSn(null)
setEditingPostSn(null)
loadPosts()
} }
// Handle save post (create or update) // 저장 완료 후 목록으로
const handleSavePost = (postData: Omit<BoardPost, 'id'>) => { const handleSaveComplete = () => {
if (editingPost) { setViewMode('list')
// Update existing post setEditingPostSn(null)
setPosts( loadPosts()
posts.map((p) => }
p.id === editingPost.id
? { // 검색 Enter 키
...p, const handleSearchKeyDown = (e: React.KeyboardEvent) => {
...postData, if (e.key === 'Enter') {
date: new Date().toISOString().split('T')[0] setPage(1)
} loadPosts()
: 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('게시글이 등록되었습니다.')
} }
setEditingPost(null)
setViewMode('list')
} }
// Handle cancel // 현재 서브탭 기준 글쓰기 권한 리소스
const handleCancel = () => { const getWriteResource = () => {
setEditingPost(null) if (activeSubTab === 'all') return 'board'
setViewMode('list') return `board:${activeSubTab}`
} }
/* ── 해경매뉴얼 전용 상태 ── */ const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
/*
(mock, API )
*/
const [manualCategory, setManualCategory] = useState<string>('전체') const [manualCategory, setManualCategory] = useState<string>('전체')
const [manualSearch, setManualSearch] = useState('') const [manualSearch, setManualSearch] = useState('')
const [manualList, setManualList] = useState<ManualFile[]>(manualFiles) const [manualList, setManualList] = useState<ManualFile[]>(manualFiles)
@ -309,7 +203,6 @@ export function BoardView() {
} }
} }
// 해경매뉴얼 탭이면 매뉴얼 뷰 표시
if (activeSubTab === 'manual') { if (activeSubTab === 'manual') {
return ( return (
<div className="flex flex-1 overflow-hidden"> <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)' }} onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = 'rgba(6,182,212,.4)' }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--bd)' }} onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--bd)' }}
> >
{/* 카테고리 + 버전 */}
<div className="flex items-center justify-between mb-3"> <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 }}> <span className="px-2 py-0.5 rounded text-[10px] font-semibold" style={{ background: cc.bg, color: cc.text }}>
{file.category} {file.category}
@ -371,13 +263,9 @@ export function BoardView() {
{file.version} {file.version}
</span> </span>
</div> </div>
{/* 제목 */}
<div className="text-[12px] font-bold mb-3 leading-[1.5]" style={{ color: 'var(--t1)', fontFamily: 'var(--fK)' }}> <div className="text-[12px] font-bold mb-3 leading-[1.5]" style={{ color: 'var(--t1)', fontFamily: 'var(--fK)' }}>
{file.title} {file.title}
</div> </div>
{/* 파일 정보 */}
<div className="flex items-center gap-2 mb-3"> <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)' }}> <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 style={{ fontSize: 12 }}>📄</span>
@ -385,8 +273,6 @@ export function BoardView() {
</div> </div>
<span className="text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{file.fileSize}</span> <span className="text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{file.fileSize}</span>
</div> </div>
{/* 관리 버튼 (수정/삭제) */}
<div className="flex items-center justify-end gap-1 mb-2"> <div className="flex items-center justify-end gap-1 mb-2">
<button onClick={(e) => { <button onClick={(e) => {
e.stopPropagation() e.stopPropagation()
@ -417,8 +303,6 @@ export function BoardView() {
🗑 🗑
</button> </button>
</div> </div>
{/* 메타 정보 */}
<div className="flex items-center justify-between pt-3" style={{ borderTop: '1px solid var(--bd)' }}> <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)' }}> <div className="flex items-center gap-3 text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fK)' }}>
<span>{file.author}</span> <span>{file.author}</span>
@ -430,9 +314,7 @@ export function BoardView() {
</span> </span>
<button onClick={(e) => { <button onClick={(e) => {
e.stopPropagation() e.stopPropagation()
// 다운로드 수 증가
setManualList(prev => prev.map(f => f.id === file.id ? { ...f, downloads: f.downloads + 1 } : f)) setManualList(prev => prev.map(f => f.id === file.id ? { ...f, downloads: f.downloads + 1 } : f))
// 파일 다운로드 생성
const content = [ const content = [
`═══════════════════════════════════════════`, `═══════════════════════════════════════════`,
` ${file.title}`, ` ${file.title}`,
@ -481,13 +363,12 @@ export function BoardView() {
</div> </div>
</div> </div>
{/* ── 업로드 모달 ── */} {/* 업로드 모달 */}
{showUploadModal && ( {showUploadModal && (
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,.55)' }} <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) }}> onClick={() => { setShowUploadModal(false); setEditingManualId(null) }}>
<div style={{ width: 480, background: 'var(--bg1)', border: '1px solid var(--bd)', borderRadius: 12, overflow: 'hidden' }} <div style={{ width: 480, background: 'var(--bg1)', border: '1px solid var(--bd)', borderRadius: 12, overflow: 'hidden' }}
onClick={e => e.stopPropagation()}> onClick={e => e.stopPropagation()}>
{/* 모달 헤더 */}
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--bd)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <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 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 16 }}>{editingManualId ? '✏️' : '📤'}</span> <span style={{ fontSize: 16 }}>{editingManualId ? '✏️' : '📤'}</span>
@ -496,10 +377,7 @@ export function BoardView() {
<span onClick={() => { setShowUploadModal(false); setEditingManualId(null) }} <span onClick={() => { setShowUploadModal(false); setEditingManualId(null) }}
style={{ cursor: 'pointer', color: 'var(--t3)', fontSize: 16, lineHeight: 1 }}></span> style={{ cursor: 'pointer', color: 'var(--t3)', fontSize: 16, lineHeight: 1 }}></span>
</div> </div>
{/* 모달 바디 */}
<div style={{ padding: 20, display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ padding: 20, display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* 카테고리 선택 */}
<div> <div>
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 6 }}></label> <label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 6 }}></label>
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6 }}>
@ -521,24 +399,18 @@ export function BoardView() {
})} })}
</div> </div>
</div> </div>
{/* 제목 */}
<div> <div>
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 6 }}> </label> <label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 6 }}> </label>
<input type="text" placeholder="매뉴얼 제목을 입력하세요" value={uploadForm.title} <input type="text" placeholder="매뉴얼 제목을 입력하세요" value={uploadForm.title}
onChange={e => setUploadForm(prev => ({ ...prev, title: e.target.value }))} 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' }} /> 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>
{/* 버전 */}
<div> <div>
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 6 }}></label> <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} <input type="text" placeholder="예: v1.0" value={uploadForm.version}
onChange={e => setUploadForm(prev => ({ ...prev, version: e.target.value }))} 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' }} /> 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>
{/* 파일 첨부 영역 */}
<div> <div>
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 6 }}> </label> <label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 6 }}> </label>
<div style={{ <div style={{
@ -553,10 +425,7 @@ export function BoardView() {
const file = (ev.target as HTMLInputElement).files?.[0] const file = (ev.target as HTMLInputElement).files?.[0]
if (file) { if (file) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(1) 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` })) setUploadForm(prev => ({ ...prev, fileName: file.name, fileSize: `${sizeMB} MB` }))
// fileType will be derived from extension
void ext
} }
} }
input.click() input.click()
@ -581,8 +450,6 @@ export function BoardView() {
</div> </div>
</div> </div>
</div> </div>
{/* 모달 푸터 */}
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--bd)', display: 'flex', justifyContent: 'flex-end', gap: 8 }}> <div style={{ padding: '12px 20px', borderTop: '1px solid var(--bd)', display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => { setShowUploadModal(false); setEditingManualId(null) }} <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' }}> 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.title.trim()) { alert('제목을 입력하세요.'); return }
if (!uploadForm.fileName) { alert('파일을 선택하세요.'); return } if (!uploadForm.fileName) { alert('파일을 선택하세요.'); return }
const ext = uploadForm.fileName.split('.').pop()?.toUpperCase() || 'PDF' const ext = uploadForm.fileName.split('.').pop()?.toUpperCase() || 'PDF'
if (editingManualId) { if (editingManualId) {
// 수정 모드
setManualList(prev => prev.map(f => f.id === editingManualId ? { setManualList(prev => prev.map(f => f.id === editingManualId ? {
...f, ...f,
category: uploadForm.category, category: uploadForm.category,
@ -605,7 +470,6 @@ export function BoardView() {
uploadDate: new Date().toISOString().split('T')[0], uploadDate: new Date().toISOString().split('T')[0],
} : f)) } : f))
} else { } else {
// 새로 업로드
const newFile: ManualFile = { const newFile: ManualFile = {
id: Math.max(...manualList.map(f => f.id)) + 1, id: Math.max(...manualList.map(f => f.id)) + 1,
category: uploadForm.category, category: uploadForm.category,
@ -619,7 +483,6 @@ export function BoardView() {
} }
setManualList(prev => [newFile, ...prev]) setManualList(prev => [newFile, ...prev])
} }
setUploadForm({ category: '방제매뉴얼', title: '', version: '', fileName: '', fileSize: '' }) setUploadForm({ category: '방제매뉴얼', title: '', version: '', fileName: '', fileSize: '' })
setEditingManualId(null) setEditingManualId(null)
setShowUploadModal(false) setShowUploadModal(false)
@ -631,163 +494,156 @@ export function BoardView() {
</div> </div>
</div> </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 ( return (
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Main Content */}
<div className="flex-1 relative overflow-hidden"> <div className="flex-1 relative overflow-hidden">
{viewMode === 'list' ? ( <div className="flex flex-col h-full bg-bg-0">
<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 items-center justify-between px-8 py-4 border-b border-border bg-bg-1"> <div className="text-sm text-text-3">
<div className="flex-1" /> <span className="text-text-1 font-semibold">{totalCount}</span>
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Search Input */} <input
<input type="text"
type="text" placeholder="검색..."
placeholder="검색..." value={searchTerm}
value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)}
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" 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') && (
{/* Write Button */}
<button <button
onClick={handleWriteClick} onClick={handleWriteClick}
className="px-6 py-2 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity" className="px-6 py-2 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity"
> >
</button> </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> </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> </div>
) : viewMode === 'detail' && selectedPost ? (
<BoardDetailView {/* 테이블 */}
post={selectedPost} <div className="flex-1 overflow-auto px-8 py-6">
onBack={handleBackToList} {isLoading ? (
onEdit={handleEditClick} <div className="text-center py-20">
onDelete={handleDeleteFromDetail} <p className="text-text-3 text-sm"> ...</p>
/> </div>
) : viewMode === 'write' ? ( ) : (
<BoardWriteForm post={editingPost} onSave={handleSavePost} onCancel={handleCancel} /> <>
) : null} <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>
</div> </div>
) )

파일 보기

@ -1,55 +1,69 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { sanitizeInput } from '@common/utils/sanitize' import { sanitizeInput } from '@common/utils/sanitize'
import {
interface BoardPost { fetchBoardPost,
id?: number createBoardPost,
category: string updateBoardPost,
title: string } from '../services/boardApi'
content: string
author: string
organization: string
hasAttachment?: boolean
}
interface BoardWriteFormProps { interface BoardWriteFormProps {
post?: BoardPost | null postSn?: number | null
onSave: (post: Omit<BoardPost, 'id'>) => void defaultCategoryCd: string
onSaveComplete: () => void
onCancel: () => void onCancel: () => void
} }
// 허용된 카테고리 (화이트리스트) // DB 카테고리 코드 ↔ 표시명
const ALLOWED_CATEGORIES = ['공지', '일반', '자료', 'Q&A'] 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 ALLOWED_FILE_EXTENSIONS = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.hwp', '.png', '.jpg', '.jpeg']
const MAX_FILE_SIZE_MB = 10 const MAX_FILE_SIZE_MB = 10
export function BoardWriteForm({ post, onSave, onCancel }: BoardWriteFormProps) { export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCancel }: BoardWriteFormProps) {
const [category, setCategory] = useState(post?.category || '일반') const isEditMode = postSn != null
const [title, setTitle] = useState(post?.title || '')
const [content, setContent] = useState(post?.content || '')
const [author, setAuthor] = useState(post?.author || '관리자')
const [organization, setOrganization] = useState(post?.organization || '본청')
const [categoryCd, setCategoryCd] = useState(defaultCategoryCd)
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isFetching, setIsFetching] = useState(false)
// 수정 모드: 기존 게시글 데이터 로드
useEffect(() => { useEffect(() => {
if (post) { if (!isEditMode) return
// eslint-disable-next-line react-hooks/set-state-in-effect let cancelled = false
setCategory(post.category) const load = async () => {
setTitle(post.title) setIsFetching(true)
setContent(post.content) try {
setAuthor(post.author) const post = await fetchBoardPost(postSn!)
setOrganization(post.organization) 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() e.preventDefault()
// 입력값 살균 (XSS 방지) // 입력값 살균 (XSS 방지)
const safeTitle = sanitizeInput(title, 200) const safeTitle = sanitizeInput(title, 200)
const safeContent = sanitizeInput(content, 10000) const safeContent = sanitizeInput(content, 10000)
const safeAuthor = sanitizeInput(author, 50)
const safeOrg = sanitizeInput(organization, 100)
if (!safeTitle) { if (!safeTitle) {
alert('제목을 입력해주세요.') alert('제목을 입력해주세요.')
@ -59,25 +73,22 @@ export function BoardWriteForm({ post, onSave, onCancel }: BoardWriteFormProps)
alert('내용을 입력해주세요.') alert('내용을 입력해주세요.')
return return
} }
if (!safeAuthor) {
alert('작성자를 입력해주세요.')
return
}
// 카테고리 화이트리스트 검증 setIsLoading(true)
if (!ALLOWED_CATEGORIES.includes(category)) { try {
alert('유효하지 않은 분류입니다.') if (isEditMode) {
return 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 ( return (
<div className="flex flex-col h-full bg-bg-0"> <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"> <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"> <h2 className="text-lg font-semibold text-text-1">
{post ? '게시글 수정' : '게시글 작성'} {isEditMode ? '게시글 수정' : '게시글 작성'}
</h2> </h2>
</div> </div>
{/* Form */} {/* */}
<form onSubmit={handleSubmit} className="flex-1 flex flex-col overflow-hidden"> <form onSubmit={handleSubmit} className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-auto px-8 py-6"> <div className="flex-1 overflow-auto px-8 py-6">
<div className="max-w-4xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
{/* Category Selection */} {/* 분류 선택 */}
<div> <div>
<label className="block text-sm font-semibold text-text-2 mb-2"> <label className="block text-sm font-semibold text-text-2 mb-2">
<span className="text-red-500">*</span> <span className="text-red-500">*</span>
</label> </label>
<select <select
value={category} value={categoryCd}
onChange={(e) => setCategory(e.target.value)} onChange={(e) => setCategoryCd(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" 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> {CATEGORY_OPTIONS.map(opt => (
<option value="일반"></option> <option key={opt.code} value={opt.code}>{opt.label}</option>
<option value="자료"></option> ))}
<option value="Q&A">Q&A</option>
</select> </select>
</div> </div>
{/* Title Input */} {/* 제목 */}
<div> <div>
<label className="block text-sm font-semibold text-text-2 mb-2"> <label className="block text-sm font-semibold text-text-2 mb-2">
<span className="text-red-500">*</span> <span className="text-red-500">*</span>
@ -144,37 +163,7 @@ export function BoardWriteForm({ post, onSave, onCancel }: BoardWriteFormProps)
/> />
</div> </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> <div>
<label className="block text-sm font-semibold text-text-2 mb-2"> <label className="block text-sm font-semibold text-text-2 mb-2">
<span className="text-red-500">*</span> <span className="text-red-500">*</span>
@ -189,7 +178,7 @@ export function BoardWriteForm({ post, onSave, onCancel }: BoardWriteFormProps)
/> />
</div> </div>
{/* File Upload (Optional) */} {/* 파일 첨부 (향후 API 연동 예정) */}
<div> <div>
<label className="block text-sm font-semibold text-text-2 mb-2"></label> <label className="block text-sm font-semibold text-text-2 mb-2"></label>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -213,7 +202,7 @@ export function BoardWriteForm({ post, onSave, onCancel }: BoardWriteFormProps)
</div> </div>
</div> </div>
{/* Footer Buttons */} {/* 하단 버튼 */}
<div className="flex items-center justify-end gap-3 px-8 py-4 border-t border-border bg-bg-1"> <div className="flex items-center justify-end gap-3 px-8 py-4 border-t border-border bg-bg-1">
<button <button
type="button" type="button"
@ -224,9 +213,10 @@ export function BoardWriteForm({ post, onSave, onCancel }: BoardWriteFormProps)
</button> </button>
<button <button
type="submit" 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> </button>
</div> </div>
</form> </form>