Files
sam-react-prod/src/components/board/BoardForm/index.tsx
유병철 9464a368ba refactor: 모달 Content 컴포넌트 분리 및 파일 입력 UI 공통화
- 모달 컴포넌트에서 Content 분리하여 재사용성 향상
  - EstimateDocumentContent, DirectConstructionContent 등
  - WorkLogContent, QuotePreviewContent, ReceivingReceiptContent
- 파일 입력 공통 UI 컴포넌트 추가
  - file-dropzone, file-input, file-list, image-upload
- 폼 컴포넌트 코드 정리 및 중복 제거 (-4,056줄)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 15:07:17 +09:00

429 lines
14 KiB
TypeScript

'use client';
/**
* 게시글 등록/수정 폼 컴포넌트
* IntegratedDetailTemplate 마이그레이션 (2025-01-20)
*
* 디자인 스펙 기준:
* - 페이지 타이틀: 게시글 상세
* - 페이지 설명: 게시글을 등록하고 관리합니다.
* - 필드: 게시판, 상단 노출, 제목, 내용(에디터), 첨부파일, 작성자, 댓글, 등록일시
*/
import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { FileDropzone } from '@/components/ui/file-dropzone';
import { FileList, type NewFile, type ExistingFile } from '@/components/ui/file-list';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { boardCreateConfig, boardEditConfig } from './boardFormConfig';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { RichTextEditor } from '../RichTextEditor';
import type { Post, Attachment } from '../types';
import { createPost, updatePost } from '../actions';
import { getBoards } from '../BoardManagement/actions';
import type { Board } from '../BoardManagement/types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
interface BoardFormProps {
mode: 'create' | 'edit';
initialData?: Post;
}
// 현재 로그인 사용자 정보 (실제로는 auth context에서 가져옴)
const CURRENT_USER = {
id: 'user1',
name: '홍길동',
department: '개발팀',
position: '과장',
};
// 상단 고정 최대 개수
const MAX_PINNED_COUNT = 5;
export function BoardForm({ mode, initialData }: BoardFormProps) {
const router = useRouter();
// ===== 폼 상태 =====
const [boardCode, setBoardCode] = useState(initialData?.boardCode || '');
const [isPinned, setIsPinned] = useState(initialData?.isPinned ? 'true' : 'false');
const [title, setTitle] = useState(initialData?.title || '');
const [content, setContent] = useState(initialData?.content || '');
const [allowComments, setAllowComments] = useState(initialData?.allowComments ? 'true' : 'false');
const [attachments, setAttachments] = useState<NewFile[]>([]);
const [existingAttachments, setExistingAttachments] = useState<ExistingFile[]>(
(initialData?.attachments || []).map(a => ({
id: a.id,
name: a.fileName,
url: a.fileUrl,
size: a.fileSize,
}))
);
// 상단 노출 초과 Alert
const [showPinnedAlert, setShowPinnedAlert] = useState(false);
// 유효성 에러
const [errors, setErrors] = useState<Record<string, string>>({});
// 게시판 목록 상태
const [boards, setBoards] = useState<Board[]>([]);
const [isBoardsLoading, setIsBoardsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
// ===== 게시판 목록 조회 =====
useEffect(() => {
async function fetchBoards() {
setIsBoardsLoading(true);
try {
const result = await getBoards();
if (result.success && result.data) {
setBoards(result.data);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('게시판 목록 조회 오류:', error);
toast.error('게시판 목록을 불러오지 못했습니다.');
} finally {
setIsBoardsLoading(false);
}
}
fetchBoards();
}, []);
// ===== 상단 노출 변경 핸들러 =====
const handlePinnedChange = useCallback((value: string) => {
if (value === 'true') {
// 상단 노출 5개 제한 체크 (Mock: 현재 3개 고정중이라고 가정)
const currentPinnedCount = 3;
if (currentPinnedCount >= MAX_PINNED_COUNT) {
setShowPinnedAlert(true);
return;
}
}
setIsPinned(value);
}, []);
// ===== 파일 업로드 핸들러 =====
const handleFilesSelect = useCallback((files: File[]) => {
const newFiles = files.map(file => ({ file }));
setAttachments((prev) => [...prev, ...newFiles]);
}, []);
const handleRemoveFile = useCallback((index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index));
}, []);
const handleRemoveExistingFile = useCallback((id: string | number) => {
setExistingAttachments((prev) => prev.filter((a) => a.id !== id));
}, []);
// ===== 유효성 검사 =====
const validate = useCallback(() => {
const newErrors: Record<string, string> = {};
if (!boardCode) {
newErrors.boardCode = '게시판을 선택해주세요.';
}
if (!title.trim()) {
newErrors.title = '제목을 입력해주세요.';
}
if (!content.trim() || content === '<p></p>') {
newErrors.content = '내용을 입력해주세요.';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}, [boardCode, title, content]);
// ===== 저장 핸들러 =====
const handleSubmit = useCallback(async () => {
if (!validate()) return;
setIsSubmitting(true);
try {
const postData = {
title,
content,
is_notice: isPinned === 'true',
is_secret: false,
};
let result;
if (mode === 'create') {
result = await createPost(boardCode, postData);
} else if (initialData) {
result = await updatePost(boardCode, initialData.id, postData);
}
if (result?.success) {
toast.success(mode === 'create' ? '게시글이 등록되었습니다.' : '게시글이 수정되었습니다.');
router.push('/ko/board');
} else {
toast.error(result?.error || '게시글 저장에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('게시글 저장 오류:', error);
toast.error('게시글 저장에 실패했습니다.');
} finally {
setIsSubmitting(false);
}
}, [boardCode, title, content, isPinned, mode, initialData, router, validate]);
// ===== 취소 핸들러 =====
const handleCancel = useCallback(() => {
router.back();
}, [router]);
// ===== 폼 콘텐츠 렌더링 =====
const renderFormContent = useCallback(() => (
<>
{/* 폼 카드 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-1">
<span className="text-red-500">*</span>
</CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* 게시판 선택 */}
<div className="space-y-2">
<Label htmlFor="board">
<span className="text-red-500">*</span>
</Label>
<Select
value={boardCode}
onValueChange={setBoardCode}
disabled={isBoardsLoading || mode === 'edit'}
>
<SelectTrigger className={errors.boardCode ? 'border-red-500' : ''}>
<SelectValue placeholder={isBoardsLoading ? '로딩중...' : '게시판을 선택해주세요'} />
</SelectTrigger>
<SelectContent>
{boards.map((board) => (
<SelectItem key={board.id} value={board.boardCode}>
{board.boardName}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.boardCode && (
<p className="text-sm text-red-500">{errors.boardCode}</p>
)}
</div>
{/* 상단 노출 */}
<div className="space-y-2">
<Label> </Label>
<RadioGroup
value={isPinned}
onValueChange={handlePinnedChange}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="false" id="pinned-false" />
<Label htmlFor="pinned-false" className="font-normal cursor-pointer">
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="true" id="pinned-true" />
<Label htmlFor="pinned-true" className="font-normal cursor-pointer">
</Label>
</div>
</RadioGroup>
</div>
{/* 제목 */}
<div className="space-y-2">
<Label htmlFor="title">
<span className="text-red-500">*</span>
</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="제목을 입력해주세요"
className={errors.title ? 'border-red-500' : ''}
/>
{errors.title && (
<p className="text-sm text-red-500">{errors.title}</p>
)}
</div>
{/* 내용 (에디터) */}
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<RichTextEditor
value={content}
onChange={setContent}
placeholder="내용을 입력해주세요"
minHeight="300px"
className={errors.content ? 'border-red-500' : ''}
/>
{errors.content && (
<p className="text-sm text-red-500">{errors.content}</p>
)}
</div>
{/* 첨부파일 */}
<div className="space-y-2">
<Label></Label>
<FileDropzone
onFilesSelect={handleFilesSelect}
multiple
maxSize={10}
compact
title="클릭하거나 파일을 드래그하세요"
description="최대 10MB"
/>
<FileList
files={attachments}
existingFiles={existingAttachments}
onRemove={handleRemoveFile}
onRemoveExisting={handleRemoveExistingFile}
compact
/>
</div>
{/* 작성자 (읽기 전용) */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input
value={CURRENT_USER.name}
disabled
className="bg-gray-50"
/>
</div>
{/* 등록일시 (수정 모드에서만) */}
{mode === 'edit' && initialData && (
<div className="space-y-2">
<Label></Label>
<Input
value={format(new Date(initialData.createdAt), 'yyyy-MM-dd HH:mm')}
disabled
className="bg-gray-50"
/>
</div>
)}
</div>
{/* 댓글 */}
<div className="space-y-2">
<Label></Label>
<RadioGroup
value={allowComments}
onValueChange={setAllowComments}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="false" id="comments-false" />
<Label htmlFor="comments-false" className="font-normal cursor-pointer">
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="true" id="comments-true" />
<Label htmlFor="comments-true" className="font-normal cursor-pointer">
</Label>
</div>
</RadioGroup>
</div>
{/* 등록일시 (등록 모드) */}
{mode === 'create' && (
<div className="space-y-2">
<Label></Label>
<Input
value={format(new Date(), 'yyyy-MM-dd HH:mm')}
disabled
className="bg-gray-50"
/>
</div>
)}
</CardContent>
</Card>
{/* 상단 노출 초과 Alert */}
<AlertDialog open={showPinnedAlert} onOpenChange={setShowPinnedAlert}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
5 .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setShowPinnedAlert(false)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
), [
boardCode, isPinned, title, content, allowComments, errors, boards,
isBoardsLoading, mode, initialData, attachments, existingAttachments,
showPinnedAlert, handlePinnedChange, handleFilesSelect,
handleRemoveFile, handleRemoveExistingFile,
]);
// Config 선택 (create/edit)
const config = mode === 'create' ? boardCreateConfig : boardEditConfig;
return (
<IntegratedDetailTemplate
config={config}
mode={mode}
isLoading={isBoardsLoading}
isSubmitting={isSubmitting}
onBack={handleCancel}
onCancel={handleCancel}
onSubmit={handleSubmit}
renderForm={renderFormContent}
/>
);
}
export default BoardForm;