wing-ops/frontend/src/tabs/board/components/BoardWriteForm.tsx
htlee 6bdea97b49 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>
2026-02-28 19:32:12 +09:00

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