summaryrefslogtreecommitdiff
path: root/components/pq
diff options
context:
space:
mode:
Diffstat (limited to 'components/pq')
-rw-r--r--components/pq/pq-input-tabs.tsx136
-rw-r--r--components/pq/pq-review-detail.tsx452
-rw-r--r--components/pq/project-select-wrapper.tsx35
-rw-r--r--components/pq/project-select.tsx173
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