summaryrefslogtreecommitdiff
path: root/lib/qna/table
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-02 00:45:49 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-02 00:45:49 +0000
commit2acf5f8966a40c1c9a97680c8dc263ee3f1ad3d1 (patch)
treef406b5c86f563347c7fd088a85fd1a82284dc5ff /lib/qna/table
parent6a9ca20deddcdcbe8495cf5a73ec7ea5f53f9b55 (diff)
(대표님/최겸) 20250702 변경사항 업데이트
Diffstat (limited to 'lib/qna/table')
-rw-r--r--lib/qna/table/create-qna-dialog.tsx203
-rw-r--r--lib/qna/table/delete-qna-dialog.tsx250
-rw-r--r--lib/qna/table/improved-comment-section.tsx319
-rw-r--r--lib/qna/table/qna-detail.tsx455
-rw-r--r--lib/qna/table/qna-export-actions.tsx261
-rw-r--r--lib/qna/table/qna-table-columns.tsx325
-rw-r--r--lib/qna/table/qna-table-toolbar-actions.tsx176
-rw-r--r--lib/qna/table/qna-table.tsx236
-rw-r--r--lib/qna/table/update-qna-sheet.tsx206
-rw-r--r--lib/qna/table/utils.tsx329
10 files changed, 2760 insertions, 0 deletions
diff --git a/lib/qna/table/create-qna-dialog.tsx b/lib/qna/table/create-qna-dialog.tsx
new file mode 100644
index 00000000..d5af932b
--- /dev/null
+++ b/lib/qna/table/create-qna-dialog.tsx
@@ -0,0 +1,203 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+
+import { createQnaSchema, type CreateQnaSchema } from "@/lib/qna/validation"
+import { createQna } from "../service"
+import { QNA_CATEGORY_LABELS } from "@/db/schema"
+import TiptapEditor from "@/components/qna/tiptap-editor"
+
+interface CreateQnaDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function CreateQnaDialog({ open, onOpenChange }: CreateQnaDialogProps) {
+ const [isCreatePending, startCreateTransition] = React.useTransition()
+
+ const form = useForm<CreateQnaSchema>({
+ resolver: zodResolver(createQnaSchema),
+ defaultValues: {
+ title: "",
+ content: "",
+ category: undefined,
+ },
+ })
+
+ function onSubmit(input: CreateQnaSchema) {
+ startCreateTransition(async () => {
+ try {
+ const result = await createQna(input)
+
+ if (result.success) {
+ toast.success(result.message || "질문이 성공적으로 등록되었습니다.")
+ form.reset()
+ onOpenChange(false)
+ } else {
+ toast.error(result.error || "질문 등록에 실패했습니다.")
+ }
+ } catch (error) {
+ toast.error("예기치 못한 오류가 발생했습니다.")
+ console.error("질문 생성 오류:", error)
+ }
+ })
+ }
+
+ // 다이얼로그가 닫힐 때 폼 리셋
+ React.useEffect(() => {
+ if (!open) {
+ form.reset()
+ }
+ }, [open, form])
+
+
+ console.log(form.getValues(),"생성")
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-6xl max-h-[90vh] flex flex-col">
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle>새 질문 작성</DialogTitle>
+ <DialogDescription>
+ 질문의 제목과 내용을 입력해주세요. 다른 사용자들이 이해하기 쉽도록 구체적으로 작성해주세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 폼 영역: flex-1으로 남은 공간 모두 사용 */}
+ <div className="flex-1 overflow-hidden">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="h-full flex flex-col space-y-4">
+ {/* 카테고리와 제목은 스크롤 없이 고정 */}
+ <div className="flex-shrink-0 space-y-4">
+ {/* 카테고리 선택 */}
+ <FormField
+ control={form.control}
+ name="category"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>카테고리 *</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ disabled={isCreatePending}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="카테고리를 선택해주세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {QNA_CATEGORY_LABELS.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 제목 입력 */}
+ <FormField
+ control={form.control}
+ name="title"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>제목 *</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="질문의 제목을 입력해주세요"
+ disabled={isCreatePending}
+ className="text-base"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 내용 입력 영역: 고정 높이로 스크롤 생성 */}
+ <FormField
+ control={form.control}
+ name="content"
+ render={({ field }) => (
+ <FormItem className="flex-1 flex flex-col min-h-0">
+ <FormLabel className="flex-shrink-0">내용 *</FormLabel>
+ <FormControl className="flex-1 min-h-0">
+ {/* 고정 높이 400px로 설정하여 스크롤 보장 */}
+ <div className="h-[400px]">
+ <TiptapEditor
+ content={field.value}
+ setContent={field.onChange}
+ disabled={isCreatePending}
+ height="400px"
+ />
+ </div>
+ </FormControl>
+ <FormMessage className="flex-shrink-0" />
+ <div className="text-sm text-muted-foreground flex-shrink-0 mt-2">
+ • 문제 상황을 구체적으로 설명해주세요<br/>
+ • 이미지 복사&붙여넣기, 드래그&드롭 지원<br/>
+ • 예상하는 결과와 실제 결과를 명시해주세요
+ </div>
+ </FormItem>
+ )}
+ />
+ </form>
+ </Form>
+ </div>
+
+ <DialogFooter className="gap-2 pt-4 border-t flex-shrink-0">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isCreatePending}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ onClick={form.handleSubmit(onSubmit)}
+ disabled={isCreatePending}
+ >
+ {isCreatePending ? "등록 중..." : "질문 등록"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/qna/table/delete-qna-dialog.tsx b/lib/qna/table/delete-qna-dialog.tsx
new file mode 100644
index 00000000..55dcd366
--- /dev/null
+++ b/lib/qna/table/delete-qna-dialog.tsx
@@ -0,0 +1,250 @@
+"use client"
+
+import * as React from "react"
+import { toast } from "sonner"
+import { Trash2 } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+
+import { QnaViewSelect } from "@/db/schema"
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { deleteQna } from "../service"
+
+interface DeleteQnaDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ qnas: QnaViewSelect[]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteQnaDialog({
+ open,
+ onOpenChange,
+ qnas,
+ showTrigger = true,
+ onSuccess
+}: DeleteQnaDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ const qnaCount = qnas.length
+ const isMultiple = qnaCount > 1
+
+ async function handleDelete() {
+ startDeleteTransition(async () => {
+ try {
+ const promises = qnas.map(qna => deleteQna(qna.id))
+ const results = await Promise.all(promises)
+
+ const successCount = results.filter(result => result.success).length
+ const failCount = results.length - successCount
+
+ if (successCount > 0) {
+ toast.success(
+ isMultiple
+ ? `${successCount}개의 질문이 삭제되었습니다.`
+ : "질문이 삭제되었습니다."
+ )
+ onSuccess?.()
+ }
+
+ if (failCount > 0) {
+ toast.error(
+ isMultiple
+ ? `${failCount}개의 질문 삭제에 실패했습니다.`
+ : "질문 삭제에 실패했습니다."
+ )
+ }
+
+ if (successCount > 0) {
+ onOpenChange(false)
+ }
+ } catch (error) {
+ toast.error("삭제 중 오류가 발생했습니다.")
+ console.error("질문 삭제 오류:", error)
+ }
+ })
+ }
+
+ const title = isMultiple ? `${qnaCount}개 질문 삭제` : "질문 삭제"
+ const description = isMultiple
+ ? "선택한 질문들을 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
+ : "이 질문을 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
+
+ if (isDesktop) {
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ {showTrigger && (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제
+ </Button>
+ </DialogTrigger>
+ )}
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle>{title}</DialogTitle>
+ <DialogDescription>{description}</DialogDescription>
+ </DialogHeader>
+
+ {/* 삭제할 질문 목록 */}
+ <div className="max-h-[300px]">
+ <div className="text-sm font-medium mb-2">삭제 대상:</div>
+ <ScrollArea className="max-h-[250px] border rounded-md p-3">
+ <div className="space-y-3">
+ {qnas.map((qna, index) => (
+ <div key={qna.id} className="flex items-start gap-3 p-3 border rounded-md bg-muted/50">
+ <div className="font-mono text-xs text-muted-foreground mt-1">
+ {index + 1}.
+ </div>
+ <div className="flex-1 min-w-0">
+ <div className="font-medium text-sm line-clamp-2 mb-1">
+ {qna.title}
+ </div>
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
+ <span>{qna.authorName}</span>
+ <span>•</span>
+ <span>{qna.companyName || "미지정"}</span>
+ <span>•</span>
+ <span>답변 {qna.totalAnswers}개</span>
+ </div>
+ <div className="flex items-center gap-1 mt-2">
+ {qna.hasAnswers && (
+ <Badge variant="secondary" className="text-xs">
+ 답변있음
+ </Badge>
+ )}
+ {qna.isPopular && (
+ <Badge variant="default" className="text-xs">
+ 인기질문
+ </Badge>
+ )}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </ScrollArea>
+ </div>
+
+ {/* 경고 메시지 */}
+ <div className="rounded-md border border-destructive/20 bg-destructive/5 p-3">
+ <div className="text-sm text-destructive">
+ <strong>주의:</strong> 질문을 삭제하면 해당 질문의 모든 답변과 댓글도 함께 삭제됩니다.
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isDeletePending}
+ >
+ 취소
+ </Button>
+ <Button
+ type="button"
+ variant="destructive"
+ onClick={handleDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending ? "삭제 중..." : "삭제"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer open={open} onOpenChange={onOpenChange}>
+ {showTrigger && (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제
+ </Button>
+ </DrawerTrigger>
+ )}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>{title}</DrawerTitle>
+ <DrawerDescription>{description}</DrawerDescription>
+ </DrawerHeader>
+
+ <div className="px-4 pb-4">
+ {/* 삭제할 질문 목록 */}
+ <div className="mb-4">
+ <div className="text-sm font-medium mb-2">삭제 대상:</div>
+ <div className="max-h-[200px] space-y-2 overflow-y-auto">
+ {qnas.map((qna, index) => (
+ <div key={qna.id} className="flex items-start gap-2 p-2 border rounded-md bg-muted/50">
+ <div className="font-mono text-xs text-muted-foreground mt-1">
+ {index + 1}.
+ </div>
+ <div className="flex-1 min-w-0">
+ <div className="font-medium text-sm line-clamp-1 mb-1">
+ {qna.title}
+ </div>
+ <div className="text-xs text-muted-foreground">
+ {qna.authorName} • 답변 {qna.totalAnswers}개
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+
+ {/* 경고 메시지 */}
+ <div className="rounded-md border border-destructive/20 bg-destructive/5 p-3 mb-4">
+ <div className="text-sm text-destructive">
+ <strong>주의:</strong> 삭제된 질문과 관련 데이터는 복구할 수 없습니다.
+ </div>
+ </div>
+ </div>
+
+ <DrawerFooter className="gap-2">
+ <Button
+ type="button"
+ variant="destructive"
+ onClick={handleDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending ? "삭제 중..." : "삭제"}
+ </Button>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isDeletePending}
+ >
+ 취소
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/qna/table/improved-comment-section.tsx b/lib/qna/table/improved-comment-section.tsx
new file mode 100644
index 00000000..ce32b706
--- /dev/null
+++ b/lib/qna/table/improved-comment-section.tsx
@@ -0,0 +1,319 @@
+import * as React from "react";
+import { useSession } from "next-auth/react";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Badge } from "@/components/ui/badge";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { format } from "date-fns";
+import { Comment } from "@/lib/qna/types";
+import {
+ MessageCircle,
+ Trash2,
+ Edit,
+ Check,
+ X,
+ User,
+ Plus
+} from "lucide-react";
+
+interface ImprovedCommentSectionProps {
+ answerId: string | number;
+ comments: Comment[];
+ onAddComment: (content: string) => Promise<void>;
+ onDeleteComment: (commentId: string | number) => Promise<void>;
+ onUpdateComment?: (commentId: string | number, content: string) => Promise<void>;
+}
+
+export function ImprovedCommentSection({
+ answerId,
+ comments,
+ onAddComment,
+ onDeleteComment,
+ onUpdateComment
+}: ImprovedCommentSectionProps) {
+ const { data: session } = useSession();
+
+ // 상태 관리
+ const [content, setContent] = React.useState("");
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
+ const [editingId, setEditingId] = React.useState<string | number | null>(null);
+ const [editContent, setEditContent] = React.useState("");
+ const [showCommentForm, setShowCommentForm] = React.useState(false);
+
+ // 댓글 작성
+ const handleSubmit = async () => {
+ if (!content.trim() || !session?.user) return;
+
+ setIsSubmitting(true);
+ try {
+ await onAddComment(content);
+ setContent("");
+ setShowCommentForm(false);
+ } catch (error) {
+ console.error("댓글 작성 실패:", error);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // 댓글 수정 시작
+ const handleEditStart = (comment: Comment) => {
+ setEditingId(comment.id);
+ setEditContent(comment.content);
+ };
+
+ // 댓글 수정 취소
+ const handleEditCancel = () => {
+ setEditingId(null);
+ setEditContent("");
+ };
+
+ // 댓글 수정 저장
+ const handleEditSave = async (commentId: string | number) => {
+ if (!editContent.trim() || !onUpdateComment) return;
+
+ try {
+ await onUpdateComment(commentId, editContent);
+ setEditingId(null);
+ setEditContent("");
+ } catch (error) {
+ console.error("댓글 수정 실패:", error);
+ }
+ };
+
+ return (
+ <div className="space-y-4">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <MessageCircle className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm font-medium text-muted-foreground">
+ 댓글
+ </span>
+ {comments.length > 0 && (
+ <Badge variant="secondary" className="text-xs h-5">
+ {comments.length}
+ </Badge>
+ )}
+ </div>
+
+ {session?.user && !showCommentForm && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setShowCommentForm(true)}
+ className="gap-2 text-xs"
+ >
+ <Plus className="h-3 w-3" />
+ 댓글 작성
+ </Button>
+ )}
+ </div>
+
+ {/* 댓글 목록 */}
+ {comments.length > 0 && (
+ <div className="space-y-3">
+ {comments.map((comment) => (
+ <div key={comment.id} className="group">
+ <div className="flex gap-3">
+ {/* 아바타 */}
+ <Avatar className="h-7 w-7 shrink-0">
+ <AvatarImage src={comment.authorImageUrl} />
+ <AvatarFallback className="text-xs">
+ <User className="h-3 w-3" />
+ </AvatarFallback>
+ </Avatar>
+
+ {/* 댓글 내용 */}
+ <div className="flex-1 min-w-0">
+ <div className="bg-muted/50 rounded-lg px-3 py-2">
+ {/* 작성자 정보 */}
+ <div className="flex items-center gap-2 mb-1">
+ <span className="text-sm font-medium">
+ {comment.authorName || comment.author}
+ </span>
+ <span className="text-xs text-muted-foreground">
+ {format(new Date(comment.createdAt), "MM월 dd일 HH:mm")}
+ </span>
+ </div>
+
+ {/* 댓글 텍스트 */}
+ {editingId === comment.id ? (
+ <div className="space-y-2">
+ <Textarea
+ value={editContent}
+ onChange={(e) => setEditContent(e.target.value)}
+ className="min-h-[60px] text-sm resize-none"
+ maxLength={250}
+ placeholder="댓글을 입력하세요..."
+ />
+ <div className="flex items-center justify-between">
+ <span className="text-xs text-muted-foreground">
+ {editContent.length}/250
+ </span>
+ <div className="flex gap-1">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleEditSave(comment.id)}
+ disabled={!editContent.trim()}
+ className="h-7 px-2 text-green-600 hover:text-green-700 hover:bg-green-50"
+ >
+ <Check className="h-3 w-3" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={handleEditCancel}
+ className="h-7 px-2 text-muted-foreground hover:bg-muted"
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ ) : (
+ <p className="text-sm leading-relaxed whitespace-pre-wrap">
+ {comment.content}
+ </p>
+ )}
+ </div>
+
+ {/* 액션 버튼들 */}
+ {session?.user?.id === comment.author && editingId !== comment.id && (
+ <div className="flex gap-1 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
+ {onUpdateComment && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleEditStart(comment)}
+ className="h-6 px-2 text-xs text-muted-foreground hover:text-blue-600"
+ >
+ <Edit className="h-3 w-3 mr-1" />
+ 수정
+ </Button>
+ )}
+
+ <AlertDialog>
+ <AlertDialogTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-6 px-2 text-xs text-muted-foreground hover:text-destructive"
+ >
+ <Trash2 className="h-3 w-3 mr-1" />
+ 삭제
+ </Button>
+ </AlertDialogTrigger>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>댓글 삭제</AlertDialogTitle>
+ <AlertDialogDescription>
+ 정말로 이 댓글을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={() => onDeleteComment(comment.id)}
+ className="bg-destructive hover:bg-destructive/90"
+ >
+ 삭제
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+
+ {/* 댓글 작성 폼 */}
+ {session?.user && showCommentForm && (
+ <div className="space-y-3 pt-2 border-t">
+ <div className="flex gap-3">
+ <Avatar className="h-7 w-7 shrink-0">
+ <AvatarImage src={session.user.image} />
+ <AvatarFallback className="text-xs">
+ <User className="h-3 w-3" />
+ </AvatarFallback>
+ </Avatar>
+ <div className="flex-1 space-y-2">
+ <Textarea
+ value={content}
+ onChange={(e) => {
+ if (e.target.value.length <= 250) {
+ setContent(e.target.value);
+ }
+ }}
+ placeholder="댓글을 입력하세요..."
+ className="min-h-[80px] resize-none"
+ maxLength={250}
+ disabled={isSubmitting}
+ />
+ <div className="flex items-center justify-between">
+ <span className="text-xs text-muted-foreground">
+ {content.length}/250
+ </span>
+ <div className="flex gap-2">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ setShowCommentForm(false);
+ setContent("");
+ }}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ size="sm"
+ onClick={handleSubmit}
+ disabled={isSubmitting || !content.trim()}
+ >
+ {isSubmitting ? "저장 중..." : "등록"}
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* 빈 상태 */}
+ {comments.length === 0 && !showCommentForm && (
+ <div className="text-center py-6 text-muted-foreground">
+ <MessageCircle className="h-8 w-8 mx-auto mb-2 opacity-50" />
+ <p className="text-sm">아직 댓글이 없습니다.</p>
+ {session?.user && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setShowCommentForm(true)}
+ className="mt-2 gap-2"
+ >
+ <Plus className="h-3 w-3" />
+ 첫 번째 댓글 작성하기
+ </Button>
+ )}
+ </div>
+ )}
+ </div>
+ );
+} \ No newline at end of file
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
diff --git a/lib/qna/table/qna-export-actions.tsx b/lib/qna/table/qna-export-actions.tsx
new file mode 100644
index 00000000..07b692c0
--- /dev/null
+++ b/lib/qna/table/qna-export-actions.tsx
@@ -0,0 +1,261 @@
+"use server"
+
+import { QnaViewSelect } from "@/db/schema"
+
+interface ExportQnaDataParams {
+ format: "csv" | "excel"
+ data: QnaViewSelect[]
+ fields: string[]
+}
+
+/**
+ * Q&A 데이터 내보내기
+ */
+export async function exportQnaData({
+ format,
+ data,
+ fields
+}: ExportQnaDataParams) {
+ try {
+ // 필드 매핑
+ const fieldMapping: Record<string, string> = {
+ title: "제목",
+ authorName: "작성자",
+ companyName: "회사명",
+ authorDomain: "도메인",
+ vendorType: "벤더타입",
+ totalAnswers: "답변수",
+ totalComments: "댓글수",
+ createdAt: "작성일",
+ lastActivityAt: "최근활동",
+ hasAnswers: "답변여부",
+ isPopular: "인기질문",
+ }
+
+ // 데이터 변환
+ const exportData = data.map(qna => {
+ const row: Record<string, any> = {}
+
+ fields.forEach(field => {
+ const label = fieldMapping[field] || field
+
+ switch (field) {
+ case "createdAt":
+ case "lastActivityAt":
+ row[label] = qna[field as keyof QnaViewSelect]
+ ? new Date(qna[field as keyof QnaViewSelect] as string).toLocaleDateString("ko-KR")
+ : ""
+ break
+ case "hasAnswers":
+ row[label] = qna.hasAnswers ? "예" : "아니오"
+ break
+ case "isPopular":
+ row[label] = qna.isPopular ? "예" : "아니오"
+ break
+ case "authorDomain":
+ const domainLabels: Record<string, string> = {
+ partners: "협력업체",
+ tech: "기술업체",
+ admin: "관리자"
+ }
+ row[label] = domainLabels[qna.authorDomain as string] || qna.authorDomain
+ break
+ case "vendorType":
+ const typeLabels: Record<string, string> = {
+ vendor: "일반벤더",
+ techVendor: "기술벤더"
+ }
+ row[label] = typeLabels[qna.vendorType as string] || qna.vendorType || ""
+ break
+ default:
+ row[label] = qna[field as keyof QnaViewSelect] || ""
+ }
+ })
+
+ return row
+ })
+
+ if (format === "csv") {
+ return generateCSV(exportData)
+ } else {
+ return generateExcel(exportData)
+ }
+ } catch (error) {
+ console.error("내보내기 오류:", error)
+ return {
+ success: false,
+ error: "데이터 내보내기 중 오류가 발생했습니다."
+ }
+ }
+}
+
+/**
+ * CSV 파일 생성
+ */
+function generateCSV(data: Record<string, any>[]) {
+ try {
+ if (data.length === 0) {
+ return {
+ success: false,
+ error: "내보낼 데이터가 없습니다."
+ }
+ }
+
+ const headers = Object.keys(data[0])
+ const csvContent = [
+ headers.join(","), // 헤더
+ ...data.map(row =>
+ headers.map(header => {
+ const value = row[header]
+ // CSV에서 쉼표와 따옴표 이스케이프
+ if (typeof value === "string" && (value.includes(",") || value.includes('"'))) {
+ return `"${value.replace(/"/g, '""')}"`
+ }
+ return value
+ }).join(",")
+ )
+ ].join("\n")
+
+ // BOM 추가 (한글 인코딩을 위해)
+ const csvWithBOM = "\uFEFF" + csvContent
+ const blob = new Blob([csvWithBOM], { type: "text/csv;charset=utf-8;" })
+
+ // 파일 다운로드 처리는 클라이언트에서 수행
+ const url = URL.createObjectURL(blob)
+ const fileName = `qna_export_${new Date().toISOString().split('T')[0]}.csv`
+
+ // 클라이언트에서 다운로드 처리
+ if (typeof window !== "undefined") {
+ const link = document.createElement("a")
+ link.href = url
+ link.download = fileName
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ URL.revokeObjectURL(url)
+ }
+
+ return {
+ success: true,
+ message: "CSV 파일이 다운로드되었습니다."
+ }
+ } catch (error) {
+ return {
+ success: false,
+ error: "CSV 생성 중 오류가 발생했습니다."
+ }
+ }
+}
+
+/**
+ * Excel 파일 생성
+ */
+function generateExcel(data: Record<string, any>[]) {
+ try {
+ if (data.length === 0) {
+ return {
+ success: false,
+ error: "내보낼 데이터가 없습니다."
+ }
+ }
+
+ // Excel 생성을 위해 SheetJS 라이브러리 사용
+ // 실제 구현에서는 xlsx 라이브러리를 사용해야 함
+
+ const headers = Object.keys(data[0])
+ const worksheet = [
+ headers, // 헤더 행
+ ...data.map(row => headers.map(header => row[header])) // 데이터 행들
+ ]
+
+ // 간단한 CSV 형태로 반환 (실제로는 xlsx 라이브러리 사용 권장)
+ const csvContent = worksheet.map(row =>
+ row.map(cell => {
+ if (typeof cell === "string" && (cell.includes(",") || cell.includes('"'))) {
+ return `"${cell.replace(/"/g, '""')}"`
+ }
+ return cell
+ }).join(",")
+ ).join("\n")
+
+ const csvWithBOM = "\uFEFF" + csvContent
+ const blob = new Blob([csvWithBOM], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ })
+
+ const url = URL.createObjectURL(blob)
+ const fileName = `qna_export_${new Date().toISOString().split('T')[0]}.xlsx`
+
+ // 클라이언트에서 다운로드 처리
+ if (typeof window !== "undefined") {
+ const link = document.createElement("a")
+ link.href = url
+ link.download = fileName
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ URL.revokeObjectURL(url)
+ }
+
+ return {
+ success: true,
+ message: "Excel 파일이 다운로드되었습니다."
+ }
+ } catch (error) {
+ return {
+ success: false,
+ error: "Excel 생성 중 오류가 발생했습니다."
+ }
+ }
+}
+
+/**
+ * Q&A 통계 내보내기
+ */
+export async function exportQnaStats(data: QnaViewSelect[]) {
+ try {
+ const stats = {
+ 총질문수: data.length,
+ 답변된질문: data.filter(q => q.hasAnswers).length,
+ 답변대기질문: data.filter(q => !q.hasAnswers).length,
+ 인기질문: data.filter(q => q.isPopular).length,
+ 평균답변수: data.reduce((sum, q) => sum + (q.totalAnswers || 0), 0) / data.length,
+ 평균댓글수: data.reduce((sum, q) => sum + (q.totalComments || 0), 0) / data.length,
+ }
+
+ // 도메인별 통계
+ const domainStats = data.reduce((acc, q) => {
+ const domain = q.authorDomain || "기타"
+ acc[domain] = (acc[domain] || 0) + 1
+ return acc
+ }, {} as Record<string, number>)
+
+ // 회사별 통계 (상위 10개)
+ const companyStats = data.reduce((acc, q) => {
+ const company = q.companyName || "미지정"
+ acc[company] = (acc[company] || 0) + 1
+ return acc
+ }, {} as Record<string, number>)
+
+ const topCompanies = Object.entries(companyStats)
+ .sort(([,a], [,b]) => b - a)
+ .slice(0, 10)
+
+ const exportData = [
+ { 구분: "전체 통계", ...stats },
+ { 구분: "도메인별", ...domainStats },
+ ...topCompanies.map(([company, count]) => ({
+ 구분: "회사별",
+ 회사명: company,
+ 질문수: count
+ }))
+ ]
+
+ return generateCSV(exportData)
+ } catch (error) {
+ return {
+ success: false,
+ error: "통계 내보내기 중 오류가 발생했습니다."
+ }
+ }
+} \ No newline at end of file
diff --git a/lib/qna/table/qna-table-columns.tsx b/lib/qna/table/qna-table-columns.tsx
new file mode 100644
index 00000000..01431e35
--- /dev/null
+++ b/lib/qna/table/qna-table-columns.tsx
@@ -0,0 +1,325 @@
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { useRouter } from "next/navigation"
+import {
+ MoreHorizontal,
+ Eye,
+ Edit,
+ Trash2,
+ MessageSquare,
+ MessageCircle,
+ Clock,
+ Building2,
+ User,
+ CheckCircle2,
+ AlertCircle,
+ TrendingUp
+} from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Badge } from "@/components/ui/badge"
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
+
+import { formatDate } from "@/lib/utils"
+import { QnaViewSelect } from "@/db/schema"
+import type { DataTableRowAction } from "@/types/table"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+
+
+type NextRouter = ReturnType<typeof useRouter>;
+
+interface GetColumnsOptions {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<QnaViewSelect> | null>>
+ router: NextRouter;
+ currentUserId?: number | string; // ← 추가
+
+}
+
+export function getColumns({ setRowAction, router, currentUserId }: GetColumnsOptions): ColumnDef<QnaViewSelect>[] {
+ return [
+ // 선택 체크박스
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="모두 선택"
+ className="translate-y-[2px]"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="행 선택"
+ className="translate-y-[2px]"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // 제목 (클릭 시 상세 페이지 이동)
+ {
+ accessorKey: "title",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="제목" />
+ ),
+ cell: ({ row }) => {
+ const qna = row.original
+
+ return (
+ <div className="flex flex-col gap-1">
+ <Button
+ variant="link"
+ className="h-auto p-0 text-left justify-start font-medium text-foreground hover:text-primary"
+ onClick={() => router.push(`/evcp/qna/${qna.id}`)}
+ >
+ <span className="line-clamp-2 max-w-[300px]">
+ {qna.title}
+ </span>
+ </Button>
+
+ {/* 상태 배지들 */}
+ <div className="flex items-center gap-1 flex-wrap">
+ {qna.hasAnswers && (
+ <Badge variant="secondary" className="text-xs">
+ <CheckCircle2 className="w-3 h-3 mr-1" />
+ 답변됨
+ </Badge>
+ )}
+ {!qna.hasAnswers && (
+ <Badge variant="outline" className="text-xs">
+ <AlertCircle className="w-3 h-3 mr-1" />
+ 답변 대기
+ </Badge>
+ )}
+ {qna.isPopular && (
+ <Badge variant="default" className="text-xs">
+ <TrendingUp className="w-3 h-3 mr-1" />
+ 인기
+ </Badge>
+ )}
+ </div>
+ </div>
+ )
+ },
+ enableSorting: true,
+ enableHiding: false,
+ },
+
+ // 작성자 정보
+ {
+ accessorKey: "authorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="작성자" />
+ ),
+ cell: ({ row }) => {
+ const qna = row.original
+
+ return (
+ <div className="flex items-center gap-2">
+ <Avatar className="h-8 w-8">
+ <AvatarImage src={qna.authorImageUrl || undefined} />
+ <AvatarFallback>
+ {qna.authorName?.slice(0, 2) || "??"}
+ </AvatarFallback>
+ </Avatar>
+ <div className="flex flex-col">
+ <span className="font-medium text-sm">{qna.authorName}</span>
+ <span className="text-xs text-muted-foreground">
+ {qna.authorEmail}
+ </span>
+ </div>
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // 회사 정보
+ {
+ accessorKey: "companyName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple
+ column={column}
+ title="회사"
+ />
+ ),
+ cell: ({ row }) => {
+ const qna = row.original
+
+ return (
+ <div className="flex flex-col gap-1">
+ <span className="font-medium text-sm">
+ {qna.companyName || "미지정"}
+ </span>
+ {qna.vendorType && (
+ <span
+ className="text-xs w-fit"
+ >
+ {qna.vendorType === "vendor" ? "일반 벤더" : "기술 벤더"}
+ </span>
+ )}
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // 도메인
+ {
+ accessorKey: "category",
+ header: "카테고리",
+ cell: ({ row }) => {
+ const domain = row.original.category
+ return (
+ <Badge variant="outline" className="text-xs">
+ {domain}
+ </Badge>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // 답변/댓글 통계
+ {
+ id: "statistics",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="활동" />
+ ),
+ cell: ({ row }) => {
+ const qna = row.original
+
+ return (
+ <div className="flex items-center gap-3">
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <div className="flex items-center gap-1 text-sm">
+ <MessageSquare className="h-4 w-4 text-blue-500" />
+ <span className="font-medium">{qna.totalAnswers || 0}</span>
+ </div>
+ </TooltipTrigger>
+ <TooltipContent>답변 수</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <div className="flex items-center gap-1 text-sm">
+ <MessageCircle className="h-4 w-4 text-green-500" />
+ <span className="font-medium">{qna.totalComments || 0}</span>
+ </div>
+ </TooltipTrigger>
+ <TooltipContent>댓글 수</TooltipContent>
+ </Tooltip>
+ </div>
+ )
+ },
+ enableSorting: false,
+ },
+
+ // 작성일
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="작성일" />
+ ),
+ cell: ({ row }) => (
+ <div className="text-sm">
+ {formatDate(row.original.createdAt)}
+ </div>
+ ),
+ enableSorting: true,
+ },
+
+ // 최근 활동
+ {
+ accessorKey: "lastActivityAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple
+ column={column}
+ title="최근 활동"
+ />
+ ),
+ cell: ({ row }) => {
+ const lastActivity = row.original.lastActivityAt
+
+ return (
+ <div className="text-sm">
+ {lastActivity ? formatDate(lastActivity) : "없음"}
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // 액션 메뉴
+ {
+ id: "actions",
+ cell: ({ row }) => {
+ const qna = row.original
+ const isAuthor = qna.author === currentUserId
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="메뉴 열기"
+ variant="ghost"
+ className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
+ >
+ <MoreHorizontal className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[160px]">
+ {/* ───────── 공통 : 상세 보기 ───────── */}
+ <DropdownMenuItem onClick={() => router.push(`/evcp/qna/${qna.id}`)}>
+ <Eye className="mr-2 h-4 w-4" />
+ 상세보기
+ </DropdownMenuItem>
+
+ {/* ───────── 본인 글일 때만 노출 ───────── */}
+ {isAuthor && (
+ <>
+ <DropdownMenuItem onClick={() => setRowAction({ type: "update", row })}>
+ <Edit className="mr-2 h-4 w-4" />
+ 수정
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+
+ <DropdownMenuItem
+ onClick={() => setRowAction({ type: "delete", row })}
+ className="text-destructive focus:text-destructive"
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </>
+ )}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ enableSorting: false,
+ enableHiding: false,
+ },
+ ]
+} \ No newline at end of file
diff --git a/lib/qna/table/qna-table-toolbar-actions.tsx b/lib/qna/table/qna-table-toolbar-actions.tsx
new file mode 100644
index 00000000..d3e8623e
--- /dev/null
+++ b/lib/qna/table/qna-table-toolbar-actions.tsx
@@ -0,0 +1,176 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, Plus, RefreshCw, FileSpreadsheet, FileText, Users } from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
+
+import { QnaViewSelect } from "@/db/schema"
+import { exportQnaData } from "./qna-export-actions"
+
+interface QnaTableToolbarActionsProps {
+ table: Table<QnaViewSelect>
+ domain: string
+ onCreateClick: () => void
+}
+
+export function QnaTableToolbarActions({
+ table,
+ domain,
+ onCreateClick
+}: QnaTableToolbarActionsProps) {
+ const [isExporting, setIsExporting] = React.useState(false)
+ const [isRefreshing, setIsRefreshing] = React.useState(false)
+
+ // 선택된 행들
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedCount = selectedRows.length
+
+ // 새로고침
+ const handleRefresh = async () => {
+ setIsRefreshing(true)
+ try {
+ // 페이지 새로고침 또는 데이터 재요청
+ window.location.reload()
+ } catch (error) {
+ toast.error("새로고침 중 오류가 발생했습니다.")
+ } finally {
+ setIsRefreshing(false)
+ }
+ }
+
+ // 데이터 내보내기
+ const handleExport = async (format: "csv" | "excel") => {
+ setIsExporting(true)
+ try {
+ const selectedData = selectedCount > 0
+ ? selectedRows.map(row => row.original)
+ : table.getFilteredRowModel().rows.map(row => row.original)
+
+ const result = await exportQnaData({
+ format,
+ data: selectedData,
+ fields: [
+ "title",
+ "authorName",
+ "companyName",
+ "totalAnswers",
+ "totalComments",
+ "createdAt",
+ "lastActivityAt"
+ ]
+ })
+
+ if (result.success) {
+ toast.success(`${format.toUpperCase()} 파일이 다운로드되었습니다.`)
+ } else {
+ toast.error(result.error || "내보내기에 실패했습니다.")
+ }
+ } catch (error) {
+ toast.error("내보내기 중 오류가 발생했습니다.")
+ console.error("Export error:", error)
+ } finally {
+ setIsExporting(false)
+ }
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {/* 선택된 항목 수 표시 */}
+ {selectedCount > 0 && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Users className="h-4 w-4" />
+ <span>{selectedCount}개 선택됨</span>
+ </div>
+ )}
+
+ {/* 새 질문 작성 버튼 */}
+ {domain === "partners" &&
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ onClick={onCreateClick}
+ size="sm"
+ className="h-8 gap-2"
+ >
+ <Plus className="h-4 w-4" />
+ 새 질문
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>새로운 질문을 작성합니다</TooltipContent>
+ </Tooltip>
+}
+
+ {/* 내보내기 드롭다운 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="h-8 gap-2"
+ disabled={isExporting}
+ >
+ <Download className="h-4 w-4" />
+ {isExporting ? "내보내는 중..." : "내보내기"}
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[180px]">
+ <DropdownMenuItem
+ onClick={() => handleExport("csv")}
+ disabled={isExporting}
+ >
+ <FileText className="mr-2 h-4 w-4" />
+ CSV 파일로 내보내기
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() => handleExport("excel")}
+ disabled={isExporting}
+ >
+ <FileSpreadsheet className="mr-2 h-4 w-4" />
+ Excel 파일로 내보내기
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onClick={() => {
+ const rowCount = selectedCount > 0 ? selectedCount : table.getFilteredRowModel().rows.length
+ toast.info(`${rowCount}개의 질문이 내보내집니다.`)
+ }}
+ disabled={isExporting}
+ className="text-xs text-muted-foreground"
+ >
+ {selectedCount > 0
+ ? `선택된 ${selectedCount}개 항목`
+ : `전체 ${table.getFilteredRowModel().rows.length}개 항목`
+ }
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ {/* 새로고침 버튼 */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefresh}
+ disabled={isRefreshing}
+ className="h-8 w-8 p-0"
+ >
+ <RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>목록 새로고침</TooltipContent>
+ </Tooltip>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/qna/table/qna-table.tsx b/lib/qna/table/qna-table.tsx
new file mode 100644
index 00000000..45efc124
--- /dev/null
+++ b/lib/qna/table/qna-table.tsx
@@ -0,0 +1,236 @@
+"use client"
+
+import * as React from "react"
+import { qnaView, type QnaViewSelect } from "@/db/schema"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { toSentenceCase } from "@/lib/utils"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { DataTableToolbar } from "@/components/data-table/data-table-toolbar"
+
+import { QNA_FILTER_OPTIONS, QNA_DOMAIN, QNA_VENDOR_TYPE } from "@/lib/qna/validation"
+import { DeleteQnaDialog } from "./delete-qna-dialog"
+import { CreateQnaDialog } from "./create-qna-dialog"
+import { UpdateQnaSheet } from "./update-qna-sheet"
+import { QnaTableToolbarActions } from "./qna-table-toolbar-actions"
+import { getColumns } from "./qna-table-columns"
+import { getQnaList } from "../service"
+import { useRouter } from "next/navigation"
+import { getDomainIcon, getQnaStatusIcon, getVendorTypeIcon } from "./utils"
+import { useSession } from "next-auth/react";
+
+interface QnaTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getQnaList>>,
+ // 추가 통계 데이터가 필요하면 여기에 추가
+ ]
+ >
+ domain:string
+}
+
+export function QnaTable({ promises, domain }: QnaTableProps) {
+ const [{ data, pageCount }] = React.use(promises)
+ const router = useRouter()
+ const { data: session } = useSession();
+
+ console.log(data)
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<QnaViewSelect> | null>(null)
+
+ const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false)
+
+ const columns = React.useMemo(
+ () =>
+ getColumns({
+ setRowAction,
+ router,
+ currentUserId: session?.user?.id, // 여기서 전달
+ }),
+ [router, setRowAction, session?.user?.id]
+ );
+ // 기본 필터 필드들 (상단 검색/필터 바용)
+ const filterFields: DataTableFilterField<QnaViewSelect>[] = [
+ {
+ id: "title",
+ label: "제목",
+ placeholder: "제목으로 검색...",
+ },
+ {
+ id: "authorName",
+ label: "작성자",
+ placeholder: "작성자명으로 검색...",
+ },
+ {
+ id: "companyName",
+ label: "회사명",
+ placeholder: "회사명으로 검색...",
+ },
+ {
+ id: "authorDomain",
+ label: "도메인",
+ options: QNA_FILTER_OPTIONS.authorDomain.map((domain) => ({
+ label: domain.label,
+ value: domain.value,
+ icon: getDomainIcon(domain.value),
+ })),
+ },
+ {
+ id: "vendorType",
+ label: "벤더 타입",
+ options: QNA_FILTER_OPTIONS.vendorType.map((type) => ({
+ label: type.label,
+ value: type.value,
+ icon: getVendorTypeIcon(type.value),
+ })),
+ },
+ {
+ id: "hasAnswers",
+ label: "답변 상태",
+ options: QNA_FILTER_OPTIONS.hasAnswers.map((status) => ({
+ label: status.label,
+ value: status.value,
+ icon: getQnaStatusIcon(status.value),
+ })),
+ },
+ ]
+
+ // 고급 필터 필드들 (고급 검색용)
+ const advancedFilterFields: DataTableAdvancedFilterField<QnaViewSelect>[] = [
+ {
+ id: "title",
+ label: "제목",
+ type: "text",
+ },
+ {
+ id: "content",
+ label: "내용",
+ type: "text",
+ },
+ {
+ id: "authorName",
+ label: "작성자명",
+ type: "text",
+ },
+ {
+ id: "companyName",
+ label: "회사명",
+ type: "text",
+ },
+ {
+ id: "authorDomain",
+ label: "사용자 도메인",
+ type: "multi-select",
+ options: QNA_FILTER_OPTIONS.authorDomain.map((domain) => ({
+ label: domain.label,
+ value: domain.value,
+ icon: getDomainIcon(domain.value),
+ })),
+ },
+ {
+ id: "vendorType",
+ label: "벤더 타입",
+ type: "multi-select",
+ options: QNA_FILTER_OPTIONS.vendorType.map((type) => ({
+ label: type.label,
+ value: type.value,
+ icon: getVendorTypeIcon(type.value),
+ })),
+ },
+ {
+ id: "hasAnswers",
+ label: "답변 여부",
+ type: "boolean",
+ },
+ {
+ id: "isPopular",
+ label: "인기 질문",
+ type: "boolean",
+ },
+ {
+ id: "totalAnswers",
+ label: "답변 수",
+ type: "number",
+ },
+ {
+ id: "totalComments",
+ label: "댓글 수",
+ type: "number",
+ },
+ {
+ id: "createdAt",
+ label: "작성일",
+ type: "date",
+ },
+ {
+ id: "lastActivityAt",
+ label: "최근 활동일",
+ type: "date",
+ },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "lastActivityAt", desc: true }], // 최근 활동순으로 기본 정렬
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => originalRow.id.toString(),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <QnaTableToolbarActions
+ table={table}
+ domain={domain}
+ onCreateClick={() => setIsCreateDialogOpen(true)}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 질문 생성 다이얼로그 */}
+ <CreateQnaDialog
+ open={isCreateDialogOpen}
+ onOpenChange={setIsCreateDialogOpen}
+ />
+
+ {/* 질문 수정 시트 */}
+ <UpdateQnaSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ qna={rowAction?.row.original ?? null}
+ />
+
+ {/* 질문 삭제 다이얼로그 */}
+ <DeleteQnaDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ qnas={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/qna/table/update-qna-sheet.tsx b/lib/qna/table/update-qna-sheet.tsx
new file mode 100644
index 00000000..72a3c633
--- /dev/null
+++ b/lib/qna/table/update-qna-sheet.tsx
@@ -0,0 +1,206 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+
+import { updateQnaSchema, type UpdateQnaSchema } from "@/lib/qna/validation"
+import { QNA_CATEGORY_LABELS, QnaViewSelect } from "@/db/schema"
+import { updateQna } from "../service"
+import TiptapEditor from "@/components/qna/tiptap-editor"
+
+interface UpdateQnaSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ qna: QnaViewSelect | null
+}
+
+export function UpdateQnaSheet({ open, onOpenChange, qna }: UpdateQnaSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ const form = useForm<UpdateQnaSchema>({
+ resolver: zodResolver(updateQnaSchema),
+ defaultValues: {
+ title: "",
+ content: "",
+ category: undefined,
+
+ },
+ })
+
+ // qna 데이터가 변경될 때 폼 초기화
+ React.useEffect(() => {
+ if (qna) {
+ form.reset({
+ title: qna.title,
+ content: qna.content,
+ category: qna.category,
+ })
+ }
+ }, [qna, form])
+
+
+ function onSubmit(input: UpdateQnaSchema) {
+ if (!qna) return
+
+ startUpdateTransition(async () => {
+ try {
+ const result = await updateQna(qna.id, input)
+
+ if (result.success) {
+ toast.success(result.message || "질문이 성공적으로 수정되었습니다.")
+ onOpenChange(false)
+ } else {
+ toast.error(result.error || "질문 수정에 실패했습니다.")
+ }
+ } catch (error) {
+ toast.error("예기치 못한 오류가 발생했습니다.")
+ console.error("질문 수정 오류:", error)
+ }
+ })
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+<SheetContent className="flex h-full w-full flex-col gap-6 sm:max-w-4xl">
+<SheetHeader className="text-left">
+ <SheetTitle>질문 수정</SheetTitle>
+ <SheetDescription>
+ 질문의 제목과 내용을 수정할 수 있습니다. 수정된 내용은 즉시 반영됩니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0 space-y-6">
+ {/* 작성자 정보 표시 */}
+ {qna && (
+ <div className="rounded-md border border-muted bg-muted/50 p-3 text-sm">
+ <div className="font-medium text-muted-foreground mb-1">질문 정보</div>
+ <div className="space-y-1">
+ <div>작성자: {qna.authorName} ({qna.authorEmail})</div>
+ <div>회사: {qna.companyName || "미지정"}</div>
+ <div>작성일: {new Date(qna.createdAt).toLocaleDateString("ko-KR")}</div>
+ <div>답변 수: {qna.totalAnswers}개 / 댓글 수: {qna.totalComments}개</div>
+ </div>
+ </div>
+ )}
+
+ {/* 제목 입력 */}
+ <FormField
+ control={form.control}
+ name="title"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>제목 *</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="질문의 제목을 입력해주세요"
+ disabled={isUpdatePending}
+ className="text-base"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="category"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>카테고리 *</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ disabled={isUpdatePending}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="카테고리를 선택해주세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {QNA_CATEGORY_LABELS.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 내용 입력 (리치텍스트 에디터) */}
+ <FormField
+ control={form.control}
+ name="content"
+ render={({ field }) => (
+ <FormItem className="flex flex-col flex-1 min-h-0">
+ <FormLabel>내용 *</FormLabel>
+ <FormControl className="flex flex-col flex-1 min-h-0">
+ <TiptapEditor
+ content={field.value}
+ setContent={field.onChange}
+ disabled={isUpdatePending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </form>
+ </Form>
+
+ <SheetFooter className="gap-2 pt-4 border-t">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isUpdatePending}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ onClick={form.handleSubmit(onSubmit)}
+ disabled={isUpdatePending}
+ >
+ {isUpdatePending ? "수정 중..." : "수정 완료"}
+ </Button>
+ </SheetFooter>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/qna/table/utils.tsx b/lib/qna/table/utils.tsx
new file mode 100644
index 00000000..f7f9effe
--- /dev/null
+++ b/lib/qna/table/utils.tsx
@@ -0,0 +1,329 @@
+import {
+ CheckCircle2,
+ AlertCircle,
+ TrendingUp,
+ Building2,
+ Wrench,
+ Shield,
+ User,
+ Users,
+ Crown,
+ MessageSquare,
+ MessageCircle,
+ Clock,
+ Calendar,
+ Eye,
+ EyeOff,
+ } from "lucide-react"
+
+ /**
+ * Q&A 상태에 따른 아이콘 반환
+ */
+ export function getQnaStatusIcon(status: string) {
+ switch (status) {
+ case "answered":
+ return CheckCircle2
+ case "unanswered":
+ return AlertCircle
+ case "popular":
+ return TrendingUp
+ default:
+ return MessageSquare
+ }
+ }
+
+ /**
+ * 벤더 타입에 따른 아이콘 반환
+ */
+ export function getVendorTypeIcon(vendorType: string) {
+ switch (vendorType) {
+ case "vendor":
+ return Building2
+ case "techVendor":
+ return Wrench
+ default:
+ return Building2
+ }
+ }
+
+ /**
+ * 사용자 도메인에 따른 아이콘 반환
+ */
+ export function getDomainIcon(domain: string) {
+ switch (domain) {
+ case "partners":
+ return Users
+ case "tech":
+ return Wrench
+ case "admin":
+ return Shield
+ default:
+ return User
+ }
+ }
+
+ /**
+ * Q&A 상태에 따른 배지 색상 반환
+ */
+ export function getQnaStatusBadge(qna: {
+ hasAnswers: boolean
+ isPopular: boolean
+ totalAnswers: number
+ totalComments: number
+ }) {
+ const badges = []
+
+ if (qna.hasAnswers) {
+ badges.push({
+ label: "답변됨",
+ variant: "secondary" as const,
+ icon: CheckCircle2,
+ })
+ } else {
+ badges.push({
+ label: "답변 대기",
+ variant: "outline" as const,
+ icon: AlertCircle,
+ })
+ }
+
+ if (qna.isPopular) {
+ badges.push({
+ label: "인기",
+ variant: "default" as const,
+ icon: TrendingUp,
+ })
+ }
+
+ return badges
+ }
+
+ /**
+ * 도메인에 따른 사용자 라벨 반환
+ */
+ export function getDomainLabel(domain: string) {
+ switch (domain) {
+ case "partners":
+ return "협력업체"
+ case "tech":
+ return "기술업체"
+ case "admin":
+ return "관리자"
+ default:
+ return domain
+ }
+ }
+
+ /**
+ * 벤더 타입에 따른 라벨 반환
+ */
+ export function getVendorTypeLabel(vendorType: string) {
+ switch (vendorType) {
+ case "vendor":
+ return "일반 벤더"
+ case "techVendor":
+ return "기술 벤더"
+ default:
+ return vendorType
+ }
+ }
+
+ /**
+ * Q&A 활동 통계 포맷팅
+ */
+ export function formatQnaStats(qna: {
+ totalAnswers: number
+ totalComments: number
+ lastActivityAt?: Date | null
+ }) {
+ const stats = []
+
+ if (qna.totalAnswers > 0) {
+ stats.push(`답변 ${qna.totalAnswers}개`)
+ }
+
+ if (qna.totalComments > 0) {
+ stats.push(`댓글 ${qna.totalComments}개`)
+ }
+
+ if (stats.length === 0) {
+ return "활동 없음"
+ }
+
+ return stats.join(" • ")
+ }
+
+ /**
+ * 상대적 시간 포맷팅 (예: "3시간 전", "2일 전")
+ */
+ export function formatRelativeTime(date: Date | string) {
+ const now = new Date()
+ const targetDate = new Date(date)
+ const diffInMilliseconds = now.getTime() - targetDate.getTime()
+ const diffInSeconds = Math.floor(diffInMilliseconds / 1000)
+ const diffInMinutes = Math.floor(diffInSeconds / 60)
+ const diffInHours = Math.floor(diffInMinutes / 60)
+ const diffInDays = Math.floor(diffInHours / 24)
+ const diffInMonths = Math.floor(diffInDays / 30)
+ const diffInYears = Math.floor(diffInDays / 365)
+
+ if (diffInSeconds < 60) {
+ return "방금 전"
+ } else if (diffInMinutes < 60) {
+ return `${diffInMinutes}분 전`
+ } else if (diffInHours < 24) {
+ return `${diffInHours}시간 전`
+ } else if (diffInDays < 30) {
+ return `${diffInDays}일 전`
+ } else if (diffInMonths < 12) {
+ return `${diffInMonths}개월 전`
+ } else {
+ return `${diffInYears}년 전`
+ }
+ }
+
+ /**
+ * Q&A 우선순위 계산 (정렬용)
+ */
+ export function calculateQnaPriority(qna: {
+ hasAnswers: boolean
+ isPopular: boolean
+ totalAnswers: number
+ totalComments: number
+ lastActivityAt?: Date | null
+ createdAt: Date
+ }) {
+ let priority = 0
+
+ // 답변이 없는 질문에 높은 우선순위
+ if (!qna.hasAnswers) {
+ priority += 100
+ }
+
+ // 인기 질문에 우선순위 추가
+ if (qna.isPopular) {
+ priority += 50
+ }
+
+ // 최근 활동에 따른 우선순위
+ if (qna.lastActivityAt) {
+ const daysSinceActivity = Math.floor(
+ (new Date().getTime() - new Date(qna.lastActivityAt).getTime()) / (1000 * 60 * 60 * 24)
+ )
+ priority += Math.max(0, 30 - daysSinceActivity) // 최근 30일 내 활동
+ }
+
+ // 활동량에 따른 우선순위
+ priority += Math.min(qna.totalAnswers * 2, 20) // 답변 수 (최대 20점)
+ priority += Math.min(qna.totalComments, 10) // 댓글 수 (최대 10점)
+
+ return priority
+ }
+
+ /**
+ * Q&A 텍스트 요약 (미리보기용)
+ */
+ export function truncateQnaContent(content: string, maxLength: number = 100) {
+ // HTML 태그 제거
+ const textContent = content.replace(/<[^>]*>/g, "").trim()
+
+ if (textContent.length <= maxLength) {
+ return textContent
+ }
+
+ return textContent.slice(0, maxLength).trim() + "..."
+ }
+
+ /**
+ * Q&A 검색 키워드 하이라이팅
+ */
+ export function highlightSearchKeywords(text: string, keywords: string) {
+ if (!keywords.trim()) return text
+
+ const keywordList = keywords.trim().split(/\s+/)
+ let highlightedText = text
+
+ keywordList.forEach(keyword => {
+ const regex = new RegExp(`(${keyword})`, "gi")
+ highlightedText = highlightedText.replace(
+ regex,
+ '<mark class="bg-yellow-200 dark:bg-yellow-800">$1</mark>'
+ )
+ })
+
+ return highlightedText
+ }
+
+ /**
+ * Q&A 필터 조건 검증
+ */
+ export function validateQnaFilters(filters: {
+ search?: string
+ authorDomain?: string[]
+ vendorType?: string[]
+ hasAnswers?: string
+ dateRange?: { from?: Date; to?: Date }
+ }) {
+ const errors: string[] = []
+
+ // 검색어 길이 체크
+ if (filters.search && filters.search.length > 100) {
+ errors.push("검색어는 100자 이하로 입력해주세요.")
+ }
+
+ // 날짜 범위 체크
+ if (filters.dateRange?.from && filters.dateRange?.to) {
+ if (filters.dateRange.from > filters.dateRange.to) {
+ errors.push("시작 날짜가 종료 날짜보다 늦을 수 없습니다.")
+ }
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors,
+ }
+ }
+
+ /**
+ * Q&A URL 생성 유틸리티
+ */
+ export function generateQnaUrls(qnaId: number) {
+ const baseUrl = typeof window !== "undefined" ? window.location.origin : ""
+
+ return {
+ detail: `${baseUrl}/qna/${qnaId}`,
+ edit: `${baseUrl}/qna/${qnaId}/edit`,
+ share: `${baseUrl}/qna/${qnaId}?shared=true`,
+ }
+ }
+
+ /**
+ * Q&A 메타데이터 생성 (SEO용)
+ */
+ export function generateQnaMetadata(qna: {
+ title: string
+ content: string
+ authorName: string
+ companyName?: string | null
+ totalAnswers: number
+ createdAt: Date
+ }) {
+ const description = truncateQnaContent(qna.content, 160)
+ const keywords = [
+ "Q&A",
+ "질문",
+ "답변",
+ qna.authorName,
+ qna.companyName,
+ ...qna.title.split(" ").slice(0, 5), // 제목의 첫 5단어
+ ].filter(Boolean).join(", ")
+
+ return {
+ title: `${qna.title} - Q&A`,
+ description,
+ keywords,
+ author: qna.authorName,
+ publishedTime: qna.createdAt.toISOString(),
+ articleTag: ["Q&A", "질문", "답변"],
+ }
+ } \ No newline at end of file