diff options
Diffstat (limited to 'components/pq/pq-review-detail.tsx')
| -rw-r--r-- | components/pq/pq-review-detail.tsx | 452 |
1 files changed, 309 insertions, 143 deletions
diff --git a/components/pq/pq-review-detail.tsx b/components/pq/pq-review-detail.tsx index e5cd080e..18af02ed 100644 --- a/components/pq/pq-review-detail.tsx +++ b/components/pq/pq-review-detail.tsx @@ -5,9 +5,16 @@ 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 { + PQGroupData, + requestPqChangesAction, + updateVendorStatusAction, + updateProjectPQStatusAction, + getItemReviewLogsAction +} from "@/lib/pq/service" import { Vendor } from "@/db/schema/vendors" import { Separator } from "@/components/ui/separator" +import { Badge } from "@/components/ui/badge" import { ChevronsUpDown, MessagesSquare, Download, Loader2, X } from "lucide-react" import { Collapsible, @@ -42,38 +49,80 @@ interface ReviewLog { createdAt: Date } +// Updated props interface to support both general and project PQs +interface VendorPQAdminReviewProps { + data: PQGroupData[] + vendor: Vendor + projectId?: number + projectName?: string + projectStatus?: string + loadData: () => Promise<PQGroupData[]> + pqType: 'general' | 'project' +} + export default function VendorPQAdminReview({ data, vendor, -}: { - data: PQGroupData[] - vendor: Vendor -}) { + projectId, + projectName, + projectStatus, + loadData, + pqType +}: VendorPQAdminReviewProps) { const { toast } = useToast() - + + // State for dynamically loaded data + const [pqData, setPqData] = React.useState<PQGroupData[]>(data) + const [isDataLoading, setIsDataLoading] = React.useState(false) + + // Load data if not provided initially (for tab switching) + React.useEffect(() => { + if (data.length === 0) { + const fetchData = async () => { + setIsDataLoading(true) + try { + const freshData = await loadData() + setPqData(freshData) + } catch (error) { + console.error("Error loading PQ data:", error) + toast({ + title: "Error", + description: "Failed to load PQ data", + variant: "destructive" + }) + } finally { + setIsDataLoading(false) + } + } + fetchData() + } else { + setPqData(data) + } + }, [data, loadData, toast]) + // 다이얼로그 상태들 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.` + 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)); @@ -90,19 +139,40 @@ export default function VendorPQAdminReview({ setShowApproveDialog(true) } - // 실제 승인 처리 + // 실제 승인 처리 - 일반 PQ와 프로젝트 PQ 분리 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." }) + + let res; + + if (pqType === 'general') { + // 일반 PQ 승인 + res = await updateVendorStatusAction(vendor.id, "PQ_APPROVED") + } else if (projectId) { + // 프로젝트 PQ 승인 + res = await updateProjectPQStatusAction({ + vendorId: vendor.id, + projectId, + status: "APPROVED", + comment: approveComment.trim() || undefined + }) + } + + if (res?.ok) { + toast({ + title: "Approved", + description: `${pqType === 'general' ? 'General' : 'Project'} PQ has been approved.` + }) // 코멘트 초기화 setPendingComments([]); } else { - toast({ title: "Error", description: res.error, variant: "destructive" }) + toast({ + title: "Error", + description: res?.error || "An error occurred", + variant: "destructive" + }) } } catch (error) { toast({ title: "Error", description: String(error), variant: "destructive" }) @@ -123,19 +193,49 @@ export default function VendorPQAdminReview({ setShowRejectDialog(true) } - // 실제 거부 처리 + // 실제 거부 처리 - 일반 PQ와 프로젝트 PQ 분리 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." }) + + if (!rejectComment.trim()) { + toast({ + title: "Error", + description: "Please provide a reason for rejection", + variant: "destructive" + }) + return; + } + + let res; + + if (pqType === 'general') { + // 일반 PQ 거부 + res = await updateVendorStatusAction(vendor.id, "REJECTED") + } else if (projectId) { + // 프로젝트 PQ 거부 + res = await updateProjectPQStatusAction({ + vendorId: vendor.id, + projectId, + status: "REJECTED", + comment: rejectComment + }) + } + + if (res?.ok) { + toast({ + title: "Rejected", + description: `${pqType === 'general' ? 'General' : 'Project'} PQ has been rejected.` + }) // 코멘트 초기화 setPendingComments([]); } else { - toast({ title: "Error", description: res.error, variant: "destructive" }) + toast({ + title: "Error", + description: res?.error || "An error occurred", + variant: "destructive" + }) } } catch (error) { toast({ title: "Error", description: String(error), variant: "destructive" }) @@ -150,103 +250,169 @@ export default function VendorPQAdminReview({ 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) { + // 4) 변경 요청 처리 - 이제 프로젝트 ID 포함 + 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 + })); + + // 서버 액션 호출 (프로젝트 ID 추가) + const res = await requestPqChangesAction({ + vendorId: vendor.id, + projectId: pqType === 'project' ? projectId : undefined, + comment: itemComments, + generalComment: requestComment || undefined + }); + + if (res.ok) { + toast({ + title: "Changes Requested", + description: `${pqType === 'general' ? 'Vendor' : 'Project'} was notified of your comments.`, + }); + // 코멘트 초기화 + setPendingComments([]); + } else { + toast({ + title: "Error", + description: res.error, + variant: "destructive" + }); + } + } catch (error) { toast({ - title: "Changes Requested", - description: "Vendor was notified of your comments.", + title: "Error", + description: String(error), + variant: "destructive" }); - // 코멘트 초기화 - setPendingComments([]); - } else { - toast({ title: "Error", description: res.error, variant: "destructive" }); + } finally { + setIsLoading(false); + setRequestComment(""); } - } catch (error) { - toast({ title: "Error", description: String(error), variant: "destructive" }); - } finally { - setIsLoading(false); - setRequestComment(""); - } -}; + }; + + // 현재 상태에 따른 액션 버튼 비활성화 여부 판단 + const getDisabledState = () => { + if (pqType === 'general') { + // 일반 PQ는 vendor 상태에 따라 결정 + return vendor.status === 'PQ_APPROVED' || vendor.status === 'APPROVED'; + } else if (pqType === 'project' && projectStatus) { + // 프로젝트 PQ는 project 상태에 따라 결정 + return projectStatus === 'APPROVED' || projectStatus === 'REJECTED'; + } + return false; + }; + + const areActionsDisabled = getDisabledState(); 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> + {/* PQ Type indicators and status */} + {pqType === 'project' && projectName && ( + <div className="flex flex-col space-y-1 mb-4"> + <div className="flex items-center gap-2"> + <Badge variant="outline">{projectName}</Badge> + {projectStatus && ( + <Badge className={ + projectStatus === 'APPROVED' ? 'bg-green-100 text-green-800' : + projectStatus === 'REJECTED' ? 'bg-red-100 text-red-800' : + 'bg-blue-100 text-blue-800' + }> + {projectStatus} + </Badge> )} - </Button> - <Button - disabled={isLoading} - onClick={handleApprove} - > - Approve - </Button> + </div> + {areActionsDisabled && ( + <p className="text-sm text-muted-foreground"> + This PQ has already been { + pqType !== 'project' + ? (vendor.status === 'PQ_APPROVED' || vendor.status === 'APPROVED' ? 'approved' : 'rejected') + : (projectStatus === 'APPROVED' ? 'approved' : 'rejected') + }. No further actions can be taken. + </p> + )} </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> + )} + + {/* Loading indicator */} + {isDataLoading && ( + <div className="flex justify-center items-center h-32"> + <Loader2 className="h-8 w-8 animate-spin text-primary" /> </div> )} - <Separator /> + {!isDataLoading && ( + <> + {/* Top header */} + <div className="flex items-center justify-between"> + <h2 className="text-2xl font-bold"> + {vendor.vendorCode} - {vendor.vendorName} {pqType === 'project' ? 'Project' : 'General'} PQ Review + </h2> + <div className="flex gap-2"> + <Button + variant="outline" + disabled={isLoading || areActionsDisabled} + onClick={handleReject} + > + Reject + </Button> + <Button + variant={pendingComments.length > 0 ? "default" : "outline"} + disabled={isLoading || areActionsDisabled} + 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 || areActionsDisabled} + 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> + )} - {/* VendorPQReviewPage 컴포넌트 대신 직접 구현 */} - <VendorPQReviewPageIntegrated - data={data} - onCommentAdded={handleCommentAdded} - /> + <Separator /> + + {/* PQ 데이터 표시 */} + {pqData.length > 0 ? ( + <VendorPQReviewPageIntegrated + data={pqData} + onCommentAdded={handleCommentAdded} + /> + ) : ( + <div className="text-center py-10"> + <p className="text-muted-foreground">No PQ data available for review.</p> + </div> + )} + </> + )} {/* 변경 요청 다이얼로그 */} <Dialog open={showRequestDialog} onOpenChange={setShowRequestDialog}> @@ -274,9 +440,9 @@ const handleSubmitRequestChanges = async () => { {formatDate(comment.createdAt)} </p> </div> - <Button - variant="ghost" - size="sm" + <Button + variant="ghost" + size="sm" className="p-0 h-8 w-8" onClick={() => handleRemoveComment(index)} > @@ -290,15 +456,15 @@ const handleSubmitRequestChanges = async () => { {/* 추가 코멘트 입력 */} <div className="space-y-2 mt-2"> <label className="text-sm font-medium"> - {pendingComments.length > 0 - ? "Additional comments (optional):" + {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..." + placeholder={pendingComments.length > 0 + ? "Add any additional notes..." : "Please correct item #1, etc..."} className="min-h-[100px]" /> @@ -312,8 +478,8 @@ const handleSubmitRequestChanges = async () => { > Cancel </Button> - <Button - onClick={handleSubmitRequestChanges} + <Button + onClick={handleSubmitRequestChanges} disabled={isLoading || (pendingComments.length === 0 && !requestComment.trim())} > Submit Changes @@ -328,7 +494,7 @@ const handleSubmitRequestChanges = async () => { <DialogHeader> <DialogTitle>Confirm Approval</DialogTitle> <DialogDescription> - Are you sure you want to approve this vendor PQ? You can add a comment if needed. + Are you sure you want to approve this {pqType === 'project' ? 'project' : 'vendor'} PQ? You can add a comment if needed. </DialogDescription> </DialogHeader> @@ -349,8 +515,8 @@ const handleSubmitRequestChanges = async () => { > Cancel </Button> - <Button - onClick={handleSubmitApprove} + <Button + onClick={handleSubmitApprove} disabled={isLoading} > Confirm Approval @@ -365,7 +531,7 @@ const handleSubmitRequestChanges = async () => { <DialogHeader> <DialogTitle>Confirm Rejection</DialogTitle> <DialogDescription> - Are you sure you want to reject this vendor PQ? Please provide a reason. + Are you sure you want to reject this {pqType === 'project' ? 'project' : 'vendor'} PQ? Please provide a reason. </DialogDescription> </DialogHeader> @@ -386,7 +552,7 @@ const handleSubmitRequestChanges = async () => { > Cancel </Button> - <Button + <Button onClick={handleSubmitReject} disabled={isLoading || !rejectComment.trim()} variant="destructive" @@ -417,46 +583,46 @@ function VendorPQReviewPageIntegrated({ data, onCommentAdded }: VendorPQReviewPa 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", + toast({ + title: "Download Error", description: error instanceof Error ? error.message : "Failed to download file", - variant: "destructive" + variant: "destructive" }); } }; @@ -524,7 +690,7 @@ function VendorPQReviewPageIntegrated({ data, onCommentAdded }: VendorPQReviewPa </TableCell> <TableCell className="text-center"> - <ItemCommentButton + <ItemCommentButton item={item} onCommentAdded={onCommentAdded} /> @@ -566,7 +732,7 @@ function ItemCommentButton({ item, onCommentAdded }: ItemCommentButtonProps) { try { setIsLoading(true); const res = await getItemReviewLogsAction({ answerId: item.answerId }); - + if (res.ok && res.data) { setLogs(res.data); // 코멘트 존재 여부 설정 @@ -595,7 +761,7 @@ function ItemCommentButton({ item, onCommentAdded }: ItemCommentButtonProps) { console.error("Error checking comments:", error); } }; - + checkComments(); }, [item.answerId]); @@ -619,9 +785,9 @@ function ItemCommentButton({ item, onCommentAdded }: ItemCommentButtonProps) { // 코멘트 추가 처리 (메모리에만 저장) const handleAddComment = React.useCallback(() => { if (!newComment.trim()) return; - + setIsLoading(true); - + // 새 코멘트 생성 const pendingComment: PendingComment = { answerId: item.answerId, @@ -630,10 +796,10 @@ function ItemCommentButton({ item, onCommentAdded }: ItemCommentButtonProps) { comment: newComment.trim(), createdAt: new Date() }; - + // 부모 컴포넌트에 전달 onCommentAdded(pendingComment); - + // 상태 초기화 setNewComment(""); setOpen(false); @@ -643,8 +809,8 @@ function ItemCommentButton({ item, onCommentAdded }: ItemCommentButtonProps) { return ( <> <Button variant="ghost" size="sm" onClick={handleButtonClick}> - <MessagesSquare - className={`h-4 w-4 ${hasComments ? 'text-blue-600' : ''}`} + <MessagesSquare + className={`h-4 w-4 ${hasComments ? 'text-blue-600' : ''}`} /> </Button> |
