summaryrefslogtreecommitdiff
path: root/lib/qna/table/qna-detail.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/qna/table/qna-detail.tsx')
-rw-r--r--lib/qna/table/qna-detail.tsx455
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