summaryrefslogtreecommitdiff
path: root/components/pq/pq-review-table.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/pq/pq-review-table.tsx')
-rw-r--r--components/pq/pq-review-table.tsx340
1 files changed, 340 insertions, 0 deletions
diff --git a/components/pq/pq-review-table.tsx b/components/pq/pq-review-table.tsx
new file mode 100644
index 00000000..e778cf91
--- /dev/null
+++ b/components/pq/pq-review-table.tsx
@@ -0,0 +1,340 @@
+"use client"
+
+import * as React from "react"
+import { ChevronsUpDown, MessagesSquare, Download, Loader2 } from "lucide-react"
+
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { Card } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { PQGroupData } from "@/lib/pq/service"
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { Textarea } from "@/components/ui/textarea"
+import { addReviewCommentAction, getItemReviewLogsAction } from "@/lib/pq/service"
+import { useToast } from "@/hooks/use-toast"
+import { formatDate } from "@/lib/utils"
+import { downloadFileAction } from "@/lib/downloadFile"
+
+interface ReviewLog {
+ id: number
+ reviewerComment: string
+ reviewerName: string | null
+ createdAt: Date
+}
+
+interface VendorPQReviewPageProps {
+ data: PQGroupData[];
+ onCommentAdded?: () => void; // 코멘트 추가 콜백
+}
+
+export default function VendorPQReviewPage({ data, onCommentAdded }: VendorPQReviewPageProps) {
+ const { toast } = useToast()
+
+ // 파일 다운로드 함수 - 서버 액션 사용
+ const handleFileDownload = async (filePath: string, fileName: string) => {
+ try {
+ toast({
+ title: "Download Started",
+ description: `Preparing ${fileName} for download...`,
+ });
+
+ // 서버 액션 호출
+ const result = await downloadFileAction(filePath);
+
+ if (!result.ok || !result.data) {
+ throw new Error(result.error || 'Failed to download file');
+ }
+
+ // Base64 디코딩하여 Blob 생성
+ const binaryString = atob(result.data.content);
+ const bytes = new Uint8Array(binaryString.length);
+ for (let i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+
+ // Blob 생성 및 다운로드
+ const blob = new Blob([bytes.buffer], { type: result.data.mimeType });
+ const url = URL.createObjectURL(blob);
+
+ // 다운로드 링크 생성 및 클릭
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+
+ // 정리
+ URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ toast({
+ title: "Download Complete",
+ description: `${fileName} downloaded successfully`,
+ });
+ } catch (error) {
+ console.error('Download error:', error);
+ toast({
+ title: "Download Error",
+ description: error instanceof Error ? error.message : "Failed to download file",
+ variant: "destructive"
+ });
+ }
+ };
+
+ return (
+ <div className="space-y-4">
+ {data.map((group) => (
+ <Collapsible key={group.groupName} defaultOpen>
+ <CollapsibleTrigger asChild>
+ <div className="flex items-center justify-between cursor-pointer p-3 bg-muted rounded">
+ <h2 className="font-semibold text-lg">{group.groupName}</h2>
+ <Button variant="ghost" size="sm" className="p-0 h-7 w-7">
+ <ChevronsUpDown className="h-4 w-4" />
+ </Button>
+ </div>
+ </CollapsibleTrigger>
+
+ <CollapsibleContent>
+ <Card className="mt-2 p-4">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[60px]">Code</TableHead>
+ <TableHead>Check Point</TableHead>
+ <TableHead>Answer</TableHead>
+ <TableHead className="w-[180px]">Attachments</TableHead>
+ <TableHead className="w-[60px] text-center">Comments</TableHead>
+ </TableRow>
+ </TableHeader>
+
+ <TableBody>
+ {group.items.map((item) => (
+ <TableRow key={item.criteriaId}>
+ <TableCell className="font-medium">{item.code}</TableCell>
+ <TableCell>{item.checkPoint}</TableCell>
+
+ <TableCell>
+ {item.answer ? (
+ <p className="whitespace-pre-wrap text-sm">
+ {item.answer}
+ </p>
+ ) : (
+ <p className="text-sm text-muted-foreground">(no answer)</p>
+ )}
+ </TableCell>
+
+ <TableCell>
+ {item.attachments.length > 0 ? (
+ <ul className="list-none space-y-1">
+ {item.attachments.map((file) => (
+ <li key={file.attachId} className="text-sm flex items-center">
+ <button
+ className="text-blue-600 hover:text-blue-800 hover:underline flex items-center truncate max-w-[160px]"
+ onClick={() => handleFileDownload(file.filePath, file.fileName)}
+ >
+ <Download className="h-3 w-3 mr-1 flex-shrink-0" />
+ <span className="truncate">{file.fileName}</span>
+ </button>
+ </li>
+ ))}
+ </ul>
+ ) : (
+ <p className="text-sm text-muted-foreground">(none)</p>
+ )}
+ </TableCell>
+
+ <TableCell className="text-center">
+ <ItemReviewButton
+ answerId={item.answerId ?? undefined}
+ checkPoint={item.checkPoint}
+ onCommentAdded={onCommentAdded}
+ />
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </Card>
+ </CollapsibleContent>
+ </Collapsible>
+ ))}
+ </div>
+ )
+}
+
+interface ItemReviewButtonProps {
+ answerId?: number;
+ checkPoint: string; // Check Point 추가
+ onCommentAdded?: () => void;
+}
+
+/**
+ * A button that opens a dialog to show logs + add new comment for a single item (vendorPqCriteriaAnswers).
+ */
+function ItemReviewButton({ answerId, checkPoint, onCommentAdded }: ItemReviewButtonProps) {
+ const { toast } = useToast();
+ const [open, setOpen] = React.useState(false);
+ const [logs, setLogs] = React.useState<ReviewLog[]>([]);
+ const [newComment, setNewComment] = React.useState("");
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [hasComments, setHasComments] = React.useState(false);
+
+ // If there's no answerId, item wasn't answered
+ if (!answerId) {
+ return <p className="text-xs text-muted-foreground">N/A</p>;
+ }
+
+ // fetchLogs 함수를 useCallback으로 메모이제이션
+ const fetchLogs = React.useCallback(async () => {
+ try {
+ setIsLoading(true);
+ const res = await getItemReviewLogsAction({ answerId });
+
+ if (res.ok && res.data) {
+ setLogs(res.data);
+ // 코멘트 존재 여부 설정
+ setHasComments(res.data.length > 0);
+ } else {
+ console.error("Error response:", res.error);
+ toast({ title: "Error", description: res.error, variant: "destructive" });
+ }
+ } catch (error) {
+ console.error("Fetch error:", error);
+ toast({ title: "Error", description: String(error), variant: "destructive" });
+ } finally {
+ setIsLoading(false);
+ }
+ }, [answerId, toast]);
+
+ // 초기 로드 시 코멘트 존재 여부 확인 (아이콘 색상용)
+ React.useEffect(() => {
+ const checkComments = async () => {
+ try {
+ const res = await getItemReviewLogsAction({ answerId });
+ if (res.ok && res.data) {
+ setHasComments(res.data.length > 0);
+ }
+ } catch (error) {
+ console.error("Error checking comments:", error);
+ }
+ };
+
+ checkComments();
+ }, [answerId]);
+
+ // open 상태가 변경될 때 로그 가져오기
+ React.useEffect(() => {
+ if (open) {
+ fetchLogs();
+ }
+ }, [open, fetchLogs]);
+
+ // 버튼 클릭 핸들러 - 다이얼로그 열기
+ const handleButtonClick = React.useCallback(() => {
+ setOpen(true);
+ }, []);
+
+ // 다이얼로그 상태 변경 핸들러
+ const handleOpenChange = React.useCallback((nextOpen: boolean) => {
+ setOpen(nextOpen);
+ }, []);
+
+ // 코멘트 추가 핸들러
+ const handleAddComment = React.useCallback(async () => {
+ try {
+ setIsLoading(true);
+
+ const res = await addReviewCommentAction({
+ answerId,
+ comment: newComment,
+ reviewerName: "AdminUser",
+ });
+
+ if (res.ok) {
+ toast({ title: "Comment added", description: "New review comment saved" });
+ setNewComment("");
+ setHasComments(true); // 코멘트 추가 성공 시 상태 업데이트
+
+ // 코멘트가 추가되었음을 부모 컴포넌트에 알림
+ if (onCommentAdded) {
+ onCommentAdded();
+ }
+
+ // 로그 다시 가져오기
+ fetchLogs();
+ } else {
+ toast({ title: "Error", description: res.error, variant: "destructive" });
+ }
+ } catch (error) {
+ toast({ title: "Error", description: String(error), variant: "destructive" });
+ } finally {
+ setIsLoading(false);
+ }
+ }, [answerId, newComment, onCommentAdded, fetchLogs, toast]);
+
+ return (
+ <>
+ <Button variant="ghost" size="sm" onClick={handleButtonClick}>
+ <MessagesSquare
+ className={`h-4 w-4 ${hasComments ? 'text-blue-600' : ''}`}
+ />
+ </Button>
+
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>{checkPoint} Comments</DialogTitle>
+ </DialogHeader>
+
+ {/* Logs section */}
+ <div className="max-h-[200px] overflow-y-auto space-y-2">
+ {isLoading ? (
+ <div className="flex justify-center p-4">
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+ </div>
+ ) : logs.length > 0 ? (
+ logs.map((log) => (
+ <div key={log.id} className="p-2 border rounded text-sm">
+ <p className="font-medium">{log.reviewerName}</p>
+ <p>{log.reviewerComment}</p>
+ <p className="text-xs text-muted-foreground">
+ {formatDate(log.createdAt)}
+ </p>
+ </div>
+ ))
+ ) : (
+ <p className="text-sm text-muted-foreground">No comments yet.</p>
+ )}
+ </div>
+
+ {/* Add new comment */}
+ <div className="space-y-2">
+ <Textarea
+ placeholder="Add a new comment..."
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ />
+ <Button
+ size="sm"
+ onClick={handleAddComment}
+ disabled={isLoading || !newComment.trim()}
+ >
+ Add Comment
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ </>
+ );
+} \ No newline at end of file