feat(WEB): 동적 게시판, 파트너 관리, 공지 팝업 모달 추가

- 동적 게시판 시스템 구현 (/boards/[boardCode])
- 파트너 관리 페이지 및 폼 추가
- 공지 팝업 모달 컴포넌트 (NoticePopupModal)
  - localStorage 기반 1일간 숨김 기능
  - 테스트 페이지 (/test/popup)
- IntegratedListTemplateV2 개선
- 기타 버그 수정 및 타입 개선

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-12-30 21:56:01 +09:00
parent 7b917fcbcd
commit f8dbc6b2ae
43 changed files with 6395 additions and 113 deletions

View File

@@ -5,6 +5,7 @@ import { useState, useEffect, useCallback } from 'react';
import { Loader2 } from 'lucide-react';
import { BoardForm } from '@/components/board/BoardManagement/BoardForm';
import { getBoardById, updateBoard } from '@/components/board/BoardManagement/actions';
import { forceRefreshMenus } from '@/lib/utils/menuRefresh';
import { Button } from '@/components/ui/button';
import type { Board, BoardFormData } from '@/components/board/BoardManagement/types';
@@ -51,6 +52,8 @@ export default function BoardEditPage() {
});
if (result.success) {
// 게시판 수정 성공 시 메뉴 즉시 갱신
await forceRefreshMenus();
router.push(`/ko/board/board-management/${board.id}`);
} else {
setError(result.error || '수정에 실패했습니다.');

View File

@@ -5,6 +5,7 @@ import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { BoardForm } from '@/components/board/BoardManagement/BoardForm';
import { createBoard } from '@/components/board/BoardManagement/actions';
import { forceRefreshMenus } from '@/lib/utils/menuRefresh';
import type { BoardFormData } from '@/components/board/BoardManagement/types';
// 게시판 코드 생성 (임시: 타임스탬프 기반)
@@ -29,6 +30,8 @@ export default function BoardNewPage() {
});
if (result.success && result.data) {
// 게시판 생성 성공 시 메뉴 즉시 갱신
await forceRefreshMenus();
router.push('/ko/board/board-management');
} else {
setError(result.error || '게시판 생성에 실패했습니다.');

View File

@@ -0,0 +1,250 @@
'use client';
/**
* 동적 게시판 수정 페이지
*/
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { ArrowLeft, Save, MessageSquare, Loader2 } from 'lucide-react';
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 { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { getDynamicBoardPost, updateDynamicBoardPost } from '@/components/board/DynamicBoard/actions';
import { getBoardByCode } from '@/components/board/BoardManagement/actions';
import type { PostApiData } from '@/components/customer-center/shared/types';
interface BoardPost {
id: string;
title: string;
content: string;
authorId: string;
authorName: string;
status: string;
views: number;
isNotice: boolean;
isSecret: boolean;
createdAt: string;
updatedAt: string;
}
// API 데이터 → 프론트엔드 타입 변환
function transformApiToPost(apiData: PostApiData): BoardPost {
return {
id: String(apiData.id),
title: apiData.title,
content: apiData.content,
authorId: String(apiData.user_id),
authorName: apiData.author?.name || '회원',
status: apiData.status,
views: apiData.views,
isNotice: apiData.is_notice,
isSecret: apiData.is_secret,
createdAt: apiData.created_at,
updatedAt: apiData.updated_at,
};
}
export default function DynamicBoardEditPage() {
const router = useRouter();
const params = useParams();
const boardCode = params.boardCode as string;
const postId = params.postId as string;
// 게시판 정보
const [boardName, setBoardName] = useState<string>('게시판');
// 원본 게시글
const [post, setPost] = useState<BoardPost | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
// 폼 상태
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [isSecret, setIsSecret] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// 게시판 정보 로드
useEffect(() => {
async function fetchBoardInfo() {
const result = await getBoardByCode(boardCode);
if (result.success && result.data) {
setBoardName(result.data.boardName);
}
}
fetchBoardInfo();
}, [boardCode]);
// 게시글 로드
useEffect(() => {
async function fetchPost() {
setIsLoading(true);
setLoadError(null);
const result = await getDynamicBoardPost(boardCode, postId);
if (result.success && result.data) {
const postData = transformApiToPost(result.data);
setPost(postData);
setTitle(postData.title);
setContent(postData.content);
setIsSecret(postData.isSecret);
} else {
setLoadError(result.error || '게시글을 찾을 수 없습니다.');
}
setIsLoading(false);
}
fetchPost();
}, [boardCode, postId]);
// 폼 제출
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) {
setError('제목을 입력해주세요.');
return;
}
if (!content.trim()) {
setError('내용을 입력해주세요.');
return;
}
setIsSubmitting(true);
setError(null);
const result = await updateDynamicBoardPost(boardCode, postId, {
title: title.trim(),
content: content.trim(),
is_secret: isSecret,
});
if (result.success) {
router.push(`/ko/boards/${boardCode}/${postId}`);
} else {
setError(result.error || '게시글 수정에 실패했습니다.');
setIsSubmitting(false);
}
};
// 취소
const handleCancel = () => {
router.push(`/ko/boards/${boardCode}/${postId}`);
};
// 로딩 상태
if (isLoading) {
return (
<PageLayout>
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</PageLayout>
);
}
// 로드 에러
if (loadError || !post) {
return (
<PageLayout>
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
<p className="text-muted-foreground">{loadError || '게시글을 찾을 수 없습니다.'}</p>
<Button variant="outline" onClick={() => router.push(`/ko/boards/${boardCode}`)}>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
</div>
</PageLayout>
);
}
return (
<PageLayout>
<PageHeader
title={boardName}
description="게시글 수정"
icon={MessageSquare}
/>
<form onSubmit={handleSubmit}>
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{/* 제목 */}
<div className="space-y-2">
<Label htmlFor="title"> *</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="제목을 입력하세요"
disabled={isSubmitting}
/>
</div>
{/* 내용 */}
<div className="space-y-2">
<Label htmlFor="content"> *</Label>
<Textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="내용을 입력하세요"
rows={15}
disabled={isSubmitting}
/>
</div>
{/* 비밀글 설정 */}
<div className="flex items-center space-x-2">
<Checkbox
id="isSecret"
checked={isSecret}
onCheckedChange={(checked) => setIsSecret(checked as boolean)}
disabled={isSubmitting}
/>
<Label htmlFor="isSecret" className="font-normal cursor-pointer">
</Label>
</div>
</CardContent>
</Card>
{/* 버튼 영역 */}
<div className="mt-6 flex items-center justify-between">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isSubmitting}
>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
<Button type="submit" disabled={isSubmitting}>
<Save className="h-4 w-4 mr-2" />
{isSubmitting ? '저장 중...' : '저장'}
</Button>
</div>
</form>
</PageLayout>
);
}

