diff options
Diffstat (limited to 'components/pq')
| -rw-r--r-- | components/pq/client-pq-input-wrapper.tsx | 90 | ||||
| -rw-r--r-- | components/pq/pq-input-tabs.tsx | 884 | ||||
| -rw-r--r-- | components/pq/pq-review-detail.tsx | 888 | ||||
| -rw-r--r-- | components/pq/pq-review-table.tsx | 344 | ||||
| -rw-r--r-- | components/pq/project-select-wrapper.tsx | 35 | ||||
| -rw-r--r-- | components/pq/project-select.tsx | 173 |
6 files changed, 0 insertions, 2414 deletions
diff --git a/components/pq/client-pq-input-wrapper.tsx b/components/pq/client-pq-input-wrapper.tsx deleted file mode 100644 index 42d2420d..00000000 --- a/components/pq/client-pq-input-wrapper.tsx +++ /dev/null @@ -1,90 +0,0 @@ -"use client" - -import * as React from "react" -import { Shell } from "@/components/shell" -import { Skeleton } from "@/components/ui/skeleton" -import { PQInputTabs } from "@/components/pq/pq-input-tabs" -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { PQGroupData, ProjectPQ } from "@/lib/pq/service" -import { useRouter, useSearchParams } from "next/navigation" - -interface ClientPQWrapperProps { - pqData: PQGroupData[] // 변경: allPQData → pqData (현재 선택된 PQ 데이터) - projectPQs: ProjectPQ[] - vendorId: number - rawSearchParams: { - projectId?: string - } -} - -export function ClientPQWrapper({ - pqData, - projectPQs, - vendorId, - rawSearchParams -}: ClientPQWrapperProps) { - const searchParams = useSearchParams() - const projectIdParam = searchParams?.get('projectId') - - // 클라이언트 측에서 projectId 파싱 - const projectId = projectIdParam ? parseInt(projectIdParam, 10) : undefined - - // 현재 프로젝트 정보 찾기 - const currentProject = projectId - ? projectPQs.find(p => p.projectId === projectId) - : null - - return ( - <Shell className="gap-2"> - {/* 헤더 - 프로젝트 정보 포함 */} - <div className="space-y-2"> - <h2 className="text-2xl font-bold tracking-tight"> - Pre-Qualification Check Sheet - {currentProject && ( - <span className="ml-2 text-muted-foreground"> - - {currentProject.projectCode} - </span> - )} - </h2> - <p className="text-muted-foreground"> - PQ에 적절한 응답을 제출하시기 바랍니다. - </p> - </div> - - {/* 일반/프로젝트 PQ 선택 탭 */} - {projectPQs.length > 0 && ( - <div className="border-b"> - <Tabs defaultValue={projectId ? `project-${projectId}` : "general"}> - <TabsList> - <TabsTrigger value="general" asChild> - <a href="/partners/pq">일반 PQ</a> - </TabsTrigger> - - {projectPQs.map(project => ( - <TabsTrigger - key={project.projectId} - value={`project-${project.projectId}`} - asChild - > - <a href={`/partners/pq?projectId=${project.projectId}`}> - {project.projectCode} - </a> - </TabsTrigger> - ))} - </TabsList> - </Tabs> - </div> - )} - - {/* PQ 입력 탭 */} - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - <PQInputTabs - data={pqData} - vendorId={vendorId} - projectId={projectId} - projectData={currentProject} - /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/components/pq/pq-input-tabs.tsx b/components/pq/pq-input-tabs.tsx deleted file mode 100644 index d72eff92..00000000 --- a/components/pq/pq-input-tabs.tsx +++ /dev/null @@ -1,884 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { z } from "zod" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, -} from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { Textarea } from "@/components/ui/textarea" -import { X, Save, CheckCircle2, AlertTriangle, ChevronsUpDown } from "lucide-react" -import prettyBytes from "pretty-bytes" -import { useToast } from "@/hooks/use-toast" -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible" - -// Form components -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - FormDescription, -} from "@/components/ui/form" - -// Custom Dropzone, FileList components -import { - Dropzone, - DropzoneDescription, - DropzoneInput, - DropzoneTitle, - DropzoneUploadIcon, - DropzoneZone, -} from "@/components/ui/dropzone" -import { - FileList, - FileListAction, - FileListDescription, - FileListHeader, - FileListIcon, - FileListInfo, - FileListItem, - FileListName, -} from "@/components/ui/file-list" - -// Dialog components -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogDescription, -} from "@/components/ui/dialog" - -// Additional UI -import { Separator } from "@/components/ui/separator" -import { Badge } from "@/components/ui/badge" - -// Server actions -import { - uploadFileAction, - savePQAnswersAction, - submitPQAction, - ProjectPQ, -} from "@/lib/pq/service" -import { PQGroupData } from "@/lib/pq/service" -import { useRouter } from "next/navigation" - -// ---------------------------------------------------------------------- -// 1) Define client-side file shapes -// ---------------------------------------------------------------------- -interface UploadedFileState { - fileName: string - url: string - size?: number -} - -interface LocalFileState { - fileObj: File - uploaded: boolean -} - -// ---------------------------------------------------------------------- -// 2) Zod schema for the entire form -// ---------------------------------------------------------------------- -const pqFormSchema = z.object({ - answers: z.array( - z.object({ - criteriaId: z.number(), - // Must have at least 1 char - answer: z.string().min(1, "Answer is required"), - - // Existing, uploaded files - uploadedFiles: z - .array( - z.object({ - fileName: z.string(), - url: z.string(), - size: z.number().optional(), - }) - ) - .min(1, "At least one file attachment is required"), - - // Local (not-yet-uploaded) files - newUploads: z.array( - z.object({ - fileObj: z.any(), - uploaded: z.boolean().default(false), - }) - ), - - // track saved state - saved: z.boolean().default(false), - }) - ), -}) - -type PQFormValues = z.infer<typeof pqFormSchema> - -// ---------------------------------------------------------------------- -// 3) Main Component: PQInputTabs -// ---------------------------------------------------------------------- -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) - const [allSaved, setAllSaved] = React.useState(false) - const [showConfirmDialog, setShowConfirmDialog] = React.useState(false) - - const { toast } = useToast() - const router = useRouter() - // ---------------------------------------------------------------------- - // A) Create initial form values - // Mark items as "saved" if they have existing answer or attachments - // ---------------------------------------------------------------------- - function createInitialFormValues(): PQFormValues { - const answers: PQFormValues["answers"] = [] - - data.forEach((group) => { - group.items.forEach((item) => { - // 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 - - // If either is present, we consider it "saved" initially - const isAlreadySaved = hasExistingAnswer || hasExistingAttachments - - answers.push({ - criteriaId: item.criteriaId, - answer: item.answer || "", - uploadedFiles: item.attachments.map((attach) => ({ - fileName: attach.fileName, - url: attach.filePath, - size: attach.fileSize, - })), - newUploads: [], - saved: isAlreadySaved, - }) - }) - }) - - return { answers } - } - - // ---------------------------------------------------------------------- - // B) Set up react-hook-form - // ---------------------------------------------------------------------- - const form = useForm<PQFormValues>({ - resolver: zodResolver(pqFormSchema), - defaultValues: createInitialFormValues(), - mode: "onChange", - }) - - // ---------------------------------------------------------------------- - // C) Track if all items are saved => controls Submit PQ button - // ---------------------------------------------------------------------- - React.useEffect(() => { - const values = form.getValues() - // 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) - ) - setAllSaved(allItemsSaved) - }, [form.watch()]) - - // Helper to find the array index by criteriaId - const getAnswerIndex = (criteriaId: number): number => { - return form.getValues().answers.findIndex((a) => a.criteriaId === criteriaId) - } - - // ---------------------------------------------------------------------- - // D) Handling File Drops, Removal - // ---------------------------------------------------------------------- - const handleDropAccepted = (criteriaId: number, files: File[]) => { - const answerIndex = getAnswerIndex(criteriaId) - if (answerIndex === -1) return - - // Convert each dropped file into a LocalFileState - const newLocalFiles: LocalFileState[] = files.map((f) => ({ - fileObj: f, - uploaded: false, - })) - - const current = form.getValues(`answers.${answerIndex}.newUploads`) - form.setValue(`answers.${answerIndex}.newUploads`, [...current, ...newLocalFiles], { - shouldDirty: true, - }) - - // Mark unsaved - form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true }) - } - - const handleDropRejected = () => { - toast({ - title: "File upload rejected", - description: "Please check file size and type.", - variant: "destructive", - }) - } - - const removeNewUpload = (answerIndex: number, fileIndex: number) => { - const current = [...form.getValues(`answers.${answerIndex}.newUploads`)] - current.splice(fileIndex, 1) - form.setValue(`answers.${answerIndex}.newUploads`, current, { shouldDirty: true }) - - form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true }) - } - - const removeUploadedFile = (answerIndex: number, fileIndex: number) => { - const current = [...form.getValues(`answers.${answerIndex}.uploadedFiles`)] - current.splice(fileIndex, 1) - form.setValue(`answers.${answerIndex}.uploadedFiles`, current, { shouldDirty: true }) - - form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true }) - } - - // ---------------------------------------------------------------------- - // E) Saving a Single Item - // ---------------------------------------------------------------------- - const handleSaveItem = async (answerIndex: number) => { - try { - const answerData = form.getValues(`answers.${answerIndex}`) - - // Validation - if (!answerData.answer) { - toast({ - title: "Validation Error", - description: "Answer is required", - variant: "destructive", - }) - return - } - - // Upload new files (if any) - if (answerData.newUploads.length > 0) { - setIsSaving(true) - - for (const localFile of answerData.newUploads) { - try { - const uploadResult = await uploadFileAction(localFile.fileObj) - const currentUploaded = form.getValues(`answers.${answerIndex}.uploadedFiles`) - currentUploaded.push({ - fileName: uploadResult.fileName, - url: uploadResult.url, - size: uploadResult.size, - }) - form.setValue(`answers.${answerIndex}.uploadedFiles`, currentUploaded, { - shouldDirty: true, - }) - } catch (error) { - console.error("File upload error:", error) - toast({ - title: "Upload Error", - description: "Failed to upload file", - variant: "destructive", - }) - } - } - - // Clear newUploads - form.setValue(`answers.${answerIndex}.newUploads`, [], { shouldDirty: true }) - } - - // Save to DB - const updatedAnswer = form.getValues(`answers.${answerIndex}`) - const saveResult = await savePQAnswersAction({ - vendorId, - projectId, // 프로젝트 ID 전달 - answers: [ - { - criteriaId: updatedAnswer.criteriaId, - answer: updatedAnswer.answer, - attachments: updatedAnswer.uploadedFiles.map((f) => ({ - fileName: f.fileName, - url: f.url, - size: f.size, - })), - }, - ], - }) - - if (saveResult.ok) { - // Mark as saved - form.setValue(`answers.${answerIndex}.saved`, true, { shouldDirty: false }) - toast({ - title: "Saved", - description: "Item saved successfully", - }) - } - } catch (error) { - console.error("Save error:", error) - toast({ - title: "Save Error", - description: "Failed to save item", - variant: "destructive", - }) - } finally { - setIsSaving(false) - } - } - - // For convenience - const answers = form.getValues().answers - const dirtyFields = form.formState.dirtyFields.answers - - // Check if any item is dirty or has new uploads - const isAnyItemDirty = answers.some((answer, i) => { - const itemDirty = !!dirtyFields?.[i] - const hasNewUploads = answer.newUploads.length > 0 - return itemDirty || hasNewUploads - }) - - // ---------------------------------------------------------------------- - // F) Save All Items - // ---------------------------------------------------------------------- - const handleSaveAll = async () => { - try { - setIsSaving(true) - const answers = form.getValues().answers - - // Only save items that are dirty or have new uploads - for (let i = 0; i < answers.length; i++) { - const itemDirty = !!dirtyFields?.[i] - const hasNewUploads = answers[i].newUploads.length > 0 - if (!itemDirty && !hasNewUploads) continue - - await handleSaveItem(i) - } - - toast({ - title: "All Saved", - description: "All items saved successfully", - }) - } catch (error) { - console.error("Save all error:", error) - toast({ - title: "Save Error", - description: "Failed to save all items", - variant: "destructive", - }) - } finally { - setIsSaving(false) - } - } - - // ---------------------------------------------------------------------- - // G) Submission with Confirmation Dialog - // ---------------------------------------------------------------------- - const handleSubmitPQ = () => { - if (!allSaved) { - toast({ - title: "Cannot Submit", - description: "Please save all items before submitting", - variant: "destructive", - }) - return - } - setShowConfirmDialog(true) - } - - const handleConfirmSubmission = async () => { - try { - setIsSubmitting(true) - setShowConfirmDialog(false) - - const result = await submitPQAction({ - vendorId, - projectId, // 프로젝트 ID 전달 - }) - - if (result.ok) { - toast({ - title: "PQ Submitted", - description: "Your PQ information has been submitted successfully", - }) - // 제출 후 페이지 새로고침 또는 리디렉션 처리 - router.refresh() - // window.location.reload() - } else { - toast({ - title: "Submit Error", - description: result.error || "Failed to submit PQ", - variant: "destructive", - }) - } - } catch (error) { - console.error("Submit error:", error) - toast({ - title: "Submit Error", - description: "Failed to submit PQ information", - variant: "destructive", - }) - } finally { - 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 - // ---------------------------------------------------------------------- - return ( - <Form {...form}> - <form> - {/* 프로젝트 정보 섹션 */} - {renderProjectInfo()} - - <Tabs defaultValue={data[0]?.groupName || ""} className="w-full"> - {/* Top Controls */} - <div className="flex justify-between items-center mb-4"> - <TabsList className="grid grid-cols-4"> - {data.map((group) => ( - <TabsTrigger - key={group.groupName} - value={group.groupName} - className="truncate" - > - <div className="flex items-center gap-2"> - {/* Mobile: truncated version */} - <span className="block sm:hidden"> - {group.groupName.length > 5 - ? group.groupName.slice(0, 5) + "..." - : group.groupName} - </span> - {/* Desktop: full text */} - <span className="hidden sm:block">{group.groupName}</span> - <span className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full bg-muted text-xs font-medium"> - {group.items.length} - </span> - </div> - </TabsTrigger> - ))} - </TabsList> - - <div className="flex gap-2"> - {/* Save All button */} - <Button - type="button" - variant="outline" - disabled={isSaving || !isAnyItemDirty} - onClick={handleSaveAll} - > - {isSaving ? "Saving..." : "Save All"} - <Save className="ml-2 h-4 w-4" /> - </Button> - - {/* Submit PQ button */} - <Button - type="button" - disabled={!allSaved || isSubmitting} - onClick={handleSubmitPQ} - > - {isSubmitting ? "Submitting..." : "Submit PQ"} - <CheckCircle2 className="ml-2 h-4 w-4" /> - </Button> - </div> - </div> - - {/* Render each group */} - {data.map((group) => ( - <TabsContent key={group.groupName} value={group.groupName}> - {/* 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, contractInfo, additionalRequirement } = item - const answerIndex = getAnswerIndex(criteriaId) - if (answerIndex === -1) return null - - const isSaved = form.watch(`answers.${answerIndex}.saved`) - const hasAnswer = form.watch(`answers.${answerIndex}.answer`) - const newUploads = form.watch(`answers.${answerIndex}.newUploads`) - const dirtyFieldsItem = form.formState.dirtyFields.answers?.[answerIndex] - - const isItemDirty = !!dirtyFieldsItem - const hasNewUploads = newUploads.length > 0 - const canSave = isItemDirty || hasNewUploads - - // For "Not Saved" vs. "Saved" status label - const hasUploads = - form.watch(`answers.${answerIndex}.uploadedFiles`).length > 0 || - newUploads.length > 0 - const isValid = !!hasAnswer || hasUploads - - return ( - <Collapsible key={criteriaId} defaultOpen={!isSaved} className="w-full"> - <Card className={isSaved ? "border-green-200" : ""}> - <CardHeader className="pb-1"> - <div className="flex justify-between"> - <div className="flex-1"> - <div className="flex items-center gap-2"> - <CollapsibleTrigger asChild> - <Button variant="ghost" size="sm" className="p-0 h-7 w-7"> - <ChevronsUpDown className="h-4 w-4" /> - <span className="sr-only">Toggle</span> - </Button> - </CollapsibleTrigger> - <CardTitle className="text-md"> - {code} - {checkPoint} - </CardTitle> - </div> - {description && ( - <CardDescription className="mt-1 whitespace-pre-wrap"> - {description} - </CardDescription> - )} - </div> - - {/* Save Status & Button */} - <div className="flex items-center gap-2"> - {!isSaved && canSave && ( - <span className="text-amber-600 text-xs flex items-center"> - <AlertTriangle className="h-4 w-4 mr-1" /> - Not Saved - </span> - )} - {isSaved && ( - <span className="text-green-600 text-xs flex items-center"> - <CheckCircle2 className="h-4 w-4 mr-1" /> - Saved - </span> - )} - - <Button - size="sm" - variant="outline" - disabled={isSaving || !canSave} - onClick={() => handleSaveItem(answerIndex)} - > - Save - </Button> - </div> - </div> - </CardHeader> - - <CollapsibleContent> - <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-2"> - <FormLabel>Answer</FormLabel> - <FormControl> - <Textarea - {...field} - className="min-h-24" - placeholder="Enter your answer here" - onChange={(e) => { - field.onChange(e) - form.setValue( - `answers.${answerIndex}.saved`, - false, - { shouldDirty: true } - ) - }} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - - {/* Attachments / Dropzone */} - <div className="grid gap-2 mt-3"> - <FormLabel>Attachments</FormLabel> - <Dropzone - maxSize={6e8} // 600MB - onDropAccepted={(files) => - handleDropAccepted(criteriaId, files) - } - onDropRejected={handleDropRejected} - > - {() => ( - <FormItem> - <DropzoneZone className="flex justify-center h-24"> - <FormControl> - <DropzoneInput /> - </FormControl> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>Drop files here</DropzoneTitle> - <DropzoneDescription> - Max size: 600MB - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - <FormDescription> - Or click to browse files - </FormDescription> - <FormMessage /> - </FormItem> - )} - </Dropzone> - </div> - - {/* Existing + Pending Files */} - <div className="mt-4 space-y-4"> - {/* 1) Not-yet-uploaded files */} - {newUploads.length > 0 && ( - <div className="grid gap-2"> - <h6 className="text-sm font-medium"> - Pending Files ({newUploads.length}) - </h6> - <FileList> - {newUploads.map((f, fileIndex) => { - const fileObj = f.fileObj - if (!fileObj) return null - - return ( - <FileListItem key={fileIndex}> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>{fileObj.name}</FileListName> - <FileListDescription> - {prettyBytes(fileObj.size)} - </FileListDescription> - </FileListInfo> - <FileListAction - onClick={() => - removeNewUpload(answerIndex, fileIndex) - } - > - <X className="h-4 w-4" /> - <span className="sr-only">Remove</span> - </FileListAction> - </FileListHeader> - </FileListItem> - ) - })} - </FileList> - </div> - )} - - {/* 2) Already uploaded files */} - {form - .watch(`answers.${answerIndex}.uploadedFiles`) - .map((file, fileIndex) => ( - <FileListItem key={fileIndex}> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>{file.fileName}</FileListName> - {/* If you want to display the path: - <FileListDescription>{file.url}</FileListDescription> - */} - </FileListInfo> - {file.size && ( - <span className="text-xs text-muted-foreground"> - {prettyBytes(file.size)} - </span> - )} - <FileListAction - onClick={() => - removeUploadedFile(answerIndex, fileIndex) - } - > - <X className="h-4 w-4" /> - <span className="sr-only">Remove</span> - </FileListAction> - </FileListHeader> - </FileListItem> - ))} - </div> - </CardContent> - </CollapsibleContent> - </Card> - </Collapsible> - ) - })} - </div> - </TabsContent> - ))} - </Tabs> - </form> - - {/* Confirmation Dialog */} - <Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}> - <DialogContent> - <DialogHeader> - <DialogTitle>Confirm Submission</DialogTitle> - <DialogDescription> - {projectId - ? `${projectData?.projectCode} 프로젝트의 PQ 응답을 제출하시겠습니까?` - : "일반 PQ 응답을 제출하시겠습니까?" - } 제출 후에는 수정이 불가능합니다. - </DialogDescription> - </DialogHeader> - - <div className="space-y-4 max-h-[600px] overflow-y-auto "> - {data.map((group) => ( - <Collapsible key={group.groupName} defaultOpen> - <CollapsibleTrigger asChild> - <div className="flex justify-between items-center p-2 mb-1 cursor-pointer "> - <p className="font-semibold">{group.groupName}</p> - <ChevronsUpDown className="h-4 w-4 ml-2" /> - </div> - </CollapsibleTrigger> - - <CollapsibleContent> - {group.items.map((item) => { - const answerObj = form - .getValues() - .answers.find((a) => a.criteriaId === item.criteriaId) - - if (!answerObj) return null - - return ( - <div key={item.criteriaId} className="mb-2 p-2 ml-2 border rounded-md text-sm"> - {/* code & checkPoint */} - <p className="font-semibold"> - {item.code} - {item.checkPoint} - </p> - - {/* user's typed answer */} - <p className="text-sm font-medium mt-2">Answer:</p> - <p className="whitespace-pre-wrap text-sm"> - {answerObj.answer || "(no answer)"} - </p> - {/* attachments */} - <p>Attachments:</p> - {answerObj.uploadedFiles.length > 0 ? ( - <ul className="list-disc list-inside ml-4 text-xs"> - {answerObj.uploadedFiles.map((file, idx) => ( - <li key={idx}>{file.fileName}</li> - ))} - </ul> - ) : ( - <p className="text-xs text-muted-foreground">(none)</p> - )} - </div> - ) - })} - </CollapsibleContent> - </Collapsible> - ))} - </div> - - <DialogFooter> - <Button - variant="outline" - onClick={() => setShowConfirmDialog(false)} - disabled={isSubmitting} - > - Cancel - </Button> - <Button onClick={handleConfirmSubmission} disabled={isSubmitting}> - {isSubmitting ? "Submitting..." : "Confirm Submit"} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - </Form> - ) -}
\ No newline at end of file diff --git a/components/pq/pq-review-detail.tsx b/components/pq/pq-review-detail.tsx deleted file mode 100644 index 4f897a2b..00000000 --- a/components/pq/pq-review-detail.tsx +++ /dev/null @@ -1,888 +0,0 @@ -"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, - 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, - 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" -import { useSession } from "next-auth/react" // Importando o hook do next-auth - -// 코멘트 상태를 위한 인터페이스 정의 -interface PendingComment { - answerId: number; - checkPoint: string; - code: string; - comment: string; - createdAt: Date; -} - -interface ReviewLog { - id: number - reviewerComment: string - reviewerName: string | null - 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[]> - loadData: (vendorId: number, projectId?: number) => Promise<PQGroupData[]> - - pqType: 'general' | 'project' -} - -export default function VendorPQAdminReview({ - data, - vendor, - projectId, - projectName, - projectStatus, - loadData, - pqType -}: VendorPQAdminReviewProps) { - const { toast } = useToast() - const { data: session } = useSession() - const reviewerName = session?.user?.name || "Unknown Reviewer" - const reviewerId = session?.user?.id - - - // 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(vendor.id, projectId) - - 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, vendor.id, projectId, 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.` - }); - } - - // 코멘트 삭제 핸들러 - 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) - } - - // 실제 승인 처리 - 일반 PQ와 프로젝트 PQ 분리 - const handleSubmitApprove = async () => { - try { - setIsLoading(true) - setShowApproveDialog(false) - - 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 || "An error occurred", - 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) - } - - // 실제 거부 처리 - 일반 PQ와 프로젝트 PQ 분리 - const handleSubmitReject = async () => { - try { - setIsLoading(true) - setShowRejectDialog(false) - - 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 || "An error occurred", - variant: "destructive" - }) - } - } catch (error) { - toast({ title: "Error", description: String(error), variant: "destructive" }) - } finally { - setIsLoading(false) - setRejectComment("") - } - } - - // 3) 변경 요청 다이얼로그 표시 - const handleRequestChanges = () => { - setShowRequestDialog(true) - } - - // 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, - reviewerName, - reviewerId - }); - - 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: "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"> - {/* 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> - )} - </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> - )} - - {/* Loading indicator */} - {isDataLoading && ( - <div className="flex justify-center items-center h-32"> - <Loader2 className="h-8 w-8 animate-spin text-primary" /> - </div> - )} - - {!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> - )} - - <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}> - <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, "KR")} - </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 {pqType === 'project' ? 'project' : '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 {pqType === 'project' ? 'project' : '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, "KR")} - </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 diff --git a/components/pq/pq-review-table.tsx b/components/pq/pq-review-table.tsx deleted file mode 100644 index ce30bac0..00000000 --- a/components/pq/pq-review-table.tsx +++ /dev/null @@ -1,344 +0,0 @@ -"use client" - -import * as React from "react" -import { ChevronsUpDown, MessagesSquare, Download, Loader2 } from "lucide-react" - -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { Card } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { PQGroupData } from "@/lib/pq/service" -import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { Textarea } from "@/components/ui/textarea" -import { addReviewCommentAction, getItemReviewLogsAction } from "@/lib/pq/service" -import { useToast } from "@/hooks/use-toast" -import { formatDate } from "@/lib/utils" -import { downloadFileAction } from "@/lib/downloadFile" -import { useSession } from "next-auth/react" - -interface ReviewLog { - id: number - reviewerComment: string - reviewerName: string | null - createdAt: Date -} - -interface VendorPQReviewPageProps { - data: PQGroupData[]; - onCommentAdded?: () => void; // 코멘트 추가 콜백 -} - -export default function VendorPQReviewPage({ data, onCommentAdded }: VendorPQReviewPageProps) { - const { toast } = useToast() - - // 파일 다운로드 함수 - 서버 액션 사용 - const handleFileDownload = async (filePath: string, fileName: string) => { - try { - toast({ - title: "Download Started", - description: `Preparing ${fileName} for download...`, - }); - - // 서버 액션 호출 - const result = await downloadFileAction(filePath); - - if (!result.ok || !result.data) { - throw new Error(result.error || 'Failed to download file'); - } - - // Base64 디코딩하여 Blob 생성 - const binaryString = atob(result.data.content); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - - // Blob 생성 및 다운로드 - const blob = new Blob([bytes.buffer], { type: result.data.mimeType }); - const url = URL.createObjectURL(blob); - - // 다운로드 링크 생성 및 클릭 - const a = document.createElement('a'); - a.href = url; - a.download = fileName; - document.body.appendChild(a); - a.click(); - - // 정리 - URL.revokeObjectURL(url); - document.body.removeChild(a); - - toast({ - title: "Download Complete", - description: `${fileName} downloaded successfully`, - }); - } catch (error) { - console.error('Download error:', error); - toast({ - title: "Download Error", - description: error instanceof Error ? error.message : "Failed to download file", - variant: "destructive" - }); - } - }; - - return ( - <div className="space-y-4"> - {data.map((group) => ( - <Collapsible key={group.groupName} defaultOpen> - <CollapsibleTrigger asChild> - <div className="flex items-center justify-between cursor-pointer p-3 bg-muted rounded"> - <h2 className="font-semibold text-lg">{group.groupName}</h2> - <Button variant="ghost" size="sm" className="p-0 h-7 w-7"> - <ChevronsUpDown className="h-4 w-4" /> - </Button> - </div> - </CollapsibleTrigger> - - <CollapsibleContent> - <Card className="mt-2 p-4"> - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-[60px]">Code</TableHead> - <TableHead>Check Point</TableHead> - <TableHead>Answer</TableHead> - <TableHead className="w-[180px]">Attachments</TableHead> - <TableHead className="w-[60px] text-center">Comments</TableHead> - </TableRow> - </TableHeader> - - <TableBody> - {group.items.map((item) => ( - <TableRow key={item.criteriaId}> - <TableCell className="font-medium">{item.code}</TableCell> - <TableCell>{item.checkPoint}</TableCell> - - <TableCell> - {item.answer ? ( - <p className="whitespace-pre-wrap text-sm"> - {item.answer} - </p> - ) : ( - <p className="text-sm text-muted-foreground">(no answer)</p> - )} - </TableCell> - - <TableCell> - {item.attachments.length > 0 ? ( - <ul className="list-none space-y-1"> - {item.attachments.map((file) => ( - <li key={file.attachId} className="text-sm flex items-center"> - <button - className="text-blue-600 hover:text-blue-800 hover:underline flex items-center truncate max-w-[160px]" - onClick={() => handleFileDownload(file.filePath, file.fileName)} - > - <Download className="h-3 w-3 mr-1 flex-shrink-0" /> - <span className="truncate">{file.fileName}</span> - </button> - </li> - ))} - </ul> - ) : ( - <p className="text-sm text-muted-foreground">(none)</p> - )} - </TableCell> - - <TableCell className="text-center"> - <ItemReviewButton - answerId={item.answerId ?? undefined} - checkPoint={item.checkPoint} - onCommentAdded={onCommentAdded} - /> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </Card> - </CollapsibleContent> - </Collapsible> - ))} - </div> - ) -} - -interface ItemReviewButtonProps { - answerId?: number; - checkPoint: string; // Check Point 추가 - onCommentAdded?: () => void; -} - -/** - * A button that opens a dialog to show logs + add new comment for a single item (vendorPqCriteriaAnswers). - */ -function ItemReviewButton({ answerId, checkPoint, onCommentAdded }: ItemReviewButtonProps) { - const { toast } = useToast(); - const [open, setOpen] = React.useState(false); - const [logs, setLogs] = React.useState<ReviewLog[]>([]); - const [newComment, setNewComment] = React.useState(""); - const [isLoading, setIsLoading] = React.useState(false); - const [hasComments, setHasComments] = React.useState(false); - const { data: session } = useSession() - const reviewerName = session?.user?.name || "Unknown Reviewer" - const reviewerId = session?.user?.id - - // If there's no answerId, item wasn't answered - if (!answerId) { - return <p className="text-xs text-muted-foreground">N/A</p>; - } - - // fetchLogs 함수를 useCallback으로 메모이제이션 - const fetchLogs = React.useCallback(async () => { - try { - setIsLoading(true); - const res = await getItemReviewLogsAction({ answerId }); - - if (res.ok && res.data) { - setLogs(res.data); - // 코멘트 존재 여부 설정 - setHasComments(res.data.length > 0); - } else { - console.error("Error response:", res.error); - toast({ title: "Error", description: res.error, variant: "destructive" }); - } - } catch (error) { - console.error("Fetch error:", error); - toast({ title: "Error", description: String(error), variant: "destructive" }); - } finally { - setIsLoading(false); - } - }, [answerId, toast]); - - // 초기 로드 시 코멘트 존재 여부 확인 (아이콘 색상용) - React.useEffect(() => { - const checkComments = async () => { - try { - const res = await getItemReviewLogsAction({ answerId }); - if (res.ok && res.data) { - setHasComments(res.data.length > 0); - } - } catch (error) { - console.error("Error checking comments:", error); - } - }; - - checkComments(); - }, [answerId]); - - // open 상태가 변경될 때 로그 가져오기 - React.useEffect(() => { - if (open) { - fetchLogs(); - } - }, [open, fetchLogs]); - - // 버튼 클릭 핸들러 - 다이얼로그 열기 - const handleButtonClick = React.useCallback(() => { - setOpen(true); - }, []); - - // 다이얼로그 상태 변경 핸들러 - const handleOpenChange = React.useCallback((nextOpen: boolean) => { - setOpen(nextOpen); - }, []); - - // 코멘트 추가 핸들러 - const handleAddComment = React.useCallback(async () => { - try { - setIsLoading(true); - - const res = await addReviewCommentAction({ - answerId, - comment: newComment, - reviewerName, - }); - - if (res.ok) { - toast({ title: "Comment added", description: "New review comment saved" }); - setNewComment(""); - setHasComments(true); // 코멘트 추가 성공 시 상태 업데이트 - - // 코멘트가 추가되었음을 부모 컴포넌트에 알림 - if (onCommentAdded) { - onCommentAdded(); - } - - // 로그 다시 가져오기 - fetchLogs(); - } else { - toast({ title: "Error", description: res.error, variant: "destructive" }); - } - } catch (error) { - toast({ title: "Error", description: String(error), variant: "destructive" }); - } finally { - setIsLoading(false); - } - }, [answerId, newComment, onCommentAdded, fetchLogs, toast]); - - return ( - <> - <Button variant="ghost" size="sm" onClick={handleButtonClick}> - <MessagesSquare - className={`h-4 w-4 ${hasComments ? 'text-blue-600' : ''}`} - /> - </Button> - - <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogContent> - <DialogHeader> - <DialogTitle>{checkPoint} Comments</DialogTitle> - </DialogHeader> - - {/* Logs section */} - <div className="max-h-[200px] overflow-y-auto space-y-2"> - {isLoading ? ( - <div className="flex justify-center p-4"> - <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> - </div> - ) : logs.length > 0 ? ( - logs.map((log) => ( - <div key={log.id} className="p-2 border rounded text-sm"> - <p className="font-medium">{log.reviewerName}</p> - <p>{log.reviewerComment}</p> - <p className="text-xs text-muted-foreground"> - {formatDate(log.createdAt, "KR")} - </p> - </div> - )) - ) : ( - <p className="text-sm text-muted-foreground">No comments yet.</p> - )} - </div> - - {/* Add new comment */} - <div className="space-y-2"> - <Textarea - placeholder="Add a new comment..." - value={newComment} - onChange={(e) => setNewComment(e.target.value)} - /> - <Button - size="sm" - onClick={handleAddComment} - disabled={isLoading || !newComment.trim()} - > - Add Comment - </Button> - </div> - </DialogContent> - </Dialog> - </> - ); -}
\ No newline at end of file diff --git a/components/pq/project-select-wrapper.tsx b/components/pq/project-select-wrapper.tsx deleted file mode 100644 index 1405ab02..00000000 --- a/components/pq/project-select-wrapper.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"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 deleted file mode 100644 index 0d6e6445..00000000 --- a/components/pq/project-select.tsx +++ /dev/null @@ -1,173 +0,0 @@ -"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 |
