Files
sam-react-prod/src/components/board/BoardDetail/index.tsx
유병철 bb7e7a75e9 feat(WEB): 상세 페이지 권한 체계 통합 및 레이아웃/문서 기능 개선
권한 시스템 통합:
- BadDebtDetail, LaborDetail, PricingDetail 권한 로직 정리
- BoardDetail, ClientDetail, ItemDetail 권한 적용 개선
- ProcessDetail, StepDetail, PermissionDetail 권한 리팩토링
- ContractDetail, HandoverReport, ProgressBilling 권한 연동
- ReceivingDetail, ShipmentDetail, WorkOrderDetail 권한 적용
- InspectionDetail, OrderSalesDetail, QuoteFooterBar 권한 개선

기능 개선:
- AuthenticatedLayout 구조 리팩토링
- JointbarInspectionDocument 문서 레이아웃 개선
- PricingTableForm 폼 기능 보강
- DynamicItemForm, SectionsTab 개선
- 주문관리 상세/생산지시 페이지 개선
- VendorLedgerDetail 수정

설정:
- Claude hooks 추가 (빌드 차단, 파일 크기 체크, 미사용 import 체크)
- 품질감사 문서관리 계획 문서 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 20:26:27 +09:00

242 lines
8.5 KiB
TypeScript

'use client';
/**
* 게시글 상세 보기 컴포넌트
*
* 디자인 스펙 기준:
* - 페이지 타이틀: 게시글 상세
* - 페이지 설명: 게시글을 조회합니다.
* - 삭제/수정 버튼 (본인 글만 표시)
* - 게시판명 라벨
* - 제목
* - 메타 정보 (작성자 | 날짜 | 조회수)
* - 내용 (HTML 렌더링)
* - 첨부파일 다운로드 링크
* - 댓글 섹션 (댓글 사용함 설정 시만 표시)
*/
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
import { FileText, Download, ArrowLeft, Trash2, Edit } 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 { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import { CommentSection } from '../CommentSection';
import { deletePost } from '../actions';
import type { Post, Comment } from '../types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { useMenuStore } from '@/store/menuStore';
interface BoardDetailProps {
post: Post;
comments: Comment[];
currentUserId: string;
}
export function BoardDetail({ post, comments: initialComments, currentUserId }: BoardDetailProps) {
const router = useRouter();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isDeleting, setIsDeleting] = 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.boardCode}/${post.id}?mode=edit`);
}, [router, post.boardCode, post.id]);
const handleConfirmDelete = useCallback(async () => {
setIsDeleting(true);
try {
const result = await deletePost(post.boardCode, post.id);
if (result.success) {
toast.success('게시글이 삭제되었습니다.');
router.push('/ko/board');
} else {
toast.error(result.error || '게시글 삭제에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('게시글 삭제 오류:', error);
toast.error('게시글 삭제에 실패했습니다.');
} finally {
setIsDeleting(false);
setShowDeleteDialog(false);
}
}, [post.boardCode, post.id, router]);
// ===== 댓글 핸들러 =====
// TODO: 댓글 API 연동 (별도 작업)
const handleAddComment = useCallback((content: string) => {
const newComment: Comment = {
id: `comment-${Date.now()}`,
postId: post.id,
authorId: currentUserId,
authorName: '사용자',
content,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
setComments((prev) => [...prev, newComment]);
}, [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
)
);
}, []);
const handleDeleteComment = useCallback((id: string) => {
setComments((prev) => prev.filter((c) => c.id !== id));
}, []);
// 파일 크기 포맷
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}
/>
<div className="space-y-6 pb-24">
{/* 게시글 카드 */}
<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.isPinned && <span className="text-red-500 mr-2">[]</span>}
{post.isSecret && <span className="text-gray-500 mr-2">[]</span>}
{post.title}
</h2>
{/* 메타 정보: 작성자 | 날짜 | 조회수 */}
<div className="flex items-center gap-2 text-sm text-gray-500 mt-2">
<span>{post.authorName}</span>
{post.authorDepartment && (
<>
<span className="text-gray-300">|</span>
<span>{post.authorDepartment}</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">
<p className="text-sm font-medium text-gray-700"></p>
{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}
/>
)}
</div>
{/* 하단 액션 버튼 (sticky) */}
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'} flex items-center justify-between`}>
<Button variant="outline" onClick={handleBack} size="sm" className="md:size-default">
<ArrowLeft className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
{isMyPost && (
<div className="flex items-center gap-1 md:gap-2">
<Button
variant="outline"
onClick={() => setShowDeleteDialog(true)}
size="sm"
className="text-destructive hover:bg-destructive hover:text-destructive-foreground md:size-default"
>
<Trash2 className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<Button onClick={handleEdit} size="sm" className="md:size-default">
<Edit className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
</div>
)}
</div>
{/* 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleConfirmDelete}
title="게시글 삭제"
description="정말 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
loading={isDeleting}
/>
</PageLayout>
);
}
export default BoardDetail;