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:
@@ -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 || '수정에 실패했습니다.');
|
||||
|
||||
@@ -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 || '게시판 생성에 실패했습니다.');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
162
src/app/[locale]/(protected)/boards/[boardCode]/create/page.tsx
Normal file
162
src/app/[locale]/(protected)/boards/[boardCode]/create/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
398
src/app/[locale]/(protected)/boards/[boardCode]/page.tsx
Normal file
398
src/app/[locale]/(protected)/boards/[boardCode]/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { EstimateListClient } from '@/components/business/juil/estimates';
|
||||
|
||||
export default function EstimatesPage() {
|
||||
return <EstimateListClient />;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import PartnerForm from '@/components/business/juil/partners/PartnerForm';
|
||||
|
||||
export default function PartnerNewPage() {
|
||||
return <PartnerForm mode="new" />;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SiteBriefingForm } from '@/components/business/juil/site-briefings';
|
||||
|
||||
export default function SiteBriefingNewPage() {
|
||||
return <SiteBriefingForm mode="new" />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SiteBriefingListClient } from '@/components/business/juil/site-briefings';
|
||||
|
||||
export default function SiteBriefingsPage() {
|
||||
return <SiteBriefingListClient />;
|
||||
}
|
||||
134
src/app/[locale]/(protected)/test/popup/page.tsx
Normal file
134
src/app/[locale]/(protected)/test/popup/page.tsx
Normal 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">
|
||||
아래 버튼을 클릭하여 팝업 모달을 테스트할 수 있습니다.
|
||||
"1일간 이 창을 열지 않음" 체크 후 닫으면 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user