diff options
Diffstat (limited to 'components/pq')
| -rw-r--r-- | components/pq/pq-input-tabs.tsx | 136 | ||||
| -rw-r--r-- | components/pq/pq-review-detail.tsx | 452 | ||||
| -rw-r--r-- | components/pq/project-select-wrapper.tsx | 35 | ||||
| -rw-r--r-- | components/pq/project-select.tsx | 173 |
4 files changed, 636 insertions, 160 deletions
diff --git a/components/pq/pq-input-tabs.tsx b/components/pq/pq-input-tabs.tsx index 743e1729..b84d9167 100644 --- a/components/pq/pq-input-tabs.tsx +++ b/components/pq/pq-input-tabs.tsx @@ -54,7 +54,7 @@ import { FileListName, } from "@/components/ui/file-list" -// Dialog components from shadcn/ui +// Dialog components import { Dialog, DialogContent, @@ -65,13 +65,15 @@ import { } from "@/components/ui/dialog" // Additional UI -import { Separator } from "../ui/separator" +import { Separator } from "@/components/ui/separator" +import { Badge } from "@/components/ui/badge" -// Server actions (adjust to your actual code) +// Server actions import { uploadFileAction, savePQAnswersAction, submitPQAction, + ProjectPQ, } from "@/lib/pq/service" import { PQGroupData } from "@/lib/pq/service" @@ -132,9 +134,13 @@ type PQFormValues = z.infer<typeof pqFormSchema> export function PQInputTabs({ data, vendorId, + projectId, + projectData, }: { data: PQGroupData[] vendorId: number + projectId?: number + projectData?: ProjectPQ | null }) { const [isSaving, setIsSaving] = React.useState(false) const [isSubmitting, setIsSubmitting] = React.useState(false) @@ -152,7 +158,7 @@ export function PQInputTabs({ data.forEach((group) => { group.items.forEach((item) => { - // Check if the server item is already “complete” + // Check if the server item is already "complete" const hasExistingAnswer = item.answer && item.answer.trim().length > 0 const hasExistingAttachments = item.attachments && item.attachments.length > 0 @@ -190,7 +196,7 @@ export function PQInputTabs({ // ---------------------------------------------------------------------- React.useEffect(() => { const values = form.getValues() - // We consider items “saved” if `saved===true` AND they have an answer or attachments + // We consider items "saved" if `saved===true` AND they have an answer or attachments const allItemsSaved = values.answers.every( (answer) => answer.saved && (answer.answer || answer.uploadedFiles.length > 0) ) @@ -299,6 +305,7 @@ export function PQInputTabs({ const updatedAnswer = form.getValues(`answers.${answerIndex}`) const saveResult = await savePQAnswersAction({ vendorId, + projectId, // 프로젝트 ID 전달 answers: [ { criteriaId: updatedAnswer.criteriaId, @@ -396,13 +403,18 @@ export function PQInputTabs({ setIsSubmitting(true) setShowConfirmDialog(false) - const result = await submitPQAction(vendorId) + const result = await submitPQAction({ + vendorId, + projectId, // 프로젝트 ID 전달 + }) + if (result.ok) { toast({ title: "PQ Submitted", description: "Your PQ information has been submitted successfully", }) - // Optionally redirect + // 제출 후 페이지 새로고침 또는 리디렉션 처리 + window.location.reload() } else { toast({ title: "Submit Error", @@ -421,6 +433,72 @@ export function PQInputTabs({ setIsSubmitting(false) } } + + // 프로젝트 정보 표시 섹션 + const renderProjectInfo = () => { + if (!projectData) return null; + + return ( + <div className="mb-6 bg-muted p-4 rounded-md"> + <div className="flex items-center justify-between mb-2"> + <h3 className="text-lg font-semibold">프로젝트 정보</h3> + <Badge variant={getStatusVariant(projectData.status)}> + {getStatusLabel(projectData.status)} + </Badge> + </div> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <p className="text-sm font-medium text-muted-foreground">프로젝트 코드</p> + <p>{projectData.projectCode}</p> + </div> + <div> + <p className="text-sm font-medium text-muted-foreground">프로젝트명</p> + <p>{projectData.projectName}</p> + </div> + {projectData.submittedAt && ( + <div className="col-span-1 md:col-span-2"> + <p className="text-sm font-medium text-muted-foreground">제출일</p> + <p>{formatDate(projectData.submittedAt)}</p> + </div> + )} + </div> + </div> + ); + }; + + // 상태 표시용 함수 + const getStatusLabel = (status: string) => { + switch (status) { + case "REQUESTED": return "요청됨"; + case "IN_PROGRESS": return "진행중"; + case "SUBMITTED": return "제출됨"; + case "APPROVED": return "승인됨"; + case "REJECTED": return "반려됨"; + default: return status; + } + }; + + const getStatusVariant = (status: string) => { + switch (status) { + case "REQUESTED": return "secondary"; + case "IN_PROGRESS": return "default"; + case "SUBMITTED": return "outline"; + case "APPROVED": return "outline"; + case "REJECTED": return "destructive"; + default: return "secondary"; + } + }; + + // 날짜 형식화 함수 + const formatDate = (date: Date) => { + if (!date) return "-"; + return new Date(date).toLocaleDateString("ko-KR", { + year: "numeric", + month: "long", + day: "numeric", + }); + }; // ---------------------------------------------------------------------- // H) Render @@ -428,6 +506,9 @@ export function PQInputTabs({ return ( <Form {...form}> <form> + {/* 프로젝트 정보 섹션 */} + {renderProjectInfo()} + <Tabs defaultValue={data[0]?.groupName || ""} className="w-full"> {/* Top Controls */} <div className="flex justify-between items-center mb-4"> @@ -485,7 +566,7 @@ export function PQInputTabs({ {/* 2-column grid */} <div className="grid grid-cols-1 md:grid-cols-2 gap-4 pb-4"> {group.items.map((item) => { - const { criteriaId, code, checkPoint, description } = item + const { criteriaId, code, checkPoint, description, contractInfo, additionalRequirement } = item const answerIndex = getAnswerIndex(criteriaId) if (answerIndex === -1) return null @@ -498,7 +579,7 @@ export function PQInputTabs({ const hasNewUploads = newUploads.length > 0 const canSave = isItemDirty || hasNewUploads - // For “Not Saved” vs. “Saved” status label + // For "Not Saved" vs. "Saved" status label const hasUploads = form.watch(`answers.${answerIndex}.uploadedFiles`).length > 0 || newUploads.length > 0 @@ -556,13 +637,32 @@ export function PQInputTabs({ </CardHeader> <CollapsibleContent> - {/* Answer Field */} - <CardHeader className="pt-0 pb-3"> + <CardContent className="pt-3 space-y-3"> + {/* 프로젝트별 추가 필드 (contractInfo, additionalRequirement) */} + {projectId && contractInfo && ( + <div className="space-y-1"> + <FormLabel className="text-sm font-medium">계약 정보</FormLabel> + <div className="rounded-md bg-muted/30 p-3 text-sm whitespace-pre-wrap"> + {contractInfo} + </div> + </div> + )} + + {projectId && additionalRequirement && ( + <div className="space-y-1"> + <FormLabel className="text-sm font-medium">추가 요구사항</FormLabel> + <div className="rounded-md bg-muted/30 p-3 text-sm whitespace-pre-wrap"> + {additionalRequirement} + </div> + </div> + )} + + {/* Answer Field */} <FormField control={form.control} name={`answers.${answerIndex}.answer`} render={({ field }) => ( - <FormItem className="mt-3"> + <FormItem className="mt-2"> <FormLabel>Answer</FormLabel> <FormControl> <Textarea @@ -583,11 +683,10 @@ export function PQInputTabs({ </FormItem> )} /> - </CardHeader> + - {/* Attachments / Dropzone */} - <CardContent> - <div className="grid gap-2"> + {/* Attachments / Dropzone */} + <div className="grid gap-2 mt-3"> <FormLabel>Attachments</FormLabel> <Dropzone maxSize={6e8} // 600MB @@ -708,7 +807,10 @@ export function PQInputTabs({ <DialogHeader> <DialogTitle>Confirm Submission</DialogTitle> <DialogDescription> - Review your answers before final submission. + {projectId + ? `${projectData?.projectCode} 프로젝트의 PQ 응답을 제출하시겠습니까?` + : "일반 PQ 응답을 제출하시겠습니까?" + } 제출 후에는 수정이 불가능합니다. </DialogDescription> </DialogHeader> 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> diff --git a/components/pq/project-select-wrapper.tsx b/components/pq/project-select-wrapper.tsx new file mode 100644 index 00000000..1405ab02 --- /dev/null +++ b/components/pq/project-select-wrapper.tsx @@ -0,0 +1,35 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { type Project } from "@/lib/rfqs/service" +import { ProjectSelector } from "./project-select" + +interface ProjectSelectorWrapperProps { + selectedProjectId?: number | null +} + +export function ProjectSelectorWrapper({ selectedProjectId }: ProjectSelectorWrapperProps) { + const router = useRouter() + + const handleProjectSelect = (project: Project | null) => { + if (project && project.id) { + router.push(`/evcp/pq-criteria/${project.id}`) + } else { + // 프로젝트가 null인 경우 (선택 해제) + router.push(`/evcp/pq-criteria`) + } + } + + return ( + <div className="w-[400px]"> + <ProjectSelector + selectedProjectId={selectedProjectId} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트를 선택하세요" + showClearOption={true} + clearOptionText="일반 PQ 보기" + /> + </div> + ) +}
\ No newline at end of file diff --git a/components/pq/project-select.tsx b/components/pq/project-select.tsx new file mode 100644 index 00000000..0d6e6445 --- /dev/null +++ b/components/pq/project-select.tsx @@ -0,0 +1,173 @@ +"use client" + +import * as React from "react" +import { Check, ChevronsUpDown, X } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator } from "@/components/ui/command" +import { cn } from "@/lib/utils" +import { getProjects, type Project } from "@/lib/rfqs/service" + +interface ProjectSelectorProps { + selectedProjectId?: number | null; + onProjectSelect: (project: Project | null) => void; + placeholder?: string; + showClearOption?: boolean; + clearOptionText?: string; +} + +export function ProjectSelector({ + selectedProjectId, + onProjectSelect, + placeholder = "프로젝트 선택...", + showClearOption = true, + clearOptionText = "일반 PQ 보기" +}: ProjectSelectorProps) { + const [open, setOpen] = React.useState(false) + const [searchTerm, setSearchTerm] = React.useState("") + const [projects, setProjects] = React.useState<Project[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + const [selectedProject, setSelectedProject] = React.useState<Project | null>(null) + + // 모든 프로젝트 데이터 로드 (한 번만) + React.useEffect(() => { + async function loadAllProjects() { + setIsLoading(true); + try { + const allProjects = await getProjects(); + setProjects(allProjects); + + // 초기 선택된 프로젝트가 있으면 설정 + if (selectedProjectId) { + const selected = allProjects.find(p => p.id === selectedProjectId); + if (selected) { + setSelectedProject(selected); + } + } + } catch (error) { + console.error("프로젝트 목록 로드 오류:", error); + } finally { + setIsLoading(false); + } + } + + loadAllProjects(); + }, [selectedProjectId]); + + // 클라이언트 측에서 검색어로 필터링 + const filteredProjects = React.useMemo(() => { + if (!searchTerm.trim()) return projects; + + const lowerSearch = searchTerm.toLowerCase(); + return projects.filter( + project => + project.projectCode.toLowerCase().includes(lowerSearch) || + project.projectName.toLowerCase().includes(lowerSearch) + ); + }, [projects, searchTerm]); + + // 프로젝트 선택 처리 + const handleSelectProject = (project: Project) => { + setSelectedProject(project); + onProjectSelect(project); + setOpen(false); + }; + + // 선택 해제 처리 + const handleClearSelection = () => { + setSelectedProject(null); + onProjectSelect(null); + setOpen(false); + }; + + return ( + <div className="space-y-1"> + {/* 선택된 프로젝트 정보 표시 (선택된 경우에만) */} + {selectedProject && ( + <div className="flex items-center justify-between px-2"> + <div className="flex flex-col"> + <div className="text-sm font-medium">{selectedProject.projectCode}</div> + <div className="text-xs text-muted-foreground truncate max-w-[300px]"> + {selectedProject.projectName} + </div> + </div> + <Button + variant="ghost" + size="sm" + className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive" + onClick={handleClearSelection} + > + <X className="h-4 w-4" /> + <span className="sr-only">선택 해제</span> + </Button> + </div> + )} + + {/* 셀렉터 컴포넌트 */} + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className="w-full justify-between" + > + {selectedProject ? "프로젝트 변경..." : placeholder} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput + placeholder="프로젝트 코드/이름 검색..." + onValueChange={setSearchTerm} + /> + <CommandList className="max-h-[300px]"> + <CommandEmpty>검색 결과가 없습니다</CommandEmpty> + + {showClearOption && selectedProject && ( + <> + <CommandGroup> + <CommandItem + onSelect={handleClearSelection} + className="text-blue-600 font-medium" + > + {clearOptionText} + </CommandItem> + </CommandGroup> + <CommandSeparator /> + </> + )} + + {isLoading ? ( + <div className="py-6 text-center text-sm">로딩 중...</div> + ) : ( + <CommandGroup> + {filteredProjects.map((project) => ( + <CommandItem + key={project.id} + value={`${project.projectCode} ${project.projectName}`} + onSelect={() => handleSelectProject(project)} + > + <Check + className={cn( + "mr-2 h-4 w-4", + selectedProject?.id === project.id + ? "opacity-100" + : "opacity-0" + )} + /> + <span className="font-medium">{project.projectCode}</span> + <span className="ml-2 text-gray-500 truncate">- {project.projectName}</span> + </CommandItem> + ))} + </CommandGroup> + )} + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + ); +}
\ No newline at end of file |
