summaryrefslogtreecommitdiff
path: root/components/pq
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-03-26 00:37:41 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-03-26 00:37:41 +0000
commite0dfb55c5457aec489fc084c4567e791b4c65eb1 (patch)
tree68543a65d88f5afb3a0202925804103daa91bc6f /components/pq
3/25 까지의 대표님 작업사항
Diffstat (limited to 'components/pq')
-rw-r--r--components/pq/pq-input-tabs.tsx780
-rw-r--r--components/pq/pq-review-detail.tsx712
-rw-r--r--components/pq/pq-review-table.tsx340
3 files changed, 1832 insertions, 0 deletions
diff --git a/components/pq/pq-input-tabs.tsx b/components/pq/pq-input-tabs.tsx
new file mode 100644
index 00000000..743e1729
--- /dev/null
+++ b/components/pq/pq-input-tabs.tsx
@@ -0,0 +1,780 @@
+"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 from shadcn/ui
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from "@/components/ui/dialog"
+
+// Additional UI
+import { Separator } from "../ui/separator"
+
+// Server actions (adjust to your actual code)
+import {
+ uploadFileAction,
+ savePQAnswersAction,
+ submitPQAction,
+} from "@/lib/pq/service"
+import { PQGroupData } from "@/lib/pq/service"
+
+// ----------------------------------------------------------------------
+// 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,
+}: {
+ data: PQGroupData[]
+ vendorId: number
+}) {
+ 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()
+
+ // ----------------------------------------------------------------------
+ // 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,
+ 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)
+ if (result.ok) {
+ toast({
+ title: "PQ Submitted",
+ description: "Your PQ information has been submitted successfully",
+ })
+ // Optionally redirect
+ } 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)
+ }
+ }
+
+ // ----------------------------------------------------------------------
+ // H) Render
+ // ----------------------------------------------------------------------
+ return (
+ <Form {...form}>
+ <form>
+ <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 } = 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>
+ {/* Answer Field */}
+ <CardHeader className="pt-0 pb-3">
+ <FormField
+ control={form.control}
+ name={`answers.${answerIndex}.answer`}
+ render={({ field }) => (
+ <FormItem className="mt-3">
+ <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>
+ )}
+ />
+ </CardHeader>
+
+ {/* Attachments / Dropzone */}
+ <CardContent>
+ <div className="grid gap-2">
+ <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>
+ Review your answers before final submission.
+ </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
new file mode 100644
index 00000000..e5cd080e
--- /dev/null
+++ b/components/pq/pq-review-detail.tsx
@@ -0,0 +1,712 @@
+"use client"
+
+import React from "react"
+import { Button } from "@/components/ui/button"
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
+import { Textarea } from "@/components/ui/textarea"
+import { useToast } from "@/hooks/use-toast"
+import { PQGroupData, requestPqChangesAction, updateVendorStatusAction, getItemReviewLogsAction } from "@/lib/pq/service"
+import { Vendor } from "@/db/schema/vendors"
+import { Separator } from "@/components/ui/separator"
+import { ChevronsUpDown, MessagesSquare, Download, Loader2, X } from "lucide-react"
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { Card } from "@/components/ui/card"
+import { formatDate } from "@/lib/utils"
+import { downloadFileAction } from "@/lib/downloadFile"
+
+// 코멘트 상태를 위한 인터페이스 정의
+interface PendingComment {
+ answerId: number;
+ checkPoint: string;
+ code: string;
+ comment: string;
+ createdAt: Date;
+}
+
+interface ReviewLog {
+ id: number
+ reviewerComment: string
+ reviewerName: string | null
+ createdAt: Date
+}
+
+export default function VendorPQAdminReview({
+ data,
+ vendor,
+}: {
+ data: PQGroupData[]
+ vendor: Vendor
+}) {
+ const { toast } = useToast()
+
+ // 다이얼로그 상태들
+ const [showRequestDialog, setShowRequestDialog] = React.useState(false)
+ const [showApproveDialog, setShowApproveDialog] = React.useState(false)
+ const [showRejectDialog, setShowRejectDialog] = React.useState(false)
+
+ // 코멘트 상태들
+ const [requestComment, setRequestComment] = React.useState("")
+ const [approveComment, setApproveComment] = React.useState("")
+ const [rejectComment, setRejectComment] = React.useState("")
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ // 항목별 코멘트 상태 추적 (메모리에만 저장)
+ const [pendingComments, setPendingComments] = React.useState<PendingComment[]>([])
+
+ // 코멘트 추가 핸들러 - 실제 서버 저장이 아닌 메모리에 저장
+ const handleCommentAdded = (newComment: PendingComment) => {
+ setPendingComments(prev => [...prev, newComment]);
+ toast({
+ title: "Comment Added",
+ description: `Comment added for ${newComment.code}. Please "Request Changes" to save.`
+ });
+ }
+
+ // 코멘트 삭제 핸들러
+ const handleRemoveComment = (index: number) => {
+ setPendingComments(prev => prev.filter((_, i) => i !== index));
+ }
+
+ // 1) 승인 다이얼로그 표시
+ const handleApprove = () => {
+ // 코멘트가 있는데 승인하려고 하면 경고
+ if (pendingComments.length > 0) {
+ if (!confirm('You have unsaved comments. Are you sure you want to approve without requesting changes?')) {
+ return;
+ }
+ }
+ setShowApproveDialog(true)
+ }
+
+ // 실제 승인 처리
+ const handleSubmitApprove = async () => {
+ try {
+ setIsLoading(true)
+ setShowApproveDialog(false)
+
+ const res = await updateVendorStatusAction(vendor.id, "APPROVED")
+ if (res.ok) {
+ toast({ title: "Approved", description: "Vendor PQ has been approved." })
+ // 코멘트 초기화
+ setPendingComments([]);
+ } else {
+ toast({ title: "Error", description: res.error, variant: "destructive" })
+ }
+ } catch (error) {
+ toast({ title: "Error", description: String(error), variant: "destructive" })
+ } finally {
+ setIsLoading(false)
+ setApproveComment("")
+ }
+ }
+
+ // 2) 거부 다이얼로그 표시
+ const handleReject = () => {
+ // 코멘트가 있는데 거부하려고 하면 경고
+ if (pendingComments.length > 0) {
+ if (!confirm('You have unsaved comments. Are you sure you want to reject without requesting changes?')) {
+ return;
+ }
+ }
+ setShowRejectDialog(true)
+ }
+
+ // 실제 거부 처리
+ const handleSubmitReject = async () => {
+ try {
+ setIsLoading(true)
+ setShowRejectDialog(false)
+
+ const res = await updateVendorStatusAction(vendor.id, "REJECTED")
+ if (res.ok) {
+ toast({ title: "Rejected", description: "Vendor PQ has been rejected." })
+ // 코멘트 초기화
+ setPendingComments([]);
+ } else {
+ toast({ title: "Error", description: res.error, variant: "destructive" })
+ }
+ } catch (error) {
+ toast({ title: "Error", description: String(error), variant: "destructive" })
+ } finally {
+ setIsLoading(false)
+ setRejectComment("")
+ }
+ }
+
+ // 3) 변경 요청 다이얼로그 표시
+ const handleRequestChanges = () => {
+ setShowRequestDialog(true)
+ }
+
+ // 4) 변경 요청 처리 - 이제 모든 코멘트를 한 번에 저장
+// 4) 변경 요청 처리 - 이제 모든 코멘트를 한 번에 저장
+const handleSubmitRequestChanges = async () => {
+ try {
+ setIsLoading(true);
+ setShowRequestDialog(false);
+
+ // 항목별 코멘트 준비 - answerId와 함께 checkPoint와 code도 전송
+ const itemComments = pendingComments.map(pc => ({
+ answerId: pc.answerId,
+ checkPoint: pc.checkPoint, // 추가: 체크포인트 정보 전송
+ code: pc.code, // 추가: 코드 정보 전송
+ comment: pc.comment
+ }));
+
+ // 서버 액션 호출
+ const res = await requestPqChangesAction({
+ vendorId: vendor.id,
+ comment: itemComments,
+ generalComment: requestComment || undefined
+ });
+
+ if (res.ok) {
+ toast({
+ title: "Changes Requested",
+ description: "Vendor was notified of your comments.",
+ });
+ // 코멘트 초기화
+ setPendingComments([]);
+ } else {
+ toast({ title: "Error", description: res.error, variant: "destructive" });
+ }
+ } catch (error) {
+ toast({ title: "Error", description: String(error), variant: "destructive" });
+ } finally {
+ setIsLoading(false);
+ setRequestComment("");
+ }
+};
+
+ return (
+ <div className="space-y-4">
+ {/* Top header */}
+ <div className="flex items-center justify-between">
+ <h2 className="text-2xl font-bold">
+ {vendor.vendorCode} - {vendor.vendorName} PQ Review
+ </h2>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ disabled={isLoading}
+ onClick={handleReject}
+ >
+ Reject
+ </Button>
+ <Button
+ variant={pendingComments.length > 0 ? "default" : "outline"}
+ disabled={isLoading}
+ onClick={handleRequestChanges}
+ >
+ Request Changes
+ {pendingComments.length > 0 && (
+ <span className="ml-2 bg-white text-primary rounded-full h-5 min-w-5 inline-flex items-center justify-center text-xs px-1">
+ {pendingComments.length}
+ </span>
+ )}
+ </Button>
+ <Button
+ disabled={isLoading}
+ onClick={handleApprove}
+ >
+ Approve
+ </Button>
+ </div>
+ </div>
+
+ <p className="text-sm text-muted-foreground">
+ Review the submitted PQ items below, then approve, reject, or request more info.
+ </p>
+
+ {/* 코멘트가 있을 때 알림 표시 */}
+ {pendingComments.length > 0 && (
+ <div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md text-yellow-800">
+ <p className="text-sm font-medium flex items-center">
+ <span className="mr-2">⚠️</span>
+ You have {pendingComments.length} pending comments. Click "Request Changes" to save them.
+ </p>
+ </div>
+ )}
+
+ <Separator />
+
+ {/* VendorPQReviewPage 컴포넌트 대신 직접 구현 */}
+ <VendorPQReviewPageIntegrated
+ data={data}
+ onCommentAdded={handleCommentAdded}
+ />
+
+ {/* 변경 요청 다이얼로그 */}
+ <Dialog open={showRequestDialog} onOpenChange={setShowRequestDialog}>
+ <DialogContent className="max-w-3xl">
+ <DialogHeader>
+ <DialogTitle>Request PQ Changes</DialogTitle>
+ <DialogDescription>
+ Review your comments and add any additional notes. The vendor will receive these changes.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 항목별 코멘트 목록 */}
+ {pendingComments.length > 0 && (
+ <div className="border rounded-md p-2 space-y-2 max-h-[300px] overflow-y-auto">
+ <h3 className="font-medium text-sm">Item Comments:</h3>
+ {pendingComments.map((comment, index) => (
+ <div key={index} className="flex items-start gap-2 p-2 border rounded-md bg-muted/50">
+ <div className="flex-1">
+ <div className="flex items-center gap-2">
+ <span className="text-sm font-medium">{comment.code}</span>
+ <span className="text-sm">{comment.checkPoint}</span>
+ </div>
+ <p className="text-sm mt-1">{comment.comment}</p>
+ <p className="text-xs text-muted-foreground mt-1">
+ {formatDate(comment.createdAt)}
+ </p>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="p-0 h-8 w-8"
+ onClick={() => handleRemoveComment(index)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+
+ {/* 추가 코멘트 입력 */}
+ <div className="space-y-2 mt-2">
+ <label className="text-sm font-medium">
+ {pendingComments.length > 0
+ ? "Additional comments (optional):"
+ : "Enter details about what should be modified:"}
+ </label>
+ <Textarea
+ value={requestComment}
+ onChange={(e) => setRequestComment(e.target.value)}
+ placeholder={pendingComments.length > 0
+ ? "Add any additional notes..."
+ : "Please correct item #1, etc..."}
+ className="min-h-[100px]"
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setShowRequestDialog(false)}
+ disabled={isLoading}
+ >
+ Cancel
+ </Button>
+ <Button
+ onClick={handleSubmitRequestChanges}
+ disabled={isLoading || (pendingComments.length === 0 && !requestComment.trim())}
+ >
+ Submit Changes
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* 승인 확인 다이얼로그 */}
+ <Dialog open={showApproveDialog} onOpenChange={setShowApproveDialog}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Confirm Approval</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to approve this vendor PQ? You can add a comment if needed.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-2">
+ <Textarea
+ value={approveComment}
+ onChange={(e) => setApproveComment(e.target.value)}
+ placeholder="Optional: Add any comments about this approval"
+ className="min-h-[100px]"
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setShowApproveDialog(false)}
+ disabled={isLoading}
+ >
+ Cancel
+ </Button>
+ <Button
+ onClick={handleSubmitApprove}
+ disabled={isLoading}
+ >
+ Confirm Approval
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* 거부 확인 다이얼로그 */}
+ <Dialog open={showRejectDialog} onOpenChange={setShowRejectDialog}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Confirm Rejection</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to reject this vendor PQ? Please provide a reason.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-2">
+ <Textarea
+ value={rejectComment}
+ onChange={(e) => setRejectComment(e.target.value)}
+ placeholder="Required: Provide reason for rejection"
+ className="min-h-[150px]"
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setShowRejectDialog(false)}
+ disabled={isLoading}
+ >
+ Cancel
+ </Button>
+ <Button
+ onClick={handleSubmitReject}
+ disabled={isLoading || !rejectComment.trim()}
+ variant="destructive"
+ >
+ Confirm Rejection
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ )
+}
+
+// 코멘트 추가 함수 인터페이스
+interface VendorPQReviewPageIntegratedProps {
+ data: PQGroupData[];
+ onCommentAdded: (comment: PendingComment) => void;
+}
+
+// 통합된 VendorPQReviewPage 컴포넌트
+function VendorPQReviewPageIntegrated({ data, onCommentAdded }: VendorPQReviewPageIntegratedProps) {
+ const { toast } = useToast();
+
+ // 파일 다운로드 함수 - 서버 액션 사용
+ const handleFileDownload = async (filePath: string, fileName: string) => {
+ try {
+ toast({
+ title: "Download Started",
+ description: `Preparing ${fileName} for download...`,
+ });
+
+ // 서버 액션 호출
+ const result = await downloadFileAction(filePath);
+
+ if (!result.ok || !result.data) {
+ throw new Error(result.error || 'Failed to download file');
+ }
+
+ // Base64 디코딩하여 Blob 생성
+ const binaryString = atob(result.data.content);
+ const bytes = new Uint8Array(binaryString.length);
+ for (let i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+
+ // Blob 생성 및 다운로드
+ const blob = new Blob([bytes.buffer], { type: result.data.mimeType });
+ const url = URL.createObjectURL(blob);
+
+ // 다운로드 링크 생성 및 클릭
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+
+ // 정리
+ URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ toast({
+ title: "Download Complete",
+ description: `${fileName} downloaded successfully`,
+ });
+ } catch (error) {
+ console.error('Download error:', error);
+ toast({
+ title: "Download Error",
+ description: error instanceof Error ? error.message : "Failed to download file",
+ variant: "destructive"
+ });
+ }
+ };
+
+ return (
+ <div className="space-y-4">
+ {data.map((group) => (
+ <Collapsible key={group.groupName} defaultOpen>
+ <CollapsibleTrigger asChild>
+ <div className="flex items-center justify-between cursor-pointer p-3 bg-muted rounded">
+ <h2 className="font-semibold text-lg">{group.groupName}</h2>
+ <Button variant="ghost" size="sm" className="p-0 h-7 w-7">
+ <ChevronsUpDown className="h-4 w-4" />
+ </Button>
+ </div>
+ </CollapsibleTrigger>
+
+ <CollapsibleContent>
+ <Card className="mt-2 p-4">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[60px]">Code</TableHead>
+ <TableHead>Check Point</TableHead>
+ <TableHead>Answer</TableHead>
+ <TableHead className="w-[180px]">Attachments</TableHead>
+ <TableHead className="w-[60px] text-center">Comments</TableHead>
+ </TableRow>
+ </TableHeader>
+
+ <TableBody>
+ {group.items.map((item) => (
+ <TableRow key={item.criteriaId}>
+ <TableCell className="font-medium">{item.code}</TableCell>
+ <TableCell>{item.checkPoint}</TableCell>
+
+ <TableCell>
+ {item.answer ? (
+ <p className="whitespace-pre-wrap text-sm">
+ {item.answer}
+ </p>
+ ) : (
+ <p className="text-sm text-muted-foreground">(no answer)</p>
+ )}
+ </TableCell>
+
+ <TableCell>
+ {item.attachments.length > 0 ? (
+ <ul className="list-none space-y-1">
+ {item.attachments.map((file) => (
+ <li key={file.attachId} className="text-sm flex items-center">
+ <button
+ className="text-blue-600 hover:text-blue-800 hover:underline flex items-center truncate max-w-[160px]"
+ onClick={() => handleFileDownload(file.filePath, file.fileName)}
+ >
+ <Download className="h-3 w-3 mr-1 flex-shrink-0" />
+ <span className="truncate">{file.fileName}</span>
+ </button>
+ </li>
+ ))}
+ </ul>
+ ) : (
+ <p className="text-sm text-muted-foreground">(none)</p>
+ )}
+ </TableCell>
+
+ <TableCell className="text-center">
+ <ItemCommentButton
+ item={item}
+ onCommentAdded={onCommentAdded}
+ />
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </Card>
+ </CollapsibleContent>
+ </Collapsible>
+ ))}
+ </div>
+ );
+}
+
+// 항목 코멘트 버튼 컴포넌트 props
+interface ItemCommentButtonProps {
+ item: any; // 항목 데이터
+ onCommentAdded: (comment: PendingComment) => void;
+}
+
+// 항목별 코멘트 버튼 컴포넌트 (기존 로그 표시 + 메모리에 새 코멘트 저장)
+function ItemCommentButton({ item, onCommentAdded }: ItemCommentButtonProps) {
+ const { toast } = useToast();
+ const [open, setOpen] = React.useState(false);
+ const [logs, setLogs] = React.useState<ReviewLog[]>([]);
+ const [newComment, setNewComment] = React.useState("");
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [hasComments, setHasComments] = React.useState(false);
+
+ // If there's no answerId, item wasn't answered
+ if (!item.answerId) {
+ return <p className="text-xs text-muted-foreground">N/A</p>;
+ }
+
+ // 기존 로그 가져오기
+ const fetchLogs = React.useCallback(async () => {
+ try {
+ setIsLoading(true);
+ const res = await getItemReviewLogsAction({ answerId: item.answerId });
+
+ if (res.ok && res.data) {
+ setLogs(res.data);
+ // 코멘트 존재 여부 설정
+ setHasComments(res.data.length > 0);
+ } else {
+ console.error("Error response:", res.error);
+ toast({ title: "Error", description: res.error, variant: "destructive" });
+ }
+ } catch (error) {
+ console.error("Fetch error:", error);
+ toast({ title: "Error", description: String(error), variant: "destructive" });
+ } finally {
+ setIsLoading(false);
+ }
+ }, [item.answerId, toast]);
+
+ // 초기 로드 시 코멘트 존재 여부 확인 (아이콘 색상용)
+ React.useEffect(() => {
+ const checkComments = async () => {
+ try {
+ const res = await getItemReviewLogsAction({ answerId: item.answerId });
+ if (res.ok && res.data) {
+ setHasComments(res.data.length > 0);
+ }
+ } catch (error) {
+ console.error("Error checking comments:", error);
+ }
+ };
+
+ checkComments();
+ }, [item.answerId]);
+
+ // open 상태가 변경될 때 로그 가져오기
+ React.useEffect(() => {
+ if (open) {
+ fetchLogs();
+ }
+ }, [open, fetchLogs]);
+
+ // 다이얼로그 열기
+ const handleButtonClick = React.useCallback(() => {
+ setOpen(true);
+ }, []);
+
+ // 다이얼로그 상태 변경
+ const handleOpenChange = React.useCallback((nextOpen: boolean) => {
+ setOpen(nextOpen);
+ }, []);
+
+ // 코멘트 추가 처리 (메모리에만 저장)
+ const handleAddComment = React.useCallback(() => {
+ if (!newComment.trim()) return;
+
+ setIsLoading(true);
+
+ // 새 코멘트 생성
+ const pendingComment: PendingComment = {
+ answerId: item.answerId,
+ checkPoint: item.checkPoint,
+ code: item.code,
+ comment: newComment.trim(),
+ createdAt: new Date()
+ };
+
+ // 부모 컴포넌트에 전달
+ onCommentAdded(pendingComment);
+
+ // 상태 초기화
+ setNewComment("");
+ setOpen(false);
+ setIsLoading(false);
+ }, [item, newComment, onCommentAdded]);
+
+ return (
+ <>
+ <Button variant="ghost" size="sm" onClick={handleButtonClick}>
+ <MessagesSquare
+ className={`h-4 w-4 ${hasComments ? 'text-blue-600' : ''}`}
+ />
+ </Button>
+
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>{item.checkPoint}</DialogTitle>
+ <DialogDescription>
+ Review existing comments and add new ones
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 기존 로그 섹션 */}
+ <div className="max-h-[200px] overflow-y-auto space-y-2">
+ {isLoading ? (
+ <div className="flex justify-center p-4">
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+ </div>
+ ) : logs.length > 0 ? (
+ <div className="space-y-2">
+ <h3 className="text-sm font-medium">Previous Comments:</h3>
+ {logs.map((log) => (
+ <div key={log.id} className="p-2 border rounded text-sm">
+ <p className="font-medium">{log.reviewerName}</p>
+ <p>{log.reviewerComment}</p>
+ <p className="text-xs text-muted-foreground">
+ {formatDate(log.createdAt)}
+ </p>
+ </div>
+ ))}
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">No previous comments yet.</p>
+ )}
+ </div>
+
+ {/* 구분선 */}
+ {/* <Separator /> */}
+
+ {/* 새 코멘트 추가 섹션 */}
+ <div className="space-y-2 mt-2">
+ <div className="flex items-center justify-between">
+ {/* <h3 className="text-sm font-medium">Add New Comment:</h3> */}
+ {/* <p className="text-xs text-muted-foreground">
+ Comments will be saved when you click "Request Changes"
+ </p> */}
+ </div>
+ <Textarea
+ placeholder="Add your comment..."
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ className="min-h-[100px]"
+ />
+ <Button
+ onClick={handleAddComment}
+ disabled={isLoading || !newComment.trim()}
+ >
+ Add Comment
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ </>
+ );
+} \ No newline at end of file
diff --git a/components/pq/pq-review-table.tsx b/components/pq/pq-review-table.tsx
new file mode 100644
index 00000000..e778cf91
--- /dev/null
+++ b/components/pq/pq-review-table.tsx
@@ -0,0 +1,340 @@
+"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"
+
+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);
+
+ // 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: "AdminUser",
+ });
+
+ 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)}
+ </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