View File

@@ -0,0 +1,411 @@
'use client';
/**
* 동적 게시판 상세 페이지
*/
import { useParams, useRouter } from 'next/navigation';
import { useState, useEffect, useCallback } from 'react';
import { format } from 'date-fns';
import { ArrowLeft, Pencil, Trash2, MessageSquare, Eye } from 'lucide-react';
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 { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {
getDynamicBoardPost,
getDynamicBoardComments,
createDynamicBoardComment,
updateDynamicBoardComment,
deleteDynamicBoardComment,
deleteDynamicBoardPost,
} from '@/components/board/DynamicBoard/actions';
import { getBoardByCode } from '@/components/board/BoardManagement/actions';
import { transformApiToComment, type CommentApiData } from '@/components/customer-center/shared/types';
import type { PostApiData } from '@/components/customer-center/shared/types';
interface BoardPost {
id: string;
title: string;
content: string;
authorId: string;
authorName: string;
status: string;
views: number;
isNotice: boolean;
createdAt: string;
updatedAt: string;
}
interface Comment {
id: string;
inquiryId: string;
authorId: string;
authorName: string;
authorProfileImage?: string;
content: string;
createdAt: string;
updatedAt: string;
}
// API 데이터 → 프론트엔드 타입 변환
function transformApiToPost(apiData: PostApiData): BoardPost {
return {
id: String(apiData.id),
title: apiData.title,
content: apiData.content,
authorId: String(apiData.user_id),
authorName: apiData.author?.name || '회원',
status: apiData.status,
views: apiData.views,
isNotice: apiData.is_notice,
createdAt: apiData.created_at,
updatedAt: apiData.updated_at,
};
}
export default function DynamicBoardDetailPage() {
const router = useRouter();
const params = useParams();
const boardCode = params.boardCode as string;
const postId = params.postId as string;
// 게시판 정보
const [boardName, setBoardName] = useState<string>('게시판');
// 게시글 및 댓글
const [post, setPost] = useState<BoardPost | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentUserId, setCurrentUserId] = useState<string>('');
// 댓글 입력
const [newComment, setNewComment] = useState('');
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [editingContent, setEditingContent] = useState('');
// 현재 사용자 ID 가져오기
useEffect(() => {
const userStr = localStorage.getItem('user');
if (userStr) {
try {
const user = JSON.parse(userStr);
setCurrentUserId(String(user.id || ''));
} catch {
setCurrentUserId('');
}
}
}, []);
// 게시판 정보 로드
useEffect(() => {
async function fetchBoardInfo() {
const result = await getBoardByCode(boardCode);
if (result.success && result.data) {
setBoardName(result.data.boardName);
}
}
fetchBoardInfo();
}, [boardCode]);
// 데이터 로드
useEffect(() => {
async function fetchData() {
setIsLoading(true);
setError(null);
const [postResult, commentsResult] = await Promise.all([
getDynamicBoardPost(boardCode, postId),
getDynamicBoardComments(boardCode, postId),
]);
if (postResult.success && postResult.data) {
setPost(transformApiToPost(postResult.data));
} else {
setError(postResult.error || '게시글을 찾을 수 없습니다.');
}
if (commentsResult.success && commentsResult.data) {
setComments(commentsResult.data.map(transformApiToComment));
}
setIsLoading(false);
}
fetchData();
}, [boardCode, postId]);
// 댓글 추가
const handleAddComment = useCallback(async () => {
if (!newComment.trim()) return;
const result = await createDynamicBoardComment(boardCode, postId, newComment);
if (result.success && result.data) {
setComments((prev) => [...prev, transformApiToComment(result.data!)]);
setNewComment('');
} else {
console.error('댓글 등록 실패:', result.error);
}
}, [boardCode, postId, newComment]);
// 댓글 수정
const handleUpdateComment = useCallback(async (commentId: string) => {
if (!editingContent.trim()) return;
const result = await updateDynamicBoardComment(boardCode, postId, commentId, editingContent);
if (result.success && result.data) {
setComments((prev) =>
prev.map((c) => (c.id === commentId ? transformApiToComment(result.data!) : c))
);
setEditingCommentId(null);
setEditingContent('');
} else {
console.error('댓글 수정 실패:', result.error);
}
}, [boardCode, postId, editingContent]);
// 댓글 삭제
const handleDeleteComment = useCallback(async (commentId: string) => {
const result = await deleteDynamicBoardComment(boardCode, postId, commentId);
if (result.success) {
setComments((prev) => prev.filter((c) => c.id !== commentId));
} else {
console.error('댓글 삭제 실패:', result.error);
}
}, [boardCode, postId]);
// 게시글 삭제
const handleDeletePost = useCallback(async () => {
const result = await deleteDynamicBoardPost(boardCode, postId);
if (result.success) {
router.push(`/ko/boards/${boardCode}`);
} else {
console.error('게시글 삭제 실패:', result.error);
}
}, [boardCode, postId, router]);
// 수정 페이지로 이동
const handleEdit = useCallback(() => {
router.push(`/ko/boards/${boardCode}/${postId}/edit`);
}, [router, boardCode, postId]);
// 목록으로 이동
const handleBack = useCallback(() => {
router.push(`/ko/boards/${boardCode}`);
}, [router, boardCode]);
if (isLoading) {
return (
<PageLayout>
<div className="flex items-center justify-center min-h-[400px]">
<p className="text-muted-foreground"> ...</p>
</div>
</PageLayout>
);
}
if (error || !post) {
return (
<PageLayout>
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
<p className="text-muted-foreground">{error || '게시글을 찾을 수 없습니다.'}</p>
<Button variant="outline" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
</div>
</PageLayout>
);
}
const isAuthor = currentUserId === post.authorId;
return (
<PageLayout>
<PageHeader
title={boardName}
description="게시글 상세"
icon={MessageSquare}
/>
{/* 게시글 상세 */}
<Card className="mb-6">
<CardHeader>
<div className="flex items-start justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
{post.isNotice && (
<Badge variant="destructive"></Badge>
)}
<CardTitle className="text-xl">{post.title}</CardTitle>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>{post.authorName}</span>
<span>{format(new Date(post.createdAt), 'yyyy-MM-dd HH:mm')}</span>
<span className="flex items-center gap-1">
<Eye className="h-4 w-4" />
{post.views}
</span>
</div>
</div>
{isAuthor && (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleEdit}>
<Pencil className="h-4 w-4 mr-1" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm">
<Trash2 className="h-4 w-4 mr-1" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeletePost}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</div>
</CardHeader>
<Separator />
<CardContent className="pt-6">
<div
className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</CardContent>
</Card>
{/* 댓글 섹션 */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
({comments.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 댓글 목록 */}
{comments.length > 0 ? (
<div className="space-y-4">
{comments.map((comment) => (
<div key={comment.id} className="border rounded-lg p-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<span className="font-medium">{comment.authorName}</span>
<span className="text-sm text-muted-foreground">
{format(new Date(comment.createdAt), 'yyyy-MM-dd HH:mm')}
</span>
</div>
{currentUserId === comment.authorId && (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingCommentId(comment.id);
setEditingContent(comment.content);
}}
>
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => handleDeleteComment(comment.id)}
>
</Button>
</div>
)}
</div>
{editingCommentId === comment.id ? (
<div className="mt-2 space-y-2">
<Textarea
value={editingContent}
onChange={(e) => setEditingContent(e.target.value)}
rows={3}
/>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingCommentId(null);
setEditingContent('');
}}
>
</Button>
<Button size="sm" onClick={() => handleUpdateComment(comment.id)}>
</Button>
</div>
</div>
) : (
<p className="mt-2 text-sm">{comment.content}</p>
)}
</div>
))}
</div>
) : (
<p className="text-center text-muted-foreground py-8">
.
</p>
)}
{/* 댓글 입력 */}
<Separator />
<div className="space-y-2">
<Textarea
placeholder="댓글을 입력하세요..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
rows={3}
/>
<div className="flex justify-end">
<Button onClick={handleAddComment} disabled={!newComment.trim()}>
</Button>
</div>
</div>
</CardContent>
</Card>
{/* 하단 버튼 */}
<div className="mt-6 flex justify-start">
<Button variant="outline" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,162 @@
'use client';
/**
* 동적 게시판 등록 페이지
*/
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { ArrowLeft, Save, MessageSquare } from 'lucide-react';
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 { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { createDynamicBoardPost } from '@/components/board/DynamicBoard/actions';
import { getBoardByCode } from '@/components/board/BoardManagement/actions';
export default function DynamicBoardCreatePage() {
const router = useRouter();
const params = useParams();
const boardCode = params.boardCode as string;
// 게시판 정보
const [boardName, setBoardName] = useState<string>('게시판');
// 폼 상태
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [isSecret, setIsSecret] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// 게시판 정보 로드
useEffect(() => {
async function fetchBoardInfo() {
const result = await getBoardByCode(boardCode);
if (result.success && result.data) {
setBoardName(result.data.boardName);
}
}
fetchBoardInfo();
}, [boardCode]);
// 폼 제출
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) {
setError('제목을 입력해주세요.');
return;
}
if (!content.trim()) {
setError('내용을 입력해주세요.');
return;
}
setIsSubmitting(true);
setError(null);
const result = await createDynamicBoardPost(boardCode, {
title: title.trim(),
content: content.trim(),
is_secret: isSecret,
});
if (result.success && result.data) {
router.push(`/ko/boards/${boardCode}/${result.data.id}`);
} else {
setError(result.error || '게시글 등록에 실패했습니다.');
setIsSubmitting(false);
}
};
// 취소
const handleCancel = () => {
router.push(`/ko/boards/${boardCode}`);
};
return (
<PageLayout>
<PageHeader
title={boardName}
description="새 게시글 등록"
icon={MessageSquare}
/>
<form onSubmit={handleSubmit}>
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{/* 제목 */}
<div className="space-y-2">
<Label htmlFor="title"> *</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="제목을 입력하세요"
disabled={isSubmitting}
/>
</div>
{/* 내용 */}
<div className="space-y-2">
<Label htmlFor="content"> *</Label>
<Textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="내용을 입력하세요"
rows={15}
disabled={isSubmitting}
/>
</div>
{/* 비밀글 설정 */}
<div className="flex items-center space-x-2">
<Checkbox
id="isSecret"
checked={isSecret}
onCheckedChange={(checked) => setIsSecret(checked as boolean)}
disabled={isSubmitting}
/>
<Label htmlFor="isSecret" className="font-normal cursor-pointer">
</Label>
</div>
</CardContent>
</Card>
{/* 버튼 영역 */}
<div className="mt-6 flex items-center justify-between">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isSubmitting}
>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
<Button type="submit" disabled={isSubmitting}>
<Save className="h-4 w-4 mr-2" />
{isSubmitting ? '등록 중...' : '등록'}
</Button>
</div>
</form>
</PageLayout>
);
}

