diff options
Diffstat (limited to 'lib/qna/table/qna-detail.tsx')
| -rw-r--r-- | lib/qna/table/qna-detail.tsx | 455 |
1 files changed, 455 insertions, 0 deletions
diff --git a/lib/qna/table/qna-detail.tsx b/lib/qna/table/qna-detail.tsx new file mode 100644 index 00000000..4f0a891f --- /dev/null +++ b/lib/qna/table/qna-detail.tsx @@ -0,0 +1,455 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { format } from "date-fns"; + +import { Button } from "@/components/ui/button"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { + ArrowLeft, + MessageSquare, + Edit, + Trash2, + Plus, + Clock, + User +} from "lucide-react"; + +import TiptapEditor from "@/components/qna/tiptap-editor"; +import { ImprovedCommentSection } from "./improved-comment-section"; +import { + createAnswer, + deleteQna, + deleteAnswer, + updateAnswer, + createComment, + deleteComment, + updateComment, +} from "@/lib/qna/service"; +import { Question } from "@/lib/qna/types"; + +interface QnaDetailProps { + question: Question; +} + +export default function QnaDetail({ question }: QnaDetailProps) { + const router = useRouter(); + const { data: session } = useSession(); + + // ------------------------------------------------------------------------- + // STATE + // ------------------------------------------------------------------------- + const [answerContent, setAnswerContent] = React.useState<string>(""); + const [loading, setLoading] = React.useState(false); + const [isAnswerDialogOpen, setIsAnswerDialogOpen] = React.useState(false); + const [editingAnswerId, setEditingAnswerId] = React.useState<number | null>(null); + + // ------------------------------------------------------------------------- + // DERIVED + // ------------------------------------------------------------------------- + const isAuthor = session?.user?.id === question.author; + const hasAnswers = (question.answers ?? []).length > 0; + const userAnswer = question.answers?.find((a) => a.author === session?.user?.id) ?? null; + + // ------------------------------------------------------------------------- + // HANDLERS – NAVIGATION + // ------------------------------------------------------------------------- + const handleGoBack = () => { + router.push("/evcp/qna"); + }; + + // ------------------------------------------------------------------------- + // HANDLERS – QUESTION + // ------------------------------------------------------------------------- + const handleDeleteQuestion = async () => { + try { + await deleteQna(question.id); + router.push("/evcp/qna"); + router.refresh(); + } catch (err) { + console.error("질문 삭제 실패:", err); + } + }; + + // ------------------------------------------------------------------------- + // HANDLERS – ANSWER + // ------------------------------------------------------------------------- + const handleSubmitAnswer = async () => { + if (!answerContent.trim()) return; + setLoading(true); + try { + await createAnswer({ qnaId: question.id, content: answerContent }); + setAnswerContent(""); + setIsAnswerDialogOpen(false); + router.refresh(); + } catch (err) { + console.error("답변 저장 실패:", err); + } finally { + setLoading(false); + } + }; + + const handleUpdateAnswer = async (answerId: number) => { + if (!answerContent.trim()) return; + setLoading(true); + try { + await updateAnswer(answerId, answerContent); + setAnswerContent(""); + setEditingAnswerId(null); + setIsAnswerDialogOpen(false); + router.refresh(); + } catch (err) { + console.error("답변 수정 실패:", err); + } finally { + setLoading(false); + } + }; + + const handleDeleteAnswer = async (id: number) => { + try { + await deleteAnswer(id); + router.refresh(); + } catch (err) { + console.error("답변 삭제 실패:", err); + } + }; + + const startEditingAnswer = (answer: any) => { + setAnswerContent(answer.content); + setEditingAnswerId(answer.id); + setIsAnswerDialogOpen(true); + }; + + const resetAnswerDialog = () => { + setAnswerContent(""); + setEditingAnswerId(null); + setIsAnswerDialogOpen(false); + }; + + return ( + <div className="min-h-screen bg-gray-50/50"> + {/* 헤더 */} + <div className="border-b bg-white"> + <div className="container mx-auto px-4 py-4"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-4"> + <Button + variant="ghost" + size="sm" + onClick={handleGoBack} + className="gap-2" + > + <ArrowLeft className="h-4 w-4" /> + 목록으로 + </Button> + {/* <div className="h-6 w-px bg-border" /> */} + {/* <Badge variant="outline"> + {getCategoryLabel(question.category as any, 'ko')} + </Badge> */} + </div> + + {isAuthor && !hasAnswers && ( + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => router.push(`/evcp/qna/${question.id}/edit`)} + className="gap-2" + > + <Edit className="h-4 w-4" /> + 수정 + </Button> + <AlertDialog> + <AlertDialogTrigger asChild> + <Button variant="outline" size="sm" className="gap-2 text-destructive hover:text-destructive"> + <Trash2 className="h-4 w-4" /> + 삭제 + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>질문 삭제</AlertDialogTitle> + <AlertDialogDescription> + 정말로 이 질문을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction onClick={handleDeleteQuestion} className="bg-destructive hover:bg-destructive/90"> + 삭제 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + )} + </div> + </div> + </div> + + {/* 메인 컨텐츠 */} + <div className="container mx-auto px-4 py-6 max-w-4xl"> + <div className="space-y-6"> + {/* 질문 영역 */} + <Card> + <CardHeader className="pb-4"> + <div className="space-y-3"> + <CardTitle className="text-2xl leading-tight"> + {question.title} + </CardTitle> + <div className="flex items-center gap-4 text-sm text-muted-foreground"> + <div className="flex items-center gap-2"> + <Avatar className="h-6 w-6"> + <AvatarImage src={question.authorImageUrl} /> + <AvatarFallback> + <User className="h-3 w-3" /> + </AvatarFallback> + </Avatar> + <span className="font-medium text-foreground"> + {question.authorName} + </span> + </div> + <div className="flex items-center gap-1"> + <Clock className="h-4 w-4" /> + <time dateTime={question.createdAt}> + {format(new Date(question.createdAt), "yyyy년 MM월 dd일 HH:mm")} + </time> + </div> + {hasAnswers && ( + <div className="flex items-center gap-1"> + <MessageSquare className="h-4 w-4" /> + <span>답변 {question.answers?.length}개</span> + </div> + )} + </div> + </div> + </CardHeader> + <CardContent> + <div + className="prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-foreground prose-strong:text-foreground prose-em:text-foreground" + dangerouslySetInnerHTML={{ __html: question.content || "내용 없음" }} + /> + </CardContent> + </Card> + + {/* 답변 영역 */} + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <h2 className="text-xl font-semibold flex items-center gap-2"> + <MessageSquare className="h-5 w-5" /> + 답변 {hasAnswers ? `(${question.answers?.length})` : ''} + </h2> + + {session?.user && ( + <Dialog open={isAnswerDialogOpen} onOpenChange={setIsAnswerDialogOpen}> + <DialogTrigger asChild> + <Button + className="gap-2" + onClick={() => { + if (!userAnswer) { + setAnswerContent(""); + setEditingAnswerId(null); + } + }} + > + <Plus className="h-4 w-4" /> + {userAnswer ? '답변 수정' : '답변 작성'} + </Button> + </DialogTrigger> + <DialogContent className="max-w-4xl max-h-[90vh]"> + <DialogHeader> + <DialogTitle> + {editingAnswerId ? '답변 수정' : '새 답변 작성'} + </DialogTitle> + <DialogDescription> + 질문에 대한 답변을 작성해주세요. 다른 사용자들이 이해하기 쉽도록 구체적으로 작성해주세요. + </DialogDescription> + </DialogHeader> + + <div className="flex-1 min-h-0"> + <TiptapEditor + content={answerContent} + setContent={setAnswerContent} + disabled={loading} + height="400px" + /> + </div> + + <DialogFooter className="gap-2 pt-4 border-t"> + <Button + variant="outline" + onClick={resetAnswerDialog} + disabled={loading} + > + 취소 + </Button> + <Button + onClick={() => editingAnswerId ? handleUpdateAnswer(editingAnswerId) : handleSubmitAnswer()} + disabled={loading || !answerContent.trim()} + > + {loading ? '저장 중...' : editingAnswerId ? '수정' : '등록'} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + )} + </div> + + {hasAnswers ? ( + <div className="space-y-4"> + {question.answers?.map((answer, index) => ( + <Card key={answer.id} className="relative"> + <CardHeader className="pb-3"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-3"> + <Avatar className="h-8 w-8"> + <AvatarImage src={answer.authorImageUrl} /> + <AvatarFallback> + <User className="h-4 w-4" /> + </AvatarFallback> + </Avatar> + <div> + <div className="font-medium text-sm"> + {answer.authorName ?? `사용자 ${answer.author}`} + </div> + <div className="text-xs text-muted-foreground"> + {format(new Date(answer.createdAt), "yyyy년 MM월 dd일 HH:mm")} + </div> + </div> + </div> + + {session?.user?.id === answer.author && ( + <div className="flex items-center gap-1"> + <Button + variant="ghost" + size="sm" + onClick={() => startEditingAnswer(answer)} + className="h-8 gap-1 text-muted-foreground hover:text-blue-600" + > + <Edit className="h-3 w-3" /> + 수정 + </Button> + <AlertDialog> + <AlertDialogTrigger asChild> + <Button + variant="ghost" + size="sm" + className="h-8 gap-1 text-muted-foreground hover:text-destructive" + > + <Trash2 className="h-3 w-3" /> + 삭제 + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>답변 삭제</AlertDialogTitle> + <AlertDialogDescription> + 정말로 이 답변을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction + onClick={() => handleDeleteAnswer(answer.id)} + className="bg-destructive hover:bg-destructive/90" + > + 삭제 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + )} + </div> + </CardHeader> + <CardContent className="pt-0"> + <div + className="prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-foreground prose-strong:text-foreground prose-em:text-foreground" + dangerouslySetInnerHTML={{ __html: answer.content }} + /> + + {/* 답변별 댓글 섹션 */} + <div className="mt-6 pt-4 border-t"> + <ImprovedCommentSection + answerId={answer.id} + comments={answer.comments ?? []} + onAddComment={async (content) => { + if (!session?.user?.id) return; + try { + await createComment({ content, answerId: answer.id }); + router.refresh(); + } catch (err) { + console.error("댓글 작성 실패:", err); + } + }} + onDeleteComment={async (commentId) => { + try { + await deleteComment(commentId); + router.refresh(); + } catch (err) { + console.error("댓글 삭제 실패:", err); + } + }} + onUpdateComment={async (commentId, content) => { + try { + await updateComment(commentId, content); + router.refresh(); + } catch (err) { + console.error("댓글 수정 실패:", err); + } + }} + /> + </div> + </CardContent> + </Card> + ))} + </div> + ) : ( + <Card className="py-12"> + <CardContent className="text-center"> + <MessageSquare className="h-12 w-12 text-muted-foreground mx-auto mb-4" /> + <p className="text-muted-foreground mb-4"> + 아직 등록된 답변이 없습니다. + </p> + {session?.user && ( + <Button + onClick={() => setIsAnswerDialogOpen(true)} + className="gap-2" + > + <Plus className="h-4 w-4" /> + 첫 번째 답변 작성하기 + </Button> + )} + </CardContent> + </Card> + )} + </div> + </div> + </div> + </div> + ); +}
\ No newline at end of file |
