- 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>
226 lines
7.8 KiB
TypeScript
Executable File
226 lines
7.8 KiB
TypeScript
Executable File
import { useState, useEffect } from 'react'
|
|
import { sanitizeInput } from '@common/utils/sanitize'
|
|
import {
|
|
fetchBoardPost,
|
|
createBoardPost,
|
|
updateBoardPost,
|
|
} from '../services/boardApi'
|
|
|
|
interface BoardWriteFormProps {
|
|
postSn?: number | null
|
|
defaultCategoryCd: string
|
|
onSaveComplete: () => void
|
|
onCancel: () => void
|
|
}
|
|
|
|
// 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({ 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 (!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)
|
|
}
|
|
}
|
|
load()
|
|
return () => { cancelled = true }
|
|
}, [postSn, isEditMode, onCancel])
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
|
|
// 입력값 살균 (XSS 방지)
|
|
const safeTitle = sanitizeInput(title, 200)
|
|
const safeContent = sanitizeInput(content, 10000)
|
|
|
|
if (!safeTitle) {
|
|
alert('제목을 입력해주세요.')
|
|
return
|
|
}
|
|
if (!safeContent) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 파일 업로드 보안 검증
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files
|
|
if (!files) return
|
|
for (const file of Array.from(files)) {
|
|
if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
|
|
alert(`파일 크기 초과: ${file.name} (최대 ${MAX_FILE_SIZE_MB}MB)`)
|
|
e.target.value = ''
|
|
return
|
|
}
|
|
const ext = '.' + (file.name.split('.').pop()?.toLowerCase() || '')
|
|
if (!ALLOWED_FILE_EXTENSIONS.includes(ext)) {
|
|
alert(`허용되지 않는 파일 형식: ${file.name}`)
|
|
e.target.value = ''
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
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">
|
|
{/* 헤더 */}
|
|
<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">
|
|
{isEditMode ? '게시글 수정' : '게시글 작성'}
|
|
</h2>
|
|
</div>
|
|
|
|
{/* 폼 */}
|
|
<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">
|
|
{/* 분류 선택 */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-text-2 mb-2">
|
|
분류 <span className="text-red-500">*</span>
|
|
</label>
|
|
<select
|
|
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"
|
|
>
|
|
{CATEGORY_OPTIONS.map(opt => (
|
|
<option key={opt.code} value={opt.code}>{opt.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* 제목 */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-text-2 mb-2">
|
|
제목 <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
maxLength={200}
|
|
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>
|
|
|
|
{/* 내용 */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-text-2 mb-2">
|
|
내용 <span className="text-red-500">*</span>
|
|
</label>
|
|
<textarea
|
|
value={content}
|
|
onChange={(e) => setContent(e.target.value)}
|
|
maxLength={10000}
|
|
placeholder="내용을 입력하세요"
|
|
rows={15}
|
|
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>
|
|
|
|
{/* 파일 첨부 (향후 API 연동 예정) */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-text-2 mb-2">첨부파일</label>
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="file"
|
|
multiple
|
|
accept={ALLOWED_FILE_EXTENSIONS.join(',')}
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
id="file-upload"
|
|
/>
|
|
<label
|
|
htmlFor="file-upload"
|
|
className="px-4 py-2 text-sm font-semibold rounded bg-bg-2 text-text-1 border border-border hover:bg-bg-3 cursor-pointer transition-colors"
|
|
>
|
|
파일 선택
|
|
</label>
|
|
<span className="text-sm text-text-3">선택된 파일 없음</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 하단 버튼 */}
|
|
<div className="flex items-center justify-end gap-3 px-8 py-4 border-t border-border bg-bg-1">
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="px-6 py-2.5 text-sm font-semibold rounded bg-bg-2 text-text-1 border border-border hover:bg-bg-3 transition-colors"
|
|
>
|
|
취소
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
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"
|
|
>
|
|
{isLoading ? '저장 중...' : isEditMode ? '수정하기' : '등록하기'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)
|
|
}
|