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:
byeongcheolryu
2025-12-19 19:12:34 +09:00
parent d742c0ce26
commit c6b605200d
213 changed files with 32644 additions and 775 deletions

View 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;

View 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;

View 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;

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

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

View 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>
&quot;{boardToDelete?.boardName}&quot; ?
<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>
</>
);
}

View 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: '경영지원부' },
];

View 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;

View 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;

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

View 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',
}),
];

View 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;

View 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: '나의 게시글' },
];