summaryrefslogtreecommitdiff
path: root/components/pq/pq-review-detail.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/pq/pq-review-detail.tsx')
-rw-r--r--components/pq/pq-review-detail.tsx712
1 files changed, 712 insertions, 0 deletions
diff --git a/components/pq/pq-review-detail.tsx b/components/pq/pq-review-detail.tsx
new file mode 100644
index 00000000..e5cd080e
--- /dev/null
+++ b/components/pq/pq-review-detail.tsx
@@ -0,0 +1,712 @@
+"use client"
+
+import React from "react"
+import { Button } from "@/components/ui/button"
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
+import { Textarea } from "@/components/ui/textarea"
+import { useToast } from "@/hooks/use-toast"
+import { PQGroupData, requestPqChangesAction, updateVendorStatusAction, getItemReviewLogsAction } from "@/lib/pq/service"
+import { Vendor } from "@/db/schema/vendors"
+import { Separator } from "@/components/ui/separator"
+import { ChevronsUpDown, MessagesSquare, Download, Loader2, X } 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 { formatDate } from "@/lib/utils"
+import { downloadFileAction } from "@/lib/downloadFile"
+
+// 코멘트 상태를 위한 인터페이스 정의
+interface PendingComment {
+ answerId: number;
+ checkPoint: string;
+ code: string;
+ comment: string;
+ createdAt: Date;
+}
+
+interface ReviewLog {
+ id: number
+ reviewerComment: string
+ reviewerName: string | null
+ createdAt: Date
+}
+
+export default function VendorPQAdminReview({
+ data,
+ vendor,
+}: {
+ data: PQGroupData[]
+ vendor: Vendor
+}) {
+ const { toast } = useToast()
+
+ // 다이얼로그 상태들
+ const [showRequestDialog, setShowRequestDialog] = React.useState(false)
+ const [showApproveDialog, setShowApproveDialog] = React.useState(false)
+ const [showRejectDialog, setShowRejectDialog] = React.useState(false)
+
+ // 코멘트 상태들
+ const [requestComment, setRequestComment] = React.useState("")
+ const [approveComment, setApproveComment] = React.useState("")
+ const [rejectComment, setRejectComment] = React.useState("")
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ // 항목별 코멘트 상태 추적 (메모리에만 저장)
+ const [pendingComments, setPendingComments] = React.useState<PendingComment[]>([])
+
+ // 코멘트 추가 핸들러 - 실제 서버 저장이 아닌 메모리에 저장
+ const handleCommentAdded = (newComment: PendingComment) => {
+ setPendingComments(prev => [...prev, newComment]);
+ toast({
+ title: "Comment Added",
+ description: `Comment added for ${newComment.code}. Please "Request Changes" to save.`
+ });
+ }
+
+ // 코멘트 삭제 핸들러
+ const handleRemoveComment = (index: number) => {
+ setPendingComments(prev => prev.filter((_, i) => i !== index));
+ }
+
+ // 1) 승인 다이얼로그 표시
+ const handleApprove = () => {
+ // 코멘트가 있는데 승인하려고 하면 경고
+ if (pendingComments.length > 0) {
+ if (!confirm('You have unsaved comments. Are you sure you want to approve without requesting changes?')) {
+ return;
+ }
+ }
+ setShowApproveDialog(true)
+ }
+
+ // 실제 승인 처리
+ const handleSubmitApprove = async () => {
+ try {
+ setIsLoading(true)
+ setShowApproveDialog(false)
+
+ const res = await updateVendorStatusAction(vendor.id, "APPROVED")
+ if (res.ok) {
+ toast({ title: "Approved", description: "Vendor PQ has been approved." })
+ // 코멘트 초기화
+ setPendingComments([]);
+ } else {
+ toast({ title: "Error", description: res.error, variant: "destructive" })
+ }
+ } catch (error) {
+ toast({ title: "Error", description: String(error), variant: "destructive" })
+ } finally {
+ setIsLoading(false)
+ setApproveComment("")
+ }
+ }
+
+ // 2) 거부 다이얼로그 표시
+ const handleReject = () => {
+ // 코멘트가 있는데 거부하려고 하면 경고
+ if (pendingComments.length > 0) {
+ if (!confirm('You have unsaved comments. Are you sure you want to reject without requesting changes?')) {
+ return;
+ }
+ }
+ setShowRejectDialog(true)
+ }
+
+ // 실제 거부 처리
+ const handleSubmitReject = async () => {
+ try {
+ setIsLoading(true)
+ setShowRejectDialog(false)
+
+ const res = await updateVendorStatusAction(vendor.id, "REJECTED")
+ if (res.ok) {
+ toast({ title: "Rejected", description: "Vendor PQ has been rejected." })
+ // 코멘트 초기화
+ setPendingComments([]);
+ } else {
+ toast({ title: "Error", description: res.error, variant: "destructive" })
+ }
+ } catch (error) {
+ toast({ title: "Error", description: String(error), variant: "destructive" })
+ } finally {
+ setIsLoading(false)
+ setRejectComment("")
+ }
+ }
+
+ // 3) 변경 요청 다이얼로그 표시
+ const handleRequestChanges = () => {
+ setShowRequestDialog(true)
+ }
+
+ // 4) 변경 요청 처리 - 이제 모든 코멘트를 한 번에 저장
+// 4) 변경 요청 처리 - 이제 모든 코멘트를 한 번에 저장
+const handleSubmitRequestChanges = async () => {
+ try {
+ setIsLoading(true);
+ setShowRequestDialog(false);
+
+ // 항목별 코멘트 준비 - answerId와 함께 checkPoint와 code도 전송
+ const itemComments = pendingComments.map(pc => ({
+ answerId: pc.answerId,
+ checkPoint: pc.checkPoint, // 추가: 체크포인트 정보 전송
+ code: pc.code, // 추가: 코드 정보 전송
+ comment: pc.comment
+ }));
+
+ // 서버 액션 호출
+ const res = await requestPqChangesAction({
+ vendorId: vendor.id,
+ comment: itemComments,
+ generalComment: requestComment || undefined
+ });
+
+ if (res.ok) {
+ toast({
+ title: "Changes Requested",
+ description: "Vendor was notified of your comments.",
+ });
+ // 코멘트 초기화
+ setPendingComments([]);
+ } else {
+ toast({ title: "Error", description: res.error, variant: "destructive" });
+ }
+ } catch (error) {
+ toast({ title: "Error", description: String(error), variant: "destructive" });
+ } finally {
+ setIsLoading(false);
+ setRequestComment("");
+ }
+};
+
+ return (
+ <div className="space-y-4">
+ {/* Top header */}
+ <div className="flex items-center justify-between">
+ <h2 className="text-2xl font-bold">
+ {vendor.vendorCode} - {vendor.vendorName} PQ Review
+ </h2>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ disabled={isLoading}
+ onClick={handleReject}
+ >
+ Reject
+ </Button>
+ <Button
+ variant={pendingComments.length > 0 ? "default" : "outline"}
+ disabled={isLoading}
+ onClick={handleRequestChanges}
+ >
+ Request Changes
+ {pendingComments.length > 0 && (
+ <span className="ml-2 bg-white text-primary rounded-full h-5 min-w-5 inline-flex items-center justify-center text-xs px-1">
+ {pendingComments.length}
+ </span>
+ )}
+ </Button>
+ <Button
+ disabled={isLoading}
+ onClick={handleApprove}
+ >
+ Approve
+ </Button>
+ </div>
+ </div>
+
+ <p className="text-sm text-muted-foreground">
+ Review the submitted PQ items below, then approve, reject, or request more info.
+ </p>
+
+ {/* 코멘트가 있을 때 알림 표시 */}
+ {pendingComments.length > 0 && (
+ <div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md text-yellow-800">
+ <p className="text-sm font-medium flex items-center">
+ <span className="mr-2">⚠️</span>
+ You have {pendingComments.length} pending comments. Click "Request Changes" to save them.
+ </p>
+ </div>
+ )}
+
+ <Separator />
+
+ {/* VendorPQReviewPage 컴포넌트 대신 직접 구현 */}
+ <VendorPQReviewPageIntegrated
+ data={data}
+ onCommentAdded={handleCommentAdded}
+ />
+
+ {/* 변경 요청 다이얼로그 */}
+ <Dialog open={showRequestDialog} onOpenChange={setShowRequestDialog}>
+ <DialogContent className="max-w-3xl">
+ <DialogHeader>
+ <DialogTitle>Request PQ Changes</DialogTitle>
+ <DialogDescription>
+ Review your comments and add any additional notes. The vendor will receive these changes.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 항목별 코멘트 목록 */}
+ {pendingComments.length > 0 && (
+ <div className="border rounded-md p-2 space-y-2 max-h-[300px] overflow-y-auto">
+ <h3 className="font-medium text-sm">Item Comments:</h3>
+ {pendingComments.map((comment, index) => (
+ <div key={index} className="flex items-start gap-2 p-2 border rounded-md bg-muted/50">
+ <div className="flex-1">
+ <div className="flex items-center gap-2">
+ <span className="text-sm font-medium">{comment.code}</span>
+ <span className="text-sm">{comment.checkPoint}</span>
+ </div>
+ <p className="text-sm mt-1">{comment.comment}</p>
+ <p className="text-xs text-muted-foreground mt-1">
+ {formatDate(comment.createdAt)}
+ </p>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="p-0 h-8 w-8"
+ onClick={() => handleRemoveComment(index)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+
+ {/* 추가 코멘트 입력 */}
+ <div className="space-y-2 mt-2">
+ <label className="text-sm font-medium">
+ {pendingComments.length > 0
+ ? "Additional comments (optional):"
+ : "Enter details about what should be modified:"}
+ </label>
+ <Textarea
+ value={requestComment}
+ onChange={(e) => setRequestComment(e.target.value)}
+ placeholder={pendingComments.length > 0
+ ? "Add any additional notes..."
+ : "Please correct item #1, etc..."}
+ className="min-h-[100px]"
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setShowRequestDialog(false)}
+ disabled={isLoading}
+ >
+ Cancel
+ </Button>
+ <Button
+ onClick={handleSubmitRequestChanges}
+ disabled={isLoading || (pendingComments.length === 0 && !requestComment.trim())}
+ >
+ Submit Changes
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* 승인 확인 다이얼로그 */}
+ <Dialog open={showApproveDialog} onOpenChange={setShowApproveDialog}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Confirm Approval</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to approve this vendor PQ? You can add a comment if needed.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-2">
+ <Textarea
+ value={approveComment}
+ onChange={(e) => setApproveComment(e.target.value)}
+ placeholder="Optional: Add any comments about this approval"
+ className="min-h-[100px]"
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setShowApproveDialog(false)}
+ disabled={isLoading}
+ >
+ Cancel
+ </Button>
+ <Button
+ onClick={handleSubmitApprove}
+ disabled={isLoading}
+ >
+ Confirm Approval
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* 거부 확인 다이얼로그 */}
+ <Dialog open={showRejectDialog} onOpenChange={setShowRejectDialog}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Confirm Rejection</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to reject this vendor PQ? Please provide a reason.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-2">
+ <Textarea
+ value={rejectComment}
+ onChange={(e) => setRejectComment(e.target.value)}
+ placeholder="Required: Provide reason for rejection"
+ className="min-h-[150px]"
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setShowRejectDialog(false)}
+ disabled={isLoading}
+ >
+ Cancel
+ </Button>
+ <Button
+ onClick={handleSubmitReject}
+ disabled={isLoading || !rejectComment.trim()}
+ variant="destructive"
+ >
+ Confirm Rejection
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ )
+}
+
+// 코멘트 추가 함수 인터페이스
+interface VendorPQReviewPageIntegratedProps {
+ data: PQGroupData[];
+ onCommentAdded: (comment: PendingComment) => void;
+}
+
+// 통합된 VendorPQReviewPage 컴포넌트
+function VendorPQReviewPageIntegrated({ data, onCommentAdded }: VendorPQReviewPageIntegratedProps) {
+ 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">
+ <ItemCommentButton
+ item={item}
+ onCommentAdded={onCommentAdded}
+ />
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </Card>
+ </CollapsibleContent>
+ </Collapsible>
+ ))}
+ </div>
+ );
+}
+
+// 항목 코멘트 버튼 컴포넌트 props
+interface ItemCommentButtonProps {
+ item: any; // 항목 데이터
+ onCommentAdded: (comment: PendingComment) => void;
+}
+
+// 항목별 코멘트 버튼 컴포넌트 (기존 로그 표시 + 메모리에 새 코멘트 저장)
+function ItemCommentButton({ item, onCommentAdded }: ItemCommentButtonProps) {
+ 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 (!item.answerId) {
+ return <p className="text-xs text-muted-foreground">N/A</p>;
+ }
+
+ // 기존 로그 가져오기
+ const fetchLogs = React.useCallback(async () => {
+ try {
+ setIsLoading(true);
+ const res = await getItemReviewLogsAction({ answerId: item.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);
+ }
+ }, [item.answerId, toast]);
+
+ // 초기 로드 시 코멘트 존재 여부 확인 (아이콘 색상용)
+ React.useEffect(() => {
+ const checkComments = async () => {
+ try {
+ const res = await getItemReviewLogsAction({ answerId: item.answerId });
+ if (res.ok && res.data) {
+ setHasComments(res.data.length > 0);
+ }
+ } catch (error) {
+ console.error("Error checking comments:", error);
+ }
+ };
+
+ checkComments();
+ }, [item.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(() => {
+ if (!newComment.trim()) return;
+
+ setIsLoading(true);
+
+ // 새 코멘트 생성
+ const pendingComment: PendingComment = {
+ answerId: item.answerId,
+ checkPoint: item.checkPoint,
+ code: item.code,
+ comment: newComment.trim(),
+ createdAt: new Date()
+ };
+
+ // 부모 컴포넌트에 전달
+ onCommentAdded(pendingComment);
+
+ // 상태 초기화
+ setNewComment("");
+ setOpen(false);
+ setIsLoading(false);
+ }, [item, newComment, onCommentAdded]);
+
+ 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>{item.checkPoint}</DialogTitle>
+ <DialogDescription>
+ Review existing comments and add new ones
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 기존 로그 섹션 */}
+ <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 ? (
+ <div className="space-y-2">
+ <h3 className="text-sm font-medium">Previous Comments:</h3>
+ {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>
+ ))}
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">No previous comments yet.</p>
+ )}
+ </div>
+
+ {/* 구분선 */}
+ {/* <Separator /> */}
+
+ {/* 새 코멘트 추가 섹션 */}
+ <div className="space-y-2 mt-2">
+ <div className="flex items-center justify-between">
+ {/* <h3 className="text-sm font-medium">Add New Comment:</h3> */}
+ {/* <p className="text-xs text-muted-foreground">
+ Comments will be saved when you click "Request Changes"
+ </p> */}
+ </div>
+ <Textarea
+ placeholder="Add your comment..."
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ className="min-h-[100px]"
+ />
+ <Button
+ onClick={handleAddComment}
+ disabled={isLoading || !newComment.trim()}
+ >
+ Add Comment
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ </>
+ );
+} \ No newline at end of file