View File

@@ -0,0 +1,398 @@
'use client';
/**
* 동적 게시판 목록 페이지
* boardCode에 따라 동적으로 게시판 데이터를 로드
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { MessageSquare, Plus } from 'lucide-react';
import { format } from 'date-fns';
import { TableCell, TableRow } from '@/components/ui/table';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
IntegratedListTemplateV2,
type TableColumn,
type PaginationConfig,
} from '@/components/templates/IntegratedListTemplateV2';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { getDynamicBoardPosts } from '@/components/board/DynamicBoard/actions';
import { getBoardByCode } from '@/components/board/BoardManagement/actions';
import type { PostApiData } from '@/components/customer-center/shared/types';
const ITEMS_PER_PAGE = 10;
// 정렬 옵션
const SORT_OPTIONS = [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '오래된순' },
];
// 게시글 상태 옵션
const STATUS_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'published', label: '게시됨' },
{ value: 'draft', label: '임시저장' },
];
interface BoardPost {
id: string;
title: string;
content: string;
authorId: string;
authorName: string;
status: string;
views: number;
isNotice: boolean;
createdAt: string;
updatedAt: string;
}
// API 데이터 → 프론트엔드 타입 변환
function transformApiToPost(apiData: PostApiData): BoardPost {
return {
id: String(apiData.id),
title: apiData.title,
content: apiData.content,
authorId: String(apiData.user_id),
authorName: apiData.author?.name || '회원',
status: apiData.status,
views: apiData.views,
isNotice: apiData.is_notice,
createdAt: apiData.created_at,
updatedAt: apiData.updated_at,
};
}
export default function DynamicBoardListPage() {
const router = useRouter();
const params = useParams();
const boardCode = params.boardCode as string;
// 게시판 정보
const [boardName, setBoardName] = useState<string>('게시판');
const [boardDescription, setBoardDescription] = useState<string>('');
// 게시글 목록
const [posts, setPosts] = useState<BoardPost[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 필터 및 검색
const [searchValue, setSearchValue] = useState('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortOption, setSortOption] = useState<string>('latest');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
// 게시판 정보 로드
useEffect(() => {
async function fetchBoardInfo() {
const result = await getBoardByCode(boardCode);
if (result.success && result.data) {
setBoardName(result.data.boardName);
setBoardDescription(result.data.description || '');
}
}
fetchBoardInfo();
}, [boardCode]);
// 게시글 목록 로드
useEffect(() => {
async function fetchPosts() {
setIsLoading(true);
setError(null);
const result = await getDynamicBoardPosts(boardCode, { per_page: 100 });
if (result.success && result.data) {
const transformed = result.data.data.map(transformApiToPost);
setPosts(transformed);
} else {
setError(result.error || '게시글 목록을 불러오는데 실패했습니다.');
}
setIsLoading(false);
}
fetchPosts();
}, [boardCode]);
// 필터링 및 정렬
const filteredData = useMemo(() => {
let result = [...posts];
// 상태 필터
if (statusFilter !== 'all') {
result = result.filter((item) => item.status === statusFilter);
}
// 날짜 필터
if (startDate && endDate) {
result = result.filter((item) => {
const itemDate = format(new Date(item.createdAt), 'yyyy-MM-dd');
return itemDate >= startDate && itemDate <= endDate;
});
}
// 검색 필터
if (searchValue) {
const searchLower = searchValue.toLowerCase();
result = result.filter(
(item) =>
item.title.toLowerCase().includes(searchLower) ||
item.authorName.toLowerCase().includes(searchLower)
);
}
// 정렬
if (sortOption === 'latest') {
result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
} else {
result.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
}
return result;
}, [posts, statusFilter, startDate, endDate, searchValue, sortOption]);
// 페이지네이션
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
return filteredData.slice(startIndex, startIndex + ITEMS_PER_PAGE);
}, [filteredData, currentPage]);
const totalPages = Math.ceil(filteredData.length / ITEMS_PER_PAGE);
// 핸들러
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((item) => item.id)));
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback(
(item: BoardPost) => {
router.push(`/ko/boards/${boardCode}/${item.id}`);
},
[router, boardCode]
);
const handleCreate = useCallback(() => {
router.push(`/ko/boards/${boardCode}/create`);
}, [router, boardCode]);
// 상태 Badge
const getStatusBadge = (status: string) => {
if (status === 'published') {
return <Badge variant="secondary" className="bg-green-100 text-green-700"></Badge>;
}
if (status === 'draft') {
return <Badge variant="secondary" className="bg-yellow-100 text-yellow-700"></Badge>;
}
return <Badge variant="secondary">{status}</Badge>;
};
// 테이블 컬럼
const tableColumns: TableColumn[] = [
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
{ key: 'title', label: '제목', className: 'min-w-[200px]' },
{ key: 'author', label: '작성자', className: 'w-[120px]' },
{ key: 'views', label: '조회수', className: 'w-[80px] text-center' },
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
{ key: 'createdAt', label: '등록일', className: 'w-[120px] text-center' },
];
// 테이블 행 렌더링
const renderTableRow = useCallback(
(item: BoardPost, 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={() => handleToggleSelection(item.id)}
/>
</TableCell>
<TableCell className="text-center">{globalIndex}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{item.isNotice && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
{item.title}
</div>
</TableCell>
<TableCell>{item.authorName}</TableCell>
<TableCell className="text-center">{item.views}</TableCell>
<TableCell className="text-center">{getStatusBadge(item.status)}</TableCell>
<TableCell className="text-center">
{format(new Date(item.createdAt), 'yyyy-MM-dd')}
</TableCell>
</TableRow>
);
},
[selectedItems, handleRowClick, handleToggleSelection]
);
// 모바일 카드 렌더링
const renderMobileCard = useCallback(
(
item: BoardPost,
index: number,
globalIndex: number,
isSelected: boolean,
onToggle: () => void
) => {
return (
<Card
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(item)}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div onClick={(e) => e.stopPropagation()}>
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">#{globalIndex}</span>
{item.isNotice && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
{getStatusBadge(item.status)}
</div>
<h3 className="font-medium">{item.title}</h3>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{item.authorName}</span>
<span>{format(new Date(item.createdAt), 'yyyy-MM-dd')}</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
},
[handleRowClick]
);
// 페이지네이션 설정
const pagination: PaginationConfig = {
currentPage,
totalPages,
totalItems: filteredData.length,
itemsPerPage: ITEMS_PER_PAGE,
onPageChange: setCurrentPage,
};
if (error) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<p className="text-muted-foreground">{error}</p>
</div>
);
}
return (
<IntegratedListTemplateV2
title={boardName}
description={boardDescription || `${boardName} 게시판입니다.`}
icon={MessageSquare}
headerActions={
<>
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
<Button className="ml-auto" onClick={handleCreate}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</>
}
searchValue={searchValue}
onSearchChange={setSearchValue}
searchPlaceholder="제목, 작성자로 검색..."
tableHeaderActions={
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{filteredData.length}
</span>
<Select
value={statusFilter}
onValueChange={setStatusFilter}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={sortOption} onValueChange={setSortOption}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
}
tableColumns={tableColumns}
data={paginatedData}
allData={filteredData}
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
getItemId={(item) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
pagination={pagination}
/>
);
}

View File

@@ -0,0 +1,5 @@
import { EstimateListClient } from '@/components/business/juil/estimates';
export default function EstimatesPage() {
return <EstimateListClient />;
}

View File

@@ -0,0 +1,19 @@
import PartnerForm from '@/components/business/juil/partners/PartnerForm';
import { getPartner } from '@/components/business/juil/partners/actions';
interface PartnerEditPageProps {
params: Promise<{ id: string }>;
}
export default async function PartnerEditPage({ params }: PartnerEditPageProps) {
const { id } = await params;
const result = await getPartner(id);
return (
<PartnerForm
mode="edit"
partnerId={id}
initialData={result.success ? result.data : undefined}
/>
);
}

View File

@@ -0,0 +1,19 @@
import PartnerForm from '@/components/business/juil/partners/PartnerForm';
import { getPartner } from '@/components/business/juil/partners/actions';
interface PartnerDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function PartnerDetailPage({ params }: PartnerDetailPageProps) {
const { id } = await params;
const result = await getPartner(id);
return (
<PartnerForm
mode="view"
partnerId={id}
initialData={result.success ? result.data : undefined}
/>
);
}

View File

@@ -0,0 +1,5 @@
import PartnerForm from '@/components/business/juil/partners/PartnerForm';
export default function PartnerNewPage() {
return <PartnerForm mode="new" />;
}

View File

@@ -0,0 +1,18 @@
import { SiteBriefingForm, getSiteBriefing } from '@/components/business/juil/site-briefings';
interface SiteBriefingEditPageProps {
params: Promise<{ id: string }>;
}
export default async function SiteBriefingEditPage({ params }: SiteBriefingEditPageProps) {
const { id } = await params;
const result = await getSiteBriefing(id);
return (
<SiteBriefingForm
mode="edit"
briefingId={id}
initialData={result.success ? result.data : undefined}
/>
);
}

View File

@@ -0,0 +1,18 @@
import { SiteBriefingForm, getSiteBriefing } from '@/components/business/juil/site-briefings';
interface SiteBriefingDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function SiteBriefingDetailPage({ params }: SiteBriefingDetailPageProps) {
const { id } = await params;
const result = await getSiteBriefing(id);
return (
<SiteBriefingForm
mode="view"
briefingId={id}
initialData={result.success ? result.data : undefined}
/>
);
}

View File

@@ -0,0 +1,5 @@
import { SiteBriefingForm } from '@/components/business/juil/site-briefings';
export default function SiteBriefingNewPage() {
return <SiteBriefingForm mode="new" />;
}

View File

@@ -0,0 +1,5 @@
import { SiteBriefingListClient } from '@/components/business/juil/site-briefings';
export default function SiteBriefingsPage() {
return <SiteBriefingListClient />;
}

View File

@@ -0,0 +1,134 @@
'use client';
import { useState } from 'react';
import { NoticePopupModal, isPopupDismissedForToday } from '@/components/common/NoticePopupModal';
import { Button } from '@/components/ui/button';
import { PageLayout } from '@/components/organisms/PageLayout';
import type { NoticePopupData } from '@/components/common/NoticePopupModal';
// 테스트용 샘플 팝업 데이터
const SAMPLE_POPUPS: NoticePopupData[] = [
{
id: 'popup-1',
title: '휴가 안내',
content: '<p>전사 휴가 안내입니다.</p><p>2025년 신정 연휴 기간: 2025.01.01 ~ 2025.01.03</p>',
imageUrl: undefined,
},
{
id: 'popup-2',
title: '시스템 점검 안내',
content: '<p>시스템 점검으로 인해 아래 일시에 서비스 이용이 제한됩니다.</p><ul><li>점검일시: 2025.01.15 00:00 ~ 06:00</li><li>점검내용: 서버 업그레이드</li></ul>',
imageUrl: 'https://placehold.co/400x300/e2e8f0/64748b?text=Notice+Image',
},
{
id: 'popup-3',
title: '신규 기능 안내',
content: '<p>새로운 기능이 추가되었습니다!</p><p>자세한 내용은 공지사항을 확인해주세요.</p>',
imageUrl: 'https://placehold.co/400x300/dbeafe/3b82f6?text=New+Feature',
},
];
export default function PopupTestPage() {
const [selectedPopup, setSelectedPopup] = useState<NoticePopupData | null>(null);
const [isOpen, setIsOpen] = useState(false);
const handleOpenPopup = (popup: NoticePopupData, force = false) => {
// 숨김 처리된 팝업은 강제로 열지 않는 한 열지 않음
if (!force && isPopupDismissedForToday(popup.id)) {
return;
}
setSelectedPopup(popup);
setIsOpen(true);
};
const clearDismissedPopups = () => {
SAMPLE_POPUPS.forEach((popup) => {
localStorage.removeItem(`popup_dismissed_${popup.id}`);
});
window.location.reload();
};
return (
<PageLayout>
<div className="space-y-6">
{/* 타이틀 */}
<h1 className="text-2xl font-bold"> </h1>
{/* 설명 */}
<div className="bg-muted/50 rounded-lg p-4 border">
<h2 className="text-lg font-medium mb-2"> </h2>
<p className="text-sm text-muted-foreground">
.
&quot;1 &quot; localStorage에 .
</p>
</div>
{/* 팝업 리스트 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{SAMPLE_POPUPS.map((popup) => {
const isDismissed = isPopupDismissedForToday(popup.id);
return (
<div
key={popup.id}
className="border rounded-lg p-4 bg-background"
>
<h3 className="font-medium mb-2">{popup.title}</h3>
<p className="text-sm text-muted-foreground mb-4 line-clamp-2">
{popup.content.replace(/<[^>]*>/g, '')}
</p>
<div className="flex items-center justify-between">
<span
className={`text-xs px-2 py-1 rounded ${
isDismissed
? 'bg-yellow-100 text-yellow-800'
: 'bg-green-100 text-green-800'
}`}
>
{isDismissed ? '오늘 숨김됨' : '표시 가능'}
</span>
<div className="flex gap-2">
{isDismissed ? (
<Button
size="sm"
variant="outline"
onClick={() => handleOpenPopup(popup, true)}
>
</Button>
) : (
<Button
size="sm"
onClick={() => handleOpenPopup(popup)}
>
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
{/* 초기화 버튼 */}
<div className="pt-4 border-t">
<Button variant="outline" onClick={clearDismissedPopups}>
(localStorage )
</Button>
</div>
</div>
{/* 팝업 모달 */}
{selectedPopup && (
<NoticePopupModal
popup={selectedPopup}
open={isOpen}
onOpenChange={setIsOpen}
/>
)}
</PageLayout>
);
}