Files
sam-react-prod/src/components/customer-center/InquiryManagement/InquiryDetail.tsx
유병철 61e3a0ed60 feat(WEB): Phase 6 IntegratedDetailTemplate 마이그레이션 완료
Phase 6 마이그레이션 (41개 컴포넌트 완료):
- 건설/시공: 협력업체, 시공관리, 기성관리, 발주관리, 계약관리 등
- 영업: 견적관리(V2), 고객관리(V2), 수주관리
- 회계: 청구관리, 매입관리, 매출관리, 거래처관리, 악성채권 등
- 생산: 작업지시, 검수관리
- 출고: 출하관리
- 자재: 입고관리, 재고현황
- 고객센터: 문의관리, 이벤트관리, 공지관리
- 인사: 직원관리
- 설정: 권한관리

주요 변경사항:
- 34개 xxxConfig.ts 파일 생성 (설정 기반 페이지 구성)
- PageLayout/PageHeader → IntegratedDetailTemplate 통합
- 일관된 타이틀/버튼 영역 (목록, 상세, 수정, 삭제)
- 1112줄 코드 감소 (중복 제거)

프로젝트 공통화 현황 분석 문서 추가:
- 상세 페이지 62%, 목록 페이지 82% 공통화 달성
- 추가 공통화 기회 및 로드맵 정리

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 15:51:02 +09:00

358 lines
12 KiB
TypeScript

