feat: 신규 페이지 구현 및 HR/설정 기능 개선
신규 페이지: - 회계관리: 거래처, 예상비용, 청구서, 발주서 - 게시판: 공지사항, 자료실, 커뮤니티 - 고객센터: 문의/FAQ - 설정: 계정, 알림, 출퇴근, 팝업, 구독, 결제내역 - 리포트 (차트 시각화) - 개발자 테스트 URL 페이지 기능 개선: - HR 직원관리/휴가관리/카드관리 강화 - IntegratedListTemplateV2 확장 - AuthenticatedLayout 패딩 표준화 - 로그인 페이지 UI 개선 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
228
src/components/board/BoardDetail/index.tsx
Normal file
228
src/components/board/BoardDetail/index.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 게시글 상세 보기 컴포넌트
|
||||
*
|
||||
* 디자인 스펙 기준:
|
||||
* - 페이지 타이틀: 게시글 상세
|
||||
* - 페이지 설명: 게시글을 조회합니다.
|
||||
* - 삭제/수정 버튼 (본인 글만 표시)
|
||||
* - 게시판명 라벨
|
||||
* - 제목
|
||||
* - 메타 정보 (작성자 | 날짜 | 조회수)
|
||||
* - 내용 (HTML 렌더링)
|
||||
* - 첨부파일 다운로드 링크
|
||||
* - 댓글 섹션 (댓글 사용함 설정 시만 표시)
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import { FileText, Download, ArrowLeft } from 'lucide-react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { CommentSection } from '../CommentSection';
|
||||
import type { Post, Comment } from '../types';
|
||||
|
||||
interface BoardDetailProps {
|
||||
post: Post;
|
||||
comments: Comment[];
|
||||
currentUserId: string;
|
||||
}
|
||||
|
||||
export function BoardDetail({ post, comments: initialComments, currentUserId }: BoardDetailProps) {
|
||||
const router = useRouter();
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [comments, setComments] = useState<Comment[]>(initialComments);
|
||||
|
||||
const isMyPost = post.authorId === currentUserId;
|
||||
|
||||
// ===== 액션 핸들러 =====
|
||||
const handleBack = useCallback(() => {
|
||||
router.push('/ko/board');
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
router.push(`/ko/board/${post.id}/edit`);
|
||||
}, [router, post.id]);
|
||||
|
||||
const handleConfirmDelete = useCallback(() => {
|
||||
// TODO: API 호출
|
||||
console.log('Delete post:', post.id);
|
||||
setShowDeleteDialog(false);
|
||||
router.push('/ko/board');
|
||||
}, [post.id, router]);
|
||||
|
||||
// ===== 댓글 핸들러 =====
|
||||
const handleAddComment = useCallback((content: string) => {
|
||||
const newComment: Comment = {
|
||||
id: `comment-${Date.now()}`,
|
||||
postId: post.id,
|
||||
authorId: currentUserId,
|
||||
authorName: '홍길동', // 실제로는 사용자 정보에서 가져옴
|
||||
authorDepartment: '개발팀',
|
||||
authorPosition: '과장',
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
setComments((prev) => [...prev, newComment]);
|
||||
// TODO: API 호출
|
||||
}, [post.id, currentUserId]);
|
||||
|
||||
const handleUpdateComment = useCallback((id: string, content: string) => {
|
||||
setComments((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === id ? { ...c, content, updatedAt: new Date().toISOString() } : c
|
||||
)
|
||||
);
|
||||
// TODO: API 호출
|
||||
}, []);
|
||||
|
||||
const handleDeleteComment = useCallback((id: string) => {
|
||||
setComments((prev) => prev.filter((c) => c.id !== id));
|
||||
// TODO: API 호출
|
||||
}, []);
|
||||
|
||||
// 파일 크기 포맷
|
||||
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`;
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
<PageHeader
|
||||
title="게시글 상세"
|
||||
description="게시글을 조회합니다."
|
||||
icon={FileText}
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
{isMyPost && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-red-500 border-red-200 hover:bg-red-50 hover:text-red-600"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>수정</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 게시글 카드 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="pb-4">
|
||||
{/* 게시판명 라벨 */}
|
||||
<Badge variant="secondary" className="w-fit mb-2">
|
||||
{post.boardName}
|
||||
</Badge>
|
||||
|
||||
{/* 제목 */}
|
||||
<h2 className="text-xl font-bold text-gray-900">{post.title}</h2>
|
||||
|
||||
{/* 메타 정보: 작성자 | 날짜 | 조회수 */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mt-2">
|
||||
<span>{post.authorName}</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span>{format(new Date(post.createdAt), 'yyyy-MM-dd HH:mm')}</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span>조회수 {post.viewCount}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* 내용 (HTML 렌더링) */}
|
||||
<div
|
||||
className="prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: post.content }}
|
||||
/>
|
||||
|
||||
{/* 첨부파일 */}
|
||||
{post.attachments.length > 0 && (
|
||||
<div className="space-y-2 pt-4 border-t border-gray-100">
|
||||
{post.attachments.map((file) => (
|
||||
<a
|
||||
key={file.id}
|
||||
href={file.fileUrl}
|
||||
download={file.fileName}
|
||||
className="flex items-center gap-2 p-2 bg-gray-50 rounded-md hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-sm text-blue-600 hover:underline">
|
||||
{file.fileName}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
({formatFileSize(file.fileSize)})
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 댓글 섹션 (댓글 사용함 설정 시만 표시) */}
|
||||
{post.allowComments && (
|
||||
<CommentSection
|
||||
postId={post.id}
|
||||
comments={comments}
|
||||
currentUserId={currentUserId}
|
||||
onAddComment={handleAddComment}
|
||||
onUpdateComment={handleUpdateComment}
|
||||
onDeleteComment={handleDeleteComment}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>게시글 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
정말 삭제하시겠습니까?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default BoardDetail;
|
||||
447
src/components/board/BoardForm/index.tsx
Normal file
447
src/components/board/BoardForm/index.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 게시글 등록/수정 폼 컴포넌트
|
||||
*
|
||||
* 디자인 스펙 기준:
|
||||
* - 페이지 타이틀: 게시글 상세
|
||||
* - 페이지 설명: 게시글을 등록하고 관리합니다.
|
||||
* - 필드: 게시판, 상단 노출, 제목, 내용(에디터), 첨부파일, 작성자, 댓글, 등록일시
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import { FileText, Upload, X, File, ArrowLeft, Save } from 'lucide-react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
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 { MOCK_BOARDS } from '../types';
|
||||
|
||||
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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ===== 폼 상태 =====
|
||||
const [boardId, setBoardId] = useState(initialData?.boardId || '');
|
||||
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<File[]>([]);
|
||||
const [existingAttachments, setExistingAttachments] = useState<Attachment[]>(
|
||||
initialData?.attachments || []
|
||||
);
|
||||
|
||||
// 상단 노출 초과 Alert
|
||||
const [showPinnedAlert, setShowPinnedAlert] = useState(false);
|
||||
|
||||
// 유효성 에러
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 게시판 목록 (all 제외)
|
||||
const boardOptions = MOCK_BOARDS.filter((b) => b.id !== 'all');
|
||||
|
||||
// ===== 상단 노출 변경 핸들러 =====
|
||||
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 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 handleRemoveFile = useCallback((index: number) => {
|
||||
setAttachments((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const handleRemoveExistingFile = useCallback((id: string) => {
|
||||
setExistingAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
}, []);
|
||||
|
||||
// ===== 유효성 검사 =====
|
||||
const validate = useCallback(() => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!boardId) {
|
||||
newErrors.boardId = '게시판을 선택해주세요.';
|
||||
}
|
||||
if (!title.trim()) {
|
||||
newErrors.title = '제목을 입력해주세요.';
|
||||
}
|
||||
if (!content.trim() || content === '<p></p>') {
|
||||
newErrors.content = '내용을 입력해주세요.';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}, [boardId, title, content]);
|
||||
|
||||
// ===== 저장 핸들러 =====
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!validate()) return;
|
||||
|
||||
const formData = {
|
||||
boardId,
|
||||
title,
|
||||
content,
|
||||
isPinned: isPinned === 'true',
|
||||
allowComments: allowComments === 'true',
|
||||
attachments,
|
||||
existingAttachments,
|
||||
};
|
||||
|
||||
console.log('Submit:', mode, formData);
|
||||
|
||||
// TODO: API 호출
|
||||
|
||||
// 목록으로 이동
|
||||
router.push('/ko/board');
|
||||
}, [boardId, title, content, isPinned, allowComments, attachments, existingAttachments, mode, router, validate]);
|
||||
|
||||
// ===== 취소 핸들러 =====
|
||||
const handleCancel = useCallback(() => {
|
||||
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`;
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
<PageHeader
|
||||
title={mode === 'create' ? '게시글 등록' : '게시글 수정'}
|
||||
description="게시글을 등록하고 관리합니다."
|
||||
icon={FileText}
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{mode === 'create' ? '등록' : '수정'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 폼 카드 */}
|
||||
<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={boardId} onValueChange={setBoardId}>
|
||||
<SelectTrigger className={errors.boardId ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="게시판을 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{boardOptions.map((board) => (
|
||||
<SelectItem key={board.id} value={board.id}>
|
||||
{board.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.boardId && (
|
||||
<p className="text-sm text-red-500">{errors.boardId}</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>
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default BoardForm;
|
||||
471
src/components/board/BoardList/index.tsx
Normal file
471
src/components/board/BoardList/index.tsx
Normal file
@@ -0,0 +1,471 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 게시판 리스트 컴포넌트
|
||||
*
|
||||
* 디자인 스펙 기준:
|
||||
* - 페이지 타이틀: 게시판
|
||||
* - 페이지 설명: 게시판의 게시글을 등록하고 관리합니다.
|
||||
* - 테이블 컬럼: No., 제목, 작성자, 등록일, 조회수
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import { FileText, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
type TabOption,
|
||||
type TableColumn,
|
||||
} from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||
import type { Post, SortOption, BoardFilter } from '../types';
|
||||
import { BOARD_FILTER_OPTIONS, MOCK_BOARDS } from '../types';
|
||||
|
||||
// ===== Mock 데이터 생성 =====
|
||||
const generateMockPosts = (): Post[] => {
|
||||
const authors = [
|
||||
{ id: 'user1', name: '홍길동', department: '개발팀', position: '과장' },
|
||||
{ id: 'user2', name: '김철수', department: '인사팀', position: '대리' },
|
||||
{ id: 'user3', name: '이영희', department: '총무팀', position: '사원' },
|
||||
{ id: 'user4', name: '박지민', department: '마케팅팀', position: '차장' },
|
||||
];
|
||||
const titles = [
|
||||
'제목',
|
||||
'2025년 1분기 사업계획 공지',
|
||||
'시스템 점검 안내',
|
||||
'연말 휴가 일정 안내',
|
||||
'신규 프로젝트 착수 회의',
|
||||
];
|
||||
const boards = MOCK_BOARDS.filter((b) => b.id !== 'all');
|
||||
|
||||
return Array.from({ length: 50 }, (_, i) => {
|
||||
const author = authors[i % authors.length];
|
||||
const board = boards[i % boards.length];
|
||||
const dayOffset = i % 30;
|
||||
|
||||
return {
|
||||
id: `post-${i + 1}`,
|
||||
boardId: board.id,
|
||||
boardName: board.name,
|
||||
title: titles[i % titles.length],
|
||||
content: `<p>게시글 내용 ${i + 1}입니다. 이것은 테스트용 콘텐츠입니다.</p>`,
|
||||
authorId: author.id,
|
||||
authorName: author.name,
|
||||
authorDepartment: author.department,
|
||||
authorPosition: author.position,
|
||||
isPinned: i < 3, // 상위 3개만 상단 고정
|
||||
allowComments: i % 2 === 0,
|
||||
viewCount: 100 + i * 7,
|
||||
attachments: i % 3 === 0 ? [
|
||||
{
|
||||
id: `file-${i}`,
|
||||
fileName: 'abc.pdf',
|
||||
fileSize: 1024000,
|
||||
fileUrl: '/files/abc.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
] : [],
|
||||
createdAt: format(new Date(2025, 8, dayOffset + 1, 12, 20), "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
updatedAt: format(new Date(2025, 8, dayOffset + 1, 12, 20), "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 현재 로그인 사용자 ID (실제로는 auth context에서 가져옴)
|
||||
const CURRENT_USER_ID = 'user1';
|
||||
|
||||
export function BoardList() {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 상태 관리 =====
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortOption] = useState<SortOption>('latest'); // 기본 정렬: 최신순
|
||||
const [boardFilter, setBoardFilter] = useState<BoardFilter>('notice');
|
||||
const [activeTab, setActiveTab] = useState<string>('notice'); // 첫 번째 탭: 공지사항
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 날짜 범위 상태
|
||||
const [startDate, setStartDate] = useState('2025-09-01');
|
||||
const [endDate, setEndDate] = useState('2025-09-03');
|
||||
|
||||
// 삭제 다이얼로그
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
|
||||
// Mock 데이터
|
||||
const [data, setData] = useState<Post[]>(generateMockPosts);
|
||||
|
||||
// ===== 체크박스 핸들러 =====
|
||||
const toggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) newSet.delete(id);
|
||||
else newSet.add(id);
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === filteredData.length && filteredData.length > 0) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(filteredData.map((item) => item.id)));
|
||||
}
|
||||
}, [selectedItems.size]);
|
||||
|
||||
// ===== 필터링된 데이터 =====
|
||||
const filteredData = useMemo(() => {
|
||||
let result = data.filter((item) =>
|
||||
item.title.includes(searchQuery) ||
|
||||
item.authorName.includes(searchQuery)
|
||||
);
|
||||
|
||||
// 탭 필터 (게시판 종류 또는 나의 게시글)
|
||||
if (activeTab === 'my') {
|
||||
// 나의 게시글 탭: 현재 사용자 게시글만
|
||||
result = result.filter((item) => item.authorId === CURRENT_USER_ID);
|
||||
} else if (activeTab !== 'all') {
|
||||
// 특정 게시판 탭
|
||||
result = result.filter((item) => item.boardId === activeTab);
|
||||
}
|
||||
|
||||
// 게시판 필터 드롭다운 (탭과 별개로 추가 필터링)
|
||||
if (boardFilter === 'my') {
|
||||
result = result.filter((item) => item.authorId === CURRENT_USER_ID);
|
||||
} else if (boardFilter !== 'all' && boardFilter !== 'notice') {
|
||||
result = result.filter((item) => item.boardId === boardFilter);
|
||||
}
|
||||
|
||||
// 정렬 (상단 고정은 항상 최상위)
|
||||
result.sort((a, b) => {
|
||||
// 상단 고정 우선
|
||||
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
|
||||
|
||||
// 정렬 옵션에 따라
|
||||
switch (sortOption) {
|
||||
case 'latest':
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
case 'oldest':
|
||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
case 'viewCount':
|
||||
return b.viewCount - a.viewCount;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [data, searchQuery, activeTab, boardFilter, sortOption]);
|
||||
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
return filteredData.slice(startIndex, startIndex + itemsPerPage);
|
||||
}, [filteredData, currentPage, itemsPerPage]);
|
||||
|
||||
const totalPages = Math.ceil(filteredData.length / itemsPerPage);
|
||||
|
||||
// ===== 액션 핸들러 =====
|
||||
const handleRowClick = useCallback((item: Post) => {
|
||||
router.push(`/ko/board/${item.id}`);
|
||||
}, [router]);
|
||||
|
||||
const handleNewPost = useCallback(() => {
|
||||
router.push('/ko/board/create');
|
||||
}, [router]);
|
||||
|
||||
const handleDeleteClick = useCallback((id: string) => {
|
||||
setDeleteTargetId(id);
|
||||
setShowDeleteDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(() => {
|
||||
if (deleteTargetId) {
|
||||
setData((prev) => prev.filter((item) => item.id !== deleteTargetId));
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(deleteTargetId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
setShowDeleteDialog(false);
|
||||
setDeleteTargetId(null);
|
||||
}, [deleteTargetId]);
|
||||
|
||||
// ===== 탭 옵션 (공지사항 고정 + 동적 게시판들 + 나의 게시글 고정) =====
|
||||
const tabs: TabOption[] = useMemo(() => {
|
||||
// 동적으로 생성되는 게시판들 (공지사항, 전체, 나의게시글 제외)
|
||||
const dynamicBoards = MOCK_BOARDS.filter(
|
||||
(b) => b.id !== 'all' && b.id !== 'notice' && b.id !== 'my'
|
||||
);
|
||||
|
||||
return [
|
||||
// 첫 번째: 공지사항 (고정)
|
||||
{
|
||||
value: 'notice',
|
||||
label: '공지사항',
|
||||
count: data.filter((p) => p.boardId === 'notice').length,
|
||||
},
|
||||
// 중간: 동적 게시판들
|
||||
...dynamicBoards.map((board) => ({
|
||||
value: board.id,
|
||||
label: board.name,
|
||||
count: data.filter((p) => p.boardId === board.id).length,
|
||||
})),
|
||||
// 마지막: 나의 게시글 (고정)
|
||||
{
|
||||
value: 'my',
|
||||
label: '나의 게시글',
|
||||
count: data.filter((p) => p.authorId === CURRENT_USER_ID).length,
|
||||
},
|
||||
];
|
||||
}, [data]);
|
||||
|
||||
// ===== 테이블 컬럼 (스크린샷 기준: No., 제목, 작성자, 등록일, 조회수) =====
|
||||
const tableColumns: TableColumn[] = useMemo(() => [
|
||||
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
|
||||
{ key: 'title', label: '제목', className: 'min-w-[300px]' },
|
||||
{ key: 'author', label: '작성자', className: 'w-[120px]' },
|
||||
{ key: 'createdAt', label: '등록일', className: 'w-[120px]' },
|
||||
{ key: 'viewCount', label: '조회수', className: 'w-[80px] text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[100px] text-center' },
|
||||
], []);
|
||||
|
||||
// ===== 테이블 행 렌더링 =====
|
||||
const renderTableRow = useCallback((item: Post, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
const isMyPost = item.authorId === CURRENT_USER_ID;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={isSelected} onCheckedChange={() => toggleSelection(item.id)} />
|
||||
</TableCell>
|
||||
{/* No. */}
|
||||
<TableCell className="text-center text-sm text-gray-500">
|
||||
{item.isPinned ? (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 bg-red-500 text-white rounded-full text-xs font-bold">
|
||||
{globalIndex}
|
||||
</span>
|
||||
) : (
|
||||
globalIndex
|
||||
)}
|
||||
</TableCell>
|
||||
{/* 제목 */}
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.isPinned && (
|
||||
<span className="text-xs text-red-500 font-medium">[공지]</span>
|
||||
)}
|
||||
<span className="truncate">{item.title}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* 작성자 */}
|
||||
<TableCell>{item.authorName}</TableCell>
|
||||
{/* 등록일 */}
|
||||
<TableCell>{format(new Date(item.createdAt), 'yyyy-MM-dd')}</TableCell>
|
||||
{/* 조회수 */}
|
||||
<TableCell className="text-center">{item.viewCount}</TableCell>
|
||||
{/* 작업 */}
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
{isSelected && isMyPost && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-gray-600 hover:text-gray-700 hover:bg-gray-50"
|
||||
onClick={() => router.push(`/ko/board/${item.id}/edit`)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={() => handleDeleteClick(item.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}, [selectedItems, toggleSelection, handleRowClick, handleDeleteClick, router]);
|
||||
|
||||
// ===== 모바일 카드 렌더링 =====
|
||||
const renderMobileCard = useCallback((
|
||||
item: Post,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
const isMyPost = item.authorId === CURRENT_USER_ID;
|
||||
|
||||
return (
|
||||
<ListMobileCard
|
||||
id={item.id}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
{item.isPinned && (
|
||||
<span className="text-xs text-red-500 font-medium">[공지]</span>
|
||||
)}
|
||||
<span>{item.title}</span>
|
||||
</div>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InfoField label="작성자" value={item.authorName} />
|
||||
<InfoField label="등록일" value={format(new Date(item.createdAt), 'yyyy-MM-dd')} />
|
||||
<InfoField label="조회수" value={String(item.viewCount)} />
|
||||
<InfoField label="게시판" value={item.boardName} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
isSelected && isMyPost ? (
|
||||
<div className="flex gap-2 w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => router.push(`/ko/board/${item.id}/edit`)}
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-2" /> 수정
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 text-red-500 border-red-200 hover:bg-red-50 hover:text-red-600"
|
||||
onClick={() => handleDeleteClick(item.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" /> 삭제
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => handleRowClick(item)}
|
||||
/>
|
||||
);
|
||||
}, [handleRowClick, handleDeleteClick, router]);
|
||||
|
||||
// ===== 헤더 액션 =====
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
extraActions={
|
||||
<Button onClick={handleNewPost}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
게시글 등록
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
// ===== 테이블 헤더 액션 (필터 1개만: 게시판) =====
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 게시판 필터 */}
|
||||
<Select value={boardFilter} onValueChange={(value) => setBoardFilter(value as BoardFilter)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="게시판" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BOARD_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2<Post>
|
||||
title="게시판"
|
||||
description="게시판의 게시글을 등록하고 관리합니다."
|
||||
icon={FileText}
|
||||
headerActions={headerActions}
|
||||
searchValue={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
searchPlaceholder="제목, 작성자 검색..."
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
totalCount={filteredData.length}
|
||||
allData={filteredData}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={toggleSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: filteredData.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>게시글 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
정말 삭제하시겠습니까?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BoardList;
|
||||
116
src/components/board/BoardManagement/BoardDetail.tsx
Normal file
116
src/components/board/BoardManagement/BoardDetail.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ClipboardList, ArrowLeft, Edit, Trash2 } from 'lucide-react';
|
||||
import type { Board } from './types';
|
||||
import {
|
||||
BOARD_STATUS_LABELS,
|
||||
BOARD_STATUS_COLORS,
|
||||
BOARD_TARGET_LABELS,
|
||||
} from './types';
|
||||
|
||||
interface BoardDetailProps {
|
||||
board: Board;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
// 날짜/시간 포맷
|
||||
const formatDateTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
export function BoardDetail({ board, onEdit, onDelete }: BoardDetailProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleBack = () => {
|
||||
router.push('/ko/board/board-management');
|
||||
};
|
||||
|
||||
// 대상 표시 텍스트
|
||||
const getTargetDisplay = () => {
|
||||
if (board.target === 'all') {
|
||||
return BOARD_TARGET_LABELS.all;
|
||||
}
|
||||
return board.targetName || BOARD_TARGET_LABELS.department;
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="게시판관리 상세"
|
||||
description="게시판 목록을 관리합니다"
|
||||
icon={ClipboardList}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 게시판 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">게시판 정보</CardTitle>
|
||||
<Badge className={BOARD_STATUS_COLORS[board.status]}>
|
||||
{BOARD_STATUS_LABELS[board.status]}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">대상</dt>
|
||||
<dd className="text-sm mt-1">{getTargetDisplay()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">작성자</dt>
|
||||
<dd className="text-sm mt-1">{board.authorName}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">게시판명</dt>
|
||||
<dd className="text-sm mt-1">{board.boardName}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">상태</dt>
|
||||
<dd className="text-sm mt-1">
|
||||
<Badge className={BOARD_STATUS_COLORS[board.status]}>
|
||||
{BOARD_STATUS_LABELS[board.status]}
|
||||
</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">등록일시</dt>
|
||||
<dd className="text-sm mt-1">{formatDateTime(board.createdAt)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={onDelete} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={onEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
221
src/components/board/BoardManagement/BoardForm.tsx
Normal file
221
src/components/board/BoardManagement/BoardForm.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
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 { ClipboardList, ArrowLeft, Save } from 'lucide-react';
|
||||
import type { Board, BoardFormData, BoardTarget, BoardStatus } from './types';
|
||||
import { BOARD_TARGETS, MOCK_DEPARTMENTS, BOARD_STATUS_LABELS } from './types';
|
||||
|
||||
interface BoardFormProps {
|
||||
mode: 'create' | 'edit';
|
||||
board?: Board;
|
||||
onSubmit: (data: BoardFormData) => void;
|
||||
}
|
||||
|
||||
// 날짜/시간 포맷
|
||||
const formatDateTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
// 현재 날짜/시간
|
||||
const getCurrentDateTime = (): string => {
|
||||
return formatDateTime(new Date().toISOString());
|
||||
};
|
||||
|
||||
export function BoardForm({ mode, board, onSubmit }: BoardFormProps) {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState<BoardFormData>({
|
||||
target: 'all',
|
||||
targetName: '',
|
||||
boardName: '',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// 수정 모드일 때 기존 데이터 로드
|
||||
useEffect(() => {
|
||||
if (mode === 'edit' && board) {
|
||||
setFormData({
|
||||
target: board.target,
|
||||
targetName: board.targetName || '',
|
||||
boardName: board.boardName,
|
||||
status: board.status,
|
||||
});
|
||||
}
|
||||
}, [mode, board]);
|
||||
|
||||
const handleBack = () => {
|
||||
if (mode === 'edit' && board) {
|
||||
router.push(`/ko/board/board-management/${board.id}`);
|
||||
} else {
|
||||
router.push('/ko/board/board-management');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
const handleTargetChange = (value: BoardTarget) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
target: value,
|
||||
targetName: value === 'all' ? '' : prev.targetName,
|
||||
}));
|
||||
};
|
||||
|
||||
// 작성자 (현재 로그인한 사용자 - mock)
|
||||
const currentUser = '홍길동';
|
||||
|
||||
// 등록일시
|
||||
const registeredAt = mode === 'edit' && board ? formatDateTime(board.createdAt) : getCurrentDateTime();
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={mode === 'create' ? '게시판관리 상세' : '게시판관리 상세'}
|
||||
description="게시판 목록을 관리합니다"
|
||||
icon={ClipboardList}
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 게시판 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">게시판 정보 *</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 대상 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="target">대상</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={formData.target}
|
||||
onValueChange={(value) => handleTargetChange(value as BoardTarget)}
|
||||
>
|
||||
<SelectTrigger id="target" className="w-[120px]">
|
||||
<SelectValue placeholder="대상 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BOARD_TARGETS.map((target) => (
|
||||
<SelectItem key={target.value} value={target.value}>
|
||||
{target.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formData.target === 'department' && (
|
||||
<Select
|
||||
value={formData.targetName}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, targetName: value }))}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="부서 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_DEPARTMENTS.map((dept) => (
|
||||
<SelectItem key={dept.id} value={dept.name}>
|
||||
{dept.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 작성자 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="author">작성자</Label>
|
||||
<Input
|
||||
id="author"
|
||||
value={mode === 'edit' && board ? board.authorName : currentUser}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 게시판명 */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="boardName">게시판명</Label>
|
||||
<Input
|
||||
id="boardName"
|
||||
value={formData.boardName}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, boardName: e.target.value }))}
|
||||
placeholder="게시판명을 입력해주세요"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상태 */}
|
||||
<div className="space-y-2">
|
||||
<Label>상태</Label>
|
||||
<RadioGroup
|
||||
value={formData.status}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, status: value as BoardStatus }))}
|
||||
className="flex items-center gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="inactive" id="inactive" />
|
||||
<Label htmlFor="inactive" className="font-normal cursor-pointer">
|
||||
{BOARD_STATUS_LABELS.inactive}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="active" id="active" />
|
||||
<Label htmlFor="active" className="font-normal cursor-pointer">
|
||||
{BOARD_STATUS_LABELS.active}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 등록일시 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="registeredAt">등록일시</Label>
|
||||
<Input
|
||||
id="registeredAt"
|
||||
value={registeredAt}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button type="button" variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{mode === 'create' ? '등록' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
467
src/components/board/BoardManagement/index.tsx
Normal file
467
src/components/board/BoardManagement/index.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ClipboardList, Edit, Trash2, Plus } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
type TabOption,
|
||||
type TableColumn,
|
||||
} from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||
import type { Board, BoardStatus } from './types';
|
||||
import {
|
||||
BOARD_STATUS_LABELS,
|
||||
BOARD_STATUS_COLORS,
|
||||
BOARD_TARGET_LABELS,
|
||||
} from './types';
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
// Mock 데이터
|
||||
const mockBoards: Board[] = [
|
||||
{
|
||||
id: '1',
|
||||
target: 'all',
|
||||
boardName: '게시판명',
|
||||
status: 'active',
|
||||
authorId: 'u1',
|
||||
authorName: '홍길동',
|
||||
createdAt: '2025-09-03T00:00:00Z',
|
||||
updatedAt: '2025-09-03T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
target: 'all',
|
||||
boardName: '게시판명',
|
||||
status: 'active',
|
||||
authorId: 'u1',
|
||||
authorName: '홍길동',
|
||||
createdAt: '2025-09-03T00:00:00Z',
|
||||
updatedAt: '2025-09-03T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
target: 'department',
|
||||
targetName: '부서명',
|
||||
boardName: '게시판명',
|
||||
status: 'active',
|
||||
authorId: 'u1',
|
||||
authorName: '홍길동',
|
||||
createdAt: '2025-09-03T00:00:00Z',
|
||||
updatedAt: '2025-09-03T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
target: 'all',
|
||||
boardName: '게시판명',
|
||||
status: 'active',
|
||||
authorId: 'u1',
|
||||
authorName: '홍길동',
|
||||
createdAt: '2025-09-03T00:00:00Z',
|
||||
updatedAt: '2025-09-03T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
target: 'department',
|
||||
targetName: '팀명',
|
||||
boardName: '게시판명',
|
||||
status: 'inactive',
|
||||
authorId: 'u1',
|
||||
authorName: '홍길동',
|
||||
createdAt: '2025-09-03T00:00:00Z',
|
||||
updatedAt: '2025-09-03T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
target: 'all',
|
||||
boardName: '게시판명',
|
||||
status: 'active',
|
||||
authorId: 'u1',
|
||||
authorName: '홍길동',
|
||||
createdAt: '2025-09-03T00:00:00Z',
|
||||
updatedAt: '2025-09-03T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
target: 'all',
|
||||
boardName: '게시판명',
|
||||
status: 'active',
|
||||
authorId: 'u1',
|
||||
authorName: '홍길동',
|
||||
createdAt: '2025-09-03T00:00:00Z',
|
||||
updatedAt: '2025-09-03T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
// 추가 Mock 데이터 생성
|
||||
const generateMockBoards = (): Board[] => {
|
||||
const boards: Board[] = [...mockBoards];
|
||||
const targets: Array<'all' | 'department'> = ['all', 'department'];
|
||||
const departmentNames = ['영업부', '개발부', '인사부', '경영지원부'];
|
||||
const authorNames = ['홍길동', '김철수', '이영희', '박지훈'];
|
||||
|
||||
for (let i = 8; i <= 20; i++) {
|
||||
const target = targets[i % targets.length];
|
||||
const status: BoardStatus = i % 5 === 0 ? 'inactive' : 'active';
|
||||
boards.push({
|
||||
id: String(i),
|
||||
target,
|
||||
targetName: target === 'department' ? departmentNames[i % departmentNames.length] : undefined,
|
||||
boardName: `게시판${i}`,
|
||||
status,
|
||||
authorId: `u${(i % 4) + 1}`,
|
||||
authorName: authorNames[i % authorNames.length],
|
||||
createdAt: '2025-09-03T00:00:00Z',
|
||||
updatedAt: '2025-09-03T00:00:00Z',
|
||||
});
|
||||
}
|
||||
return boards;
|
||||
};
|
||||
|
||||
export function BoardManagement() {
|
||||
const router = useRouter();
|
||||
|
||||
// 게시판 데이터 상태
|
||||
const [boards, setBoards] = useState<Board[]>(generateMockBoards);
|
||||
|
||||
// 검색 및 필터 상태
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<string>('all');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [boardToDelete, setBoardToDelete] = useState<Board | null>(null);
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredBoards = useMemo(() => {
|
||||
let filtered = boards;
|
||||
|
||||
// 탭 필터 (상태)
|
||||
if (activeTab !== 'all') {
|
||||
filtered = filtered.filter(b => b.status === activeTab);
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
filtered = filtered.filter(b =>
|
||||
b.boardName.toLowerCase().includes(search) ||
|
||||
b.authorName.toLowerCase().includes(search) ||
|
||||
(b.targetName && b.targetName.toLowerCase().includes(search)) ||
|
||||
BOARD_TARGET_LABELS[b.target].toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [boards, activeTab, searchValue]);
|
||||
|
||||
// 페이지네이션된 데이터
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
return filteredBoards.slice(startIndex, startIndex + itemsPerPage);
|
||||
}, [filteredBoards, currentPage, itemsPerPage]);
|
||||
|
||||
// 통계 계산
|
||||
const stats = useMemo(() => {
|
||||
const activeCount = boards.filter(b => b.status === 'active').length;
|
||||
const inactiveCount = boards.filter(b => b.status === 'inactive').length;
|
||||
return { activeCount, inactiveCount };
|
||||
}, [boards]);
|
||||
|
||||
// 탭 옵션
|
||||
const tabs: TabOption[] = useMemo(() => [
|
||||
{ value: 'all', label: '전체', count: boards.length, color: 'gray' },
|
||||
{ value: 'active', label: '사용', count: stats.activeCount, color: 'green' },
|
||||
{ value: 'inactive', label: '미사용', count: stats.inactiveCount, color: 'red' },
|
||||
], [boards.length, stats]);
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = useMemo(() => [
|
||||
{ key: 'rowNumber', label: 'No.', className: 'w-[60px] text-center' },
|
||||
{ key: 'target', label: '대상', className: 'min-w-[100px]' },
|
||||
{ key: 'boardName', label: '게시판명', className: 'min-w-[150px]' },
|
||||
{ key: 'status', label: '상태', className: 'min-w-[80px]' },
|
||||
{ key: 'authorName', label: '작성자', className: 'min-w-[100px]' },
|
||||
{ key: 'createdAt', label: '등록일시', className: 'min-w-[120px]' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[100px] text-right' },
|
||||
], []);
|
||||
|
||||
// 체크박스 토글
|
||||
const toggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 전체 선택/해제
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length && paginatedData.length > 0) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
const allIds = new Set(paginatedData.map((item) => item.id));
|
||||
setSelectedItems(allIds);
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
|
||||
// 일괄 삭제 핸들러
|
||||
const handleBulkDelete = useCallback(() => {
|
||||
const ids = Array.from(selectedItems);
|
||||
setBoards(prev => prev.filter(board => !ids.includes(board.id)));
|
||||
setSelectedItems(new Set());
|
||||
}, [selectedItems]);
|
||||
|
||||
// 핸들러
|
||||
const handleAddBoard = useCallback(() => {
|
||||
router.push('/ko/board/board-management/new');
|
||||
}, [router]);
|
||||
|
||||
const handleDeleteBoard = useCallback(() => {
|
||||
if (boardToDelete) {
|
||||
setBoards(prev => prev.filter(board => board.id !== boardToDelete.id));
|
||||
setDeleteDialogOpen(false);
|
||||
setBoardToDelete(null);
|
||||
}
|
||||
}, [boardToDelete]);
|
||||
|
||||
const handleRowClick = useCallback((row: Board) => {
|
||||
router.push(`/ko/board/board-management/${row.id}`);
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback((id: string) => {
|
||||
router.push(`/ko/board/board-management/${id}/edit`);
|
||||
}, [router]);
|
||||
|
||||
const openDeleteDialog = useCallback((board: Board) => {
|
||||
setBoardToDelete(board);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// 대상 표시 텍스트
|
||||
const getTargetDisplay = (board: Board) => {
|
||||
if (board.target === 'all') {
|
||||
return BOARD_TARGET_LABELS.all;
|
||||
}
|
||||
return board.targetName || BOARD_TARGET_LABELS.department;
|
||||
};
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback((item: Board, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelection(item.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-center">
|
||||
{globalIndex}
|
||||
</TableCell>
|
||||
<TableCell>{getTargetDisplay(item)}</TableCell>
|
||||
<TableCell>{item.boardName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={BOARD_STATUS_COLORS[item.status]}>
|
||||
{BOARD_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{item.authorName}</TableCell>
|
||||
<TableCell>{formatDate(item.createdAt)}</TableCell>
|
||||
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||
{isSelected && (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(item.id)}
|
||||
title="수정"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openDeleteDialog(item)}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}, [selectedItems, toggleSelection, handleRowClick, handleEdit, openDeleteDialog]);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback((
|
||||
item: Board,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
id={item.id}
|
||||
title={item.boardName}
|
||||
headerBadges={
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
#{globalIndex}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getTargetDisplay(item)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
statusBadge={
|
||||
<Badge className={BOARD_STATUS_COLORS[item.status]}>
|
||||
{BOARD_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onCardClick={() => handleRowClick(item)}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="대상" value={getTargetDisplay(item)} />
|
||||
<InfoField label="작성자" value={item.authorName} />
|
||||
<InfoField label="등록일시" value={formatDate(item.createdAt)} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
isSelected ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => { e.stopPropagation(); handleEdit(item.id); }}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
|
||||
onClick={(e) => { e.stopPropagation(); openDeleteDialog(item); }}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}, [handleRowClick, handleEdit, openDeleteDialog]);
|
||||
|
||||
// 헤더 액션
|
||||
const headerActions = (
|
||||
<Button onClick={handleAddBoard}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
게시판 등록
|
||||
</Button>
|
||||
);
|
||||
|
||||
// 페이지네이션 설정
|
||||
const totalPages = Math.ceil(filteredBoards.length / itemsPerPage);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2<Board>
|
||||
title="게시판관리"
|
||||
description="게시판 목록을 관리합니다"
|
||||
icon={ClipboardList}
|
||||
headerActions={headerActions}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
searchPlaceholder="게시판명, 작성자, 대상 검색..."
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
totalCount={filteredBoards.length}
|
||||
allData={filteredBoards}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={toggleSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: filteredBoards.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>게시판 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{boardToDelete?.boardName}" 게시판을 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-destructive">
|
||||
삭제된 게시판 정보는 복구할 수 없습니다.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteBoard}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
58
src/components/board/BoardManagement/types.ts
Normal file
58
src/components/board/BoardManagement/types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// 게시판 상태 타입
|
||||
export type BoardStatus = 'active' | 'inactive';
|
||||
|
||||
// 대상 타입 (전사, 부서 등)
|
||||
export type BoardTarget = 'all' | 'department';
|
||||
|
||||
// 게시판 타입
|
||||
export interface Board {
|
||||
id: string;
|
||||
target: BoardTarget;
|
||||
targetName?: string; // 부서명 (target이 department일 때)
|
||||
boardName: string;
|
||||
status: BoardStatus;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 게시판 폼 데이터 타입
|
||||
export interface BoardFormData {
|
||||
target: BoardTarget;
|
||||
targetName?: string;
|
||||
boardName: string;
|
||||
status: BoardStatus;
|
||||
}
|
||||
|
||||
// 상태 라벨
|
||||
export const BOARD_STATUS_LABELS: Record<BoardStatus, string> = {
|
||||
active: '사용함',
|
||||
inactive: '사용안함',
|
||||
};
|
||||
|
||||
// 상태 색상
|
||||
export const BOARD_STATUS_COLORS: Record<BoardStatus, string> = {
|
||||
active: 'bg-green-100 text-green-800 hover:bg-green-100',
|
||||
inactive: 'bg-gray-100 text-gray-800 hover:bg-gray-100',
|
||||
};
|
||||
|
||||
// 대상 라벨
|
||||
export const BOARD_TARGET_LABELS: Record<BoardTarget, string> = {
|
||||
all: '전사',
|
||||
department: '부서',
|
||||
};
|
||||
|
||||
// 대상 옵션
|
||||
export const BOARD_TARGETS = [
|
||||
{ value: 'all' as BoardTarget, label: '전사' },
|
||||
{ value: 'department' as BoardTarget, label: '부서' },
|
||||
];
|
||||
|
||||
// Mock 부서 데이터
|
||||
export const MOCK_DEPARTMENTS = [
|
||||
{ id: 'd1', name: '영업부' },
|
||||
{ id: 'd2', name: '개발부' },
|
||||
{ id: 'd3', name: '인사부' },
|
||||
{ id: 'd4', name: '경영지원부' },
|
||||
];
|
||||
182
src/components/board/CommentSection/CommentItem.tsx
Normal file
182
src/components/board/CommentSection/CommentItem.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 개별 댓글 컴포넌트
|
||||
*
|
||||
* 디자인 스펙 기준:
|
||||
* - 프로필 이미지, 부서명 이름 직책, 등록일시, 댓글 내용
|
||||
* - 수정/삭제 버튼 (본인 댓글만)
|
||||
* - 수정 클릭 시 인풋박스에 기존 댓글 내용 입력 상태로 변경
|
||||
* - 삭제 클릭 시 "정말 삭제하시겠습니까?" 확인 Alert
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { User, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import type { Comment } from '../types';
|
||||
|
||||
interface CommentItemProps {
|
||||
comment: Comment;
|
||||
currentUserId: string;
|
||||
onUpdate: (id: string, content: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function CommentItem({
|
||||
comment,
|
||||
currentUserId,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: CommentItemProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState(comment.content);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
const isMyComment = comment.authorId === currentUserId;
|
||||
|
||||
// 수정 모드 토글
|
||||
const handleEditClick = useCallback(() => {
|
||||
setIsEditing(true);
|
||||
setEditContent(comment.content);
|
||||
}, [comment.content]);
|
||||
|
||||
// 수정 취소
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
setEditContent(comment.content);
|
||||
}, [comment.content]);
|
||||
|
||||
// 수정 저장
|
||||
const handleSaveEdit = useCallback(() => {
|
||||
if (editContent.trim()) {
|
||||
onUpdate(comment.id, editContent.trim());
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [comment.id, editContent, onUpdate]);
|
||||
|
||||
// 삭제 확인
|
||||
const handleConfirmDelete = useCallback(() => {
|
||||
onDelete(comment.id);
|
||||
setShowDeleteDialog(false);
|
||||
}, [comment.id, onDelete]);
|
||||
|
||||
// 작성자 정보 포맷
|
||||
const authorInfo = [
|
||||
comment.authorDepartment,
|
||||
comment.authorName,
|
||||
comment.authorPosition,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 py-4 border-b border-gray-100 last:border-0">
|
||||
{/* 프로필 이미지 */}
|
||||
<div className="flex-shrink-0">
|
||||
{comment.authorProfileImage ? (
|
||||
<img
|
||||
src={comment.authorProfileImage}
|
||||
alt={comment.authorName}
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 댓글 내용 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* 작성자 정보 + 날짜 + 버튼 */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-gray-900">{authorInfo}</span>
|
||||
<span className="text-gray-400">
|
||||
{format(new Date(comment.createdAt), 'yyyy-MM-dd HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 본인 댓글만 수정/삭제 버튼 표시 */}
|
||||
{isMyComment && !isEditing && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-gray-500 hover:text-gray-700"
|
||||
onClick={handleEditClick}
|
||||
>
|
||||
<Pencil className="h-3 w-3 mr-1" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-red-500 hover:text-red-600"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 댓글 본문 또는 수정 폼 */}
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
placeholder="댓글을 입력해주세요"
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleSaveEdit}>
|
||||
저장
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleCancelEdit}>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-700 whitespace-pre-wrap">{comment.content}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>댓글 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
정말 삭제하시겠습니까?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentItem;
|
||||
90
src/components/board/CommentSection/index.tsx
Normal file
90
src/components/board/CommentSection/index.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 댓글 섹션 컴포넌트
|
||||
*
|
||||
* 디자인 스펙 기준:
|
||||
* - 댓글 등록 Textarea + 버튼
|
||||
* - 댓글 수 표시 ("댓글 N")
|
||||
* - 댓글 목록
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { CommentItem } from './CommentItem';
|
||||
import type { Comment } from '../types';
|
||||
|
||||
interface CommentSectionProps {
|
||||
postId: string;
|
||||
comments: Comment[];
|
||||
currentUserId: string;
|
||||
onAddComment: (content: string) => void;
|
||||
onUpdateComment: (id: string, content: string) => void;
|
||||
onDeleteComment: (id: string) => void;
|
||||
}
|
||||
|
||||
export function CommentSection({
|
||||
postId,
|
||||
comments,
|
||||
currentUserId,
|
||||
onAddComment,
|
||||
onUpdateComment,
|
||||
onDeleteComment,
|
||||
}: CommentSectionProps) {
|
||||
const [newComment, setNewComment] = useState('');
|
||||
|
||||
// 댓글 등록 핸들러
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (newComment.trim()) {
|
||||
onAddComment(newComment.trim());
|
||||
setNewComment('');
|
||||
}
|
||||
}, [newComment, onAddComment]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 댓글 등록 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-red-500 rounded-full" />
|
||||
댓글 등록
|
||||
</h3>
|
||||
<Textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="댓글을 입력해주세요"
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSubmit}>등록</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 댓글 목록 */}
|
||||
{comments.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-red-500 rounded-full" />
|
||||
댓글 {comments.length}
|
||||
</h3>
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
<div className="p-4">
|
||||
{comments.map((comment) => (
|
||||
<CommentItem
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
currentUserId={currentUserId}
|
||||
onUpdate={onUpdateComment}
|
||||
onDelete={onDeleteComment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentSection;
|
||||
287
src/components/board/RichTextEditor/MenuBar.tsx
Normal file
287
src/components/board/RichTextEditor/MenuBar.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* TipTap 에디터 툴바
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Editor } from '@tiptap/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Strikethrough,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
List,
|
||||
ListOrdered,
|
||||
Link as LinkIcon,
|
||||
Image as ImageIcon,
|
||||
Undo,
|
||||
Redo,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface MenuBarProps {
|
||||
editor: Editor | null;
|
||||
onImageUpload?: (file: File) => Promise<string>;
|
||||
}
|
||||
|
||||
export function MenuBar({ editor, onImageUpload }: MenuBarProps) {
|
||||
const [linkUrl, setLinkUrl] = useState('');
|
||||
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 링크 추가 (hooks는 조건문 전에 선언해야 함)
|
||||
const handleAddLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
if (linkUrl) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.extendMarkRange('link')
|
||||
.setLink({ href: linkUrl })
|
||||
.run();
|
||||
setLinkUrl('');
|
||||
setIsLinkPopoverOpen(false);
|
||||
}
|
||||
}, [editor, linkUrl]);
|
||||
|
||||
// 링크 제거
|
||||
const handleRemoveLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
editor.chain().focus().unsetLink().run();
|
||||
setIsLinkPopoverOpen(false);
|
||||
}, [editor]);
|
||||
|
||||
// 이미지 업로드
|
||||
const handleImageUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!editor) return;
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (onImageUpload) {
|
||||
try {
|
||||
const url = await onImageUpload(file);
|
||||
editor.chain().focus().setImage({ src: url }).run();
|
||||
} catch (error) {
|
||||
console.error('Image upload failed:', error);
|
||||
}
|
||||
} else {
|
||||
// 기본: Base64로 삽입 (개발용)
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const base64 = reader.result as string;
|
||||
editor.chain().focus().setImage({ src: base64 }).run();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// 파일 input 초기화
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}, [editor, onImageUpload]);
|
||||
|
||||
// editor가 없으면 렌더링 안 함 (hooks 이후에 체크)
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buttonClass = 'h-8 w-8 p-0';
|
||||
const activeClass = 'bg-gray-200';
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1 border-b border-gray-200 p-2 bg-gray-50 rounded-t-md">
|
||||
{/* Undo/Redo */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={buttonClass}
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
title="실행 취소"
|
||||
>
|
||||
<Undo className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={buttonClass}
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
title="다시 실행"
|
||||
>
|
||||
<Redo className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-6 bg-gray-300 mx-1" />
|
||||
|
||||
{/* 텍스트 스타일 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`${buttonClass} ${editor.isActive('bold') ? activeClass : ''}`}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
title="굵게 (Ctrl+B)"
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`${buttonClass} ${editor.isActive('italic') ? activeClass : ''}`}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
title="기울임 (Ctrl+I)"
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`${buttonClass} ${editor.isActive('underline') ? activeClass : ''}`}
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
title="밑줄 (Ctrl+U)"
|
||||
>
|
||||
<Underline className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`${buttonClass} ${editor.isActive('strike') ? activeClass : ''}`}
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
title="취소선"
|
||||
>
|
||||
<Strikethrough className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-6 bg-gray-300 mx-1" />
|
||||
|
||||
{/* 정렬 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`${buttonClass} ${editor.isActive({ textAlign: 'left' }) ? activeClass : ''}`}
|
||||
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||
title="왼쪽 정렬"
|
||||
>
|
||||
<AlignLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`${buttonClass} ${editor.isActive({ textAlign: 'center' }) ? activeClass : ''}`}
|
||||
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||
title="가운데 정렬"
|
||||
>
|
||||
<AlignCenter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`${buttonClass} ${editor.isActive({ textAlign: 'right' }) ? activeClass : ''}`}
|
||||
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||
title="오른쪽 정렬"
|
||||
>
|
||||
<AlignRight className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-6 bg-gray-300 mx-1" />
|
||||
|
||||
{/* 목록 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`${buttonClass} ${editor.isActive('bulletList') ? activeClass : ''}`}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
title="글머리 기호"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`${buttonClass} ${editor.isActive('orderedList') ? activeClass : ''}`}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
title="번호 매기기"
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-6 bg-gray-300 mx-1" />
|
||||
|
||||
{/* 링크 */}
|
||||
<Popover open={isLinkPopoverOpen} onOpenChange={setIsLinkPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`${buttonClass} ${editor.isActive('link') ? activeClass : ''}`}
|
||||
title="링크"
|
||||
>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-3">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
placeholder="URL을 입력하세요"
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddLink()}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleAddLink} className="flex-1">
|
||||
링크 추가
|
||||
</Button>
|
||||
{editor.isActive('link') && (
|
||||
<Button size="sm" variant="outline" onClick={handleRemoveLink}>
|
||||
제거
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* 이미지 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={buttonClass}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="이미지"
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/components/board/RichTextEditor/extensions.ts
Normal file
48
src/components/board/RichTextEditor/extensions.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* TipTap 에디터 확장 설정
|
||||
*/
|
||||
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
|
||||
export const getEditorExtensions = (placeholder?: string) => [
|
||||
StarterKit.configure({
|
||||
// 기본 확장 설정
|
||||
heading: {
|
||||
levels: [1, 2, 3],
|
||||
},
|
||||
bulletList: {
|
||||
keepMarks: true,
|
||||
keepAttributes: false,
|
||||
},
|
||||
orderedList: {
|
||||
keepMarks: true,
|
||||
keepAttributes: false,
|
||||
},
|
||||
}),
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
class: 'text-blue-600 underline hover:text-blue-800',
|
||||
},
|
||||
}),
|
||||
Image.configure({
|
||||
inline: true,
|
||||
allowBase64: true,
|
||||
HTMLAttributes: {
|
||||
class: 'max-w-full h-auto rounded-lg',
|
||||
},
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholder || '내용을 입력해주세요',
|
||||
emptyEditorClass: 'is-editor-empty',
|
||||
}),
|
||||
];
|
||||
90
src/components/board/RichTextEditor/index.tsx
Normal file
90
src/components/board/RichTextEditor/index.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* TipTap 기반 WYSIWYG 에디터
|
||||
*/
|
||||
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import { useEffect } from 'react';
|
||||
import { getEditorExtensions } from './extensions';
|
||||
import { MenuBar } from './MenuBar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface RichTextEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
onImageUpload?: (file: File) => Promise<string>;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
minHeight?: string;
|
||||
}
|
||||
|
||||
export function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '내용을 입력해주세요',
|
||||
onImageUpload,
|
||||
className,
|
||||
disabled = false,
|
||||
minHeight = '200px',
|
||||
}: RichTextEditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: getEditorExtensions(placeholder),
|
||||
content: value,
|
||||
editable: !disabled,
|
||||
immediatelyRender: false, // SSR hydration mismatch 방지
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange(editor.getHTML());
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: cn(
|
||||
'prose prose-sm max-w-none focus:outline-none',
|
||||
'px-4 py-3',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
),
|
||||
style: `min-height: ${minHeight}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 외부에서 value가 변경될 때 에디터 업데이트
|
||||
useEffect(() => {
|
||||
if (editor && value !== editor.getHTML()) {
|
||||
editor.commands.setContent(value);
|
||||
}
|
||||
}, [editor, value]);
|
||||
|
||||
// disabled 상태 변경 시 반영
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.setEditable(!disabled);
|
||||
}
|
||||
}, [editor, disabled]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border border-gray-200 rounded-md overflow-hidden bg-white',
|
||||
disabled && 'bg-gray-50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!disabled && <MenuBar editor={editor} onImageUpload={onImageUpload} />}
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className={cn(
|
||||
'overflow-y-auto',
|
||||
'[&_.is-editor-empty]:before:content-[attr(data-placeholder)]',
|
||||
'[&_.is-editor-empty]:before:text-gray-400',
|
||||
'[&_.is-editor-empty]:before:float-left',
|
||||
'[&_.is-editor-empty]:before:pointer-events-none',
|
||||
'[&_.is-editor-empty]:before:h-0'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RichTextEditor;
|
||||
120
src/components/board/types.ts
Normal file
120
src/components/board/types.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 게시판 관리 타입 정의
|
||||
*/
|
||||
|
||||
// ===== 게시판 타입 =====
|
||||
export interface Board {
|
||||
id: string;
|
||||
name: string; // 게시판명 (예: 공지사항, 자유게시판)
|
||||
description?: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ===== 게시글 타입 =====
|
||||
export interface Post {
|
||||
id: string;
|
||||
boardId: string; // 게시판 ID
|
||||
boardName: string; // 게시판명
|
||||
title: string; // 제목
|
||||
content: string; // 내용 (HTML)
|
||||
authorId: string; // 작성자 ID
|
||||
authorName: string; // 작성자명
|
||||
authorDepartment?: string; // 작성자 부서
|
||||
authorPosition?: string; // 작성자 직책
|
||||
isPinned: boolean; // 상단 노출 여부
|
||||
allowComments: boolean; // 댓글 허용 여부
|
||||
viewCount: number; // 조회수
|
||||
attachments: Attachment[]; // 첨부파일
|
||||
createdAt: string; // 등록일시
|
||||
updatedAt: string; // 수정일시
|
||||
}
|
||||
|
||||
// ===== 첨부파일 타입 =====
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
fileUrl: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
// ===== 댓글 타입 =====
|
||||
export interface Comment {
|
||||
id: string;
|
||||
postId: string; // 게시글 ID
|
||||
authorId: string; // 작성자 ID
|
||||
authorName: string; // 작성자명
|
||||
authorDepartment?: string; // 작성자 부서
|
||||
authorPosition?: string; // 작성자 직책
|
||||
authorProfileImage?: string; // 프로필 이미지
|
||||
content: string; // 댓글 내용
|
||||
createdAt: string; // 등록일시
|
||||
updatedAt: string; // 수정일시
|
||||
}
|
||||
|
||||
// ===== 필터/정렬 옵션 =====
|
||||
export type SortOption = 'latest' | 'oldest' | 'viewCount';
|
||||
|
||||
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '오래된순' },
|
||||
{ value: 'viewCount', label: '조회순' },
|
||||
];
|
||||
|
||||
export type BoardFilter = 'all' | 'my' | string; // 'all', 'my', 또는 boardId
|
||||
|
||||
// ===== 폼 데이터 타입 =====
|
||||
export interface PostFormData {
|
||||
boardId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
isPinned: boolean;
|
||||
allowComments: boolean;
|
||||
attachments: File[];
|
||||
}
|
||||
|
||||
export interface CommentFormData {
|
||||
content: string;
|
||||
}
|
||||
|
||||
// ===== API 응답 타입 =====
|
||||
export interface PostListResponse {
|
||||
data: Post[];
|
||||
pagination: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
perPage: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PostDetailResponse {
|
||||
data: Post;
|
||||
comments: Comment[];
|
||||
}
|
||||
|
||||
// ===== 탭 옵션 (게시판 탭) =====
|
||||
export interface BoardTabOption {
|
||||
value: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
// ===== Mock 데이터용 =====
|
||||
export const MOCK_BOARDS: Board[] = [
|
||||
{ id: 'all', name: '전체보드', isActive: true, createdAt: '', updatedAt: '' },
|
||||
{ id: 'notice', name: '공지사항', isActive: true, createdAt: '', updatedAt: '' },
|
||||
{ id: 'approval', name: '전자결재', isActive: true, createdAt: '', updatedAt: '' },
|
||||
{ id: 'print', name: '인쇄', isActive: true, createdAt: '', updatedAt: '' },
|
||||
{ id: 'seaweed', name: '미역', isActive: true, createdAt: '', updatedAt: '' },
|
||||
{ id: 'depression', name: '우울', isActive: true, createdAt: '', updatedAt: '' },
|
||||
];
|
||||
|
||||
export const BOARD_FILTER_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'notice', label: '공지사항' },
|
||||
{ value: 'board1', label: '게시판명' },
|
||||
{ value: 'board2', label: '게시판명2' },
|
||||
{ value: 'my', label: '나의 게시글' },
|
||||
];
|
||||
Reference in New Issue
Block a user