Phase 6 마이그레이션 (41개 컴포넌트 완료): - 건설/시공: 협력업체, 시공관리, 기성관리, 발주관리, 계약관리 등 - 영업: 견적관리(V2), 고객관리(V2), 수주관리 - 회계: 청구관리, 매입관리, 매출관리, 거래처관리, 악성채권 등 - 생산: 작업지시, 검수관리 - 출고: 출하관리 - 자재: 입고관리, 재고현황 - 고객센터: 문의관리, 이벤트관리, 공지관리 - 인사: 직원관리 - 설정: 권한관리 주요 변경사항: - 34개 xxxConfig.ts 파일 생성 (설정 기반 페이지 구성) - PageLayout/PageHeader → IntegratedDetailTemplate 통합 - 일관된 타이틀/버튼 영역 (목록, 상세, 수정, 삭제) - 1112줄 코드 감소 (중복 제거) 프로젝트 공통화 현황 분석 문서 추가: - 상세 페이지 62%, 목록 페이지 82% 공통화 달성 - 추가 공통화 기회 및 로드맵 정리 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
358 lines
12 KiB
TypeScript
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; |