'use client';
/**
* 1:1 문의 상세 컴포넌트
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
*
* 특이사항:
* - 삭제/수정 버튼 (답변완료 후에는 수정 버튼 비활성화)
* - 문의 영역 (제목, 작성자|날짜, 내용, 첨부파일)
* - 답변 영역 (작성자|날짜, 내용, 첨부파일)
* - 댓글 등록 입력창 + 등록 버튼
* - 댓글 목록 (프로필, 이름, 내용, 날짜, 수정/삭제)
*/
import { useState, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
import { Download, Edit, Trash2, User } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
Card,
CardContent,
} from '@/components/ui/card';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { inquiryConfig } from './inquiryConfig';
import type { Inquiry, Reply, Comment, Attachment } from './types';
interface InquiryDetailProps {
inquiry: Inquiry;
reply?: Reply;
comments: Comment[];
currentUserId: string;
onAddComment: (content: string) => Promise<void>;
onUpdateComment: (commentId: string, content: string) => Promise<void>;
onDeleteComment: (commentId: string) => Promise<void>;
onDeleteInquiry: () => Promise<boolean>;
}
export function InquiryDetail({
inquiry,
reply,
comments,
currentUserId,
onAddComment,
onUpdateComment,
onDeleteComment,
onDeleteInquiry,
}: InquiryDetailProps) {
const router = useRouter();
const [newComment, setNewComment] = useState('');
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [editingContent, setEditingContent] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const isMyInquiry = inquiry.authorId === currentUserId;
const canEdit = isMyInquiry && inquiry.status !== 'completed';
// 동적 config (작성자/상태에 따라 버튼 표시)
const dynamicConfig = useMemo(() => ({
...inquiryConfig,
actions: {
...inquiryConfig.actions,
showDelete: isMyInquiry,
showEdit: canEdit,
},
}), [isMyInquiry, canEdit]);
// 수정 버튼 클릭 시 수정 페이지로 이동
const handleEditClick = useCallback(() => {
router.push(`/ko/customer-center/qna/${inquiry.id}?mode=edit`);
}, [router, inquiry.id]);
// onDelete 핸들러 (Promise 반환)
const handleFormDelete = useCallback(async () => {
const success = await onDeleteInquiry();
if (success) {
router.push('/ko/customer-center/qna');
return { success: true };
}
return { success: false, error: '삭제에 실패했습니다.' };
}, [onDeleteInquiry, router]);
// ===== 댓글 핸들러 =====
const handleAddComment = useCallback(async () => {
if (!newComment.trim() || isSubmitting) return;
setIsSubmitting(true);
try {
await onAddComment(newComment.trim());
setNewComment('');
} finally {
setIsSubmitting(false);
}
}, [newComment, onAddComment, isSubmitting]);
const handleStartEdit = useCallback((comment: Comment) => {
setEditingCommentId(comment.id);
setEditingContent(comment.content);
}, []);
const handleCancelEdit = useCallback(() => {
setEditingCommentId(null);
setEditingContent('');
}, []);
const handleSaveEdit = useCallback(async (id: string) => {
if (!editingContent.trim() || isSubmitting) return;
setIsSubmitting(true);
try {
await onUpdateComment(id, editingContent.trim());
setEditingCommentId(null);
setEditingContent('');
} finally {
setIsSubmitting(false);
}
}, [editingContent, onUpdateComment, isSubmitting]);
const handleDeleteComment = useCallback(async (id: string) => {
if (isSubmitting) return;
setIsSubmitting(true);
try {
await onDeleteComment(id);
} finally {
setIsSubmitting(false);
}
}, [onDeleteComment, isSubmitting]);
// 파일 크기 포맷
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`;
};
// 첨부파일 렌더링
const renderAttachments = (attachments: Attachment[]) => {
if (attachments.length === 0) return null;
return (
<div className="space-y-2 pt-4 border-t border-gray-100">
{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>
);
};
// 폼 콘텐츠 렌더링
const renderFormContent = useCallback(() => (
<div className="space-y-6">
{/* 문의 카드 */}
<Card>
<CardContent className="pt-6 space-y-4">
{/* 섹션 타이틀 */}
<div className="flex items-center gap-2">
<span className="w-2 h-2 bg-red-500 rounded-full" />
<h3 className="text-lg font-semibold"></h3>
</div>
{/* 제목 */}
<h2 className="text-xl font-bold text-gray-900">{inquiry.title}</h2>
{/* 메타 정보 */}
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>{inquiry.authorName}</span>
<span className="text-gray-300">|</span>
<span>{format(new Date(inquiry.createdAt), 'yyyy-MM-dd HH:mm')}</span>
</div>
{/* 내용 (HTML 렌더링) */}
<div
className="prose prose-sm max-w-none min-h-[200px] p-4 bg-gray-50 rounded-lg"
dangerouslySetInnerHTML={{ __html: inquiry.content }}
/>
{/* 첨부파일 */}
{renderAttachments(inquiry.attachments)}
</CardContent>
</Card>
{/* 답변 카드 */}
{reply && (
<Card>
<CardContent className="pt-6 space-y-4">
{/* 섹션 타이틀 */}
<div className="flex items-center gap-2">
<span className="w-2 h-2 bg-red-500 rounded-full" />
<h3 className="text-lg font-semibold"></h3>
</div>
{/* 메타 정보 */}
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>{reply.authorName}</span>
<span className="text-gray-300">|</span>
<span>{format(new Date(reply.createdAt), 'yyyy-MM-dd HH:mm')}</span>
</div>
{/* 내용 (HTML 렌더링) */}
<div
className="prose prose-sm max-w-none min-h-[200px] p-4 bg-gray-50 rounded-lg"
dangerouslySetInnerHTML={{ __html: reply.content }}
/>
{/* 첨부파일 */}
{renderAttachments(reply.attachments)}
</CardContent>
</Card>
)}
{/* 댓글 등록 */}
<Card>
<CardContent className="pt-6 space-y-4">
<div className="flex items-center gap-2">
<span className="w-2 h-2 bg-red-500 rounded-full" />
<h3 className="text-lg font-semibold"> </h3>
</div>
<Textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="댓글을 입력해주세요"
className="min-h-[80px]"
/>
<div className="flex justify-end">
<Button onClick={handleAddComment} disabled={isSubmitting || !newComment.trim()}>
{isSubmitting ? '등록 중...' : '등록'}
</Button>
</div>
</CardContent>
</Card>
{/* 댓글 목록 */}
{comments.length > 0 && (
<Card>
<CardContent className="pt-6 space-y-4">
<div className="flex items-center gap-2">
<span className="w-2 h-2 bg-red-500 rounded-full" />
<h3 className="text-lg font-semibold"> {comments.length}</h3>
</div>
<div className="space-y-4">
{comments.map((comment) => (
<div key={comment.id} className="flex gap-3 p-4 bg-gray-50 rounded-lg">
{/* 프로필 아바타 */}
<div className="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center shrink-0 overflow-hidden">
{comment.authorProfileImage ? (
<img
src={comment.authorProfileImage}
alt={comment.authorName}
className="h-full w-full object-cover"
/>
) : (
<User className="h-5 w-5 text-gray-500" />
)}
</div>
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<span className="font-medium">{comment.authorName}</span>
<div className="flex items-center gap-2">
{comment.authorId === currentUserId && editingCommentId !== comment.id && (
<>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleStartEdit(comment)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-red-500 hover:text-red-600"
onClick={() => handleDeleteComment(comment.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
<span className="text-sm text-gray-500">
{format(new Date(comment.createdAt), 'yyyy-MM-dd HH:mm')}
</span>
</div>
</div>
{editingCommentId === comment.id ? (
<div className="space-y-2">
<Textarea
value={editingContent}
onChange={(e) => setEditingContent(e.target.value)}
className="min-h-[60px]"
/>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={handleCancelEdit}>
</Button>
<Button size="sm" onClick={() => handleSaveEdit(comment.id)}>
</Button>
</div>
</div>
) : (
<p className="text-gray-700">{comment.content}</p>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
), [
inquiry,
reply,
comments,
currentUserId,
newComment,
editingCommentId,
editingContent,
isSubmitting,
renderAttachments,
handleAddComment,
handleStartEdit,
handleCancelEdit,
handleSaveEdit,
handleDeleteComment,
]);
return (
<IntegratedDetailTemplate
config={dynamicConfig}
mode="view"
initialData={inquiry}
itemId={inquiry.id}
isLoading={isSubmitting}
onDelete={handleFormDelete}
onEdit={handleEditClick}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
);
}
export default InquiryDetail;