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>
This commit is contained in:
유병철
2026-01-22 15:07:17 +09:00
parent 6fa69d81f4
commit 9464a368ba
48 changed files with 3900 additions and 4063 deletions

View File

@@ -10,11 +10,13 @@
* - 필드: 게시판, 상단 노출, 제목, 내용(에디터), 첨부파일, 작성자, 댓글, 등록일시
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
import { Upload, X, File, Loader2 } from 'lucide-react';
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';
@@ -70,7 +72,6 @@ const MAX_PINNED_COUNT = 5;
export function BoardForm({ mode, initialData }: BoardFormProps) {
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
// ===== 폼 상태 =====
const [boardCode, setBoardCode] = useState(initialData?.boardCode || '');
@@ -78,9 +79,14 @@ export function BoardForm({ mode, initialData }: BoardFormProps) {
const [title, setTitle] = useState(initialData?.title || '');
const [content, setContent] = useState(initialData?.content || '');
const [allowComments, setAllowComments] = useState(initialData?.allowComments ? 'true' : 'false');
const [attachments, setAttachments] = useState<File[]>([]);
const [existingAttachments, setExistingAttachments] = useState<Attachment[]>(
initialData?.attachments || []
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
@@ -128,22 +134,16 @@ export function BoardForm({ mode, initialData }: BoardFormProps) {
}, []);
// ===== 파일 업로드 핸들러 =====
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files) {
setAttachments((prev) => [...prev, ...Array.from(files)]);
}
// 파일 input 초기화
if (fileInputRef.current) {
fileInputRef.current.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) => {
const handleRemoveExistingFile = useCallback((id: string | number) => {
setExistingAttachments((prev) => prev.filter((a) => a.id !== id));
}, []);
@@ -206,13 +206,6 @@ export function BoardForm({ mode, initialData }: BoardFormProps) {
router.back();
}, [router]);
// 파일 크기 포맷
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
// ===== 폼 콘텐츠 렌더링 =====
const renderFormContent = useCallback(() => (
<>
@@ -314,80 +307,21 @@ export function BoardForm({ mode, initialData }: BoardFormProps) {
{/* 첨부파일 */}
<div className="space-y-2">
<Label></Label>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-4 w-4 mr-2" />
</Button>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
className="hidden"
/>
</div>
{/* 기존 파일 목록 */}
{existingAttachments.length > 0 && (
<div className="space-y-2 mt-3">
{existingAttachments.map((file) => (
<div
key={file.id}
className="flex items-center justify-between p-2 bg-gray-50 rounded-md"
>
<div className="flex items-center gap-2">
<File className="h-4 w-4 text-gray-500" />
<span className="text-sm">{file.fileName}</span>
<span className="text-xs text-gray-400">
({formatFileSize(file.fileSize)})
</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveExistingFile(file.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{/* 새로 추가된 파일 목록 */}
{attachments.length > 0 && (
<div className="space-y-2 mt-3">
{attachments.map((file, index) => (
<div
key={index}
className="flex items-center justify-between p-2 bg-blue-50 rounded-md"
>
<div className="flex items-center gap-2">
<File className="h-4 w-4 text-blue-500" />
<span className="text-sm">{file.name}</span>
<span className="text-xs text-gray-400">
({formatFileSize(file.size)})
</span>
<span className="text-xs text-blue-500">( )</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveFile(index)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
<FileDropzone
onFilesSelect={handleFilesSelect}
multiple
maxSize={10}
compact
title="클릭하거나 파일을 드래그하세요"
description="최대 10MB"
/>
<FileList
files={attachments}
existingFiles={existingAttachments}
onRemove={handleRemoveFile}
onRemoveExisting={handleRemoveExistingFile}
compact
/>
</div>
{/* 작성자 (읽기 전용) */}
@@ -471,7 +405,7 @@ export function BoardForm({ mode, initialData }: BoardFormProps) {
), [
boardCode, isPinned, title, content, allowComments, errors, boards,
isBoardsLoading, mode, initialData, attachments, existingAttachments,
showPinnedAlert, formatFileSize, handlePinnedChange, handleFileSelect,
showPinnedAlert, handlePinnedChange, handleFilesSelect,
handleRemoveFile, handleRemoveExistingFile,